// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2025 Abel Luck package provider import ( "context" "crypto/rand" "crypto/sha512" "encoding/base64" "fmt" "filippo.io/edwards25519" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) var _ resource.Resource = &TorFamilyIdentityResource{} func NewTorFamilyIdentityResource() resource.Resource { return &TorFamilyIdentityResource{} } type TorFamilyIdentityResource struct{} type TorFamilyIdentityResourceModel struct { Id types.String `tfsdk:"id"` FamilyName types.String `tfsdk:"family_name"` SecretKey types.String `tfsdk:"secret_key"` } func (r *TorFamilyIdentityResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_family_identity" } func (r *TorFamilyIdentityResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: `Generates a Tor family identity key as described in proposal 321 (Happy Families). ### References - https://community.torproject.org/relay/setup/post-install/family-ids/ - https://gitlab.torproject.org/tpo/core/torspec/-/blob/main/proposals/321-happy-families.md `, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "Base64-encoded public key (as stored in public_family_id file)", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "family_name": schema.StringAttribute{ Required: true, MarkdownDescription: "Name of the family", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "secret_key": schema.StringAttribute{ Computed: true, Sensitive: true, MarkdownDescription: "Binary contents of the secret family key file (base64 encoded)", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, }, } } func (r *TorFamilyIdentityResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { } // torExpandSeed mimics Tor's ed25519_extsk function func torExpandSeed(seed []byte) []byte { h := sha512.Sum512(seed) // Apply ed25519 bit manipulation to first 32 bytes h[0] &= 248 h[31] &= 127 h[31] |= 64 return h[:] } // torComputePublicKey mimics how Tor computes public key from expanded secret key func torComputePublicKey(expandedSK []byte) ([]byte, error) { // Tor uses only the first 32 bytes (the scalar) to compute public key scalar := expandedSK[:32] var scalarBytes [32]byte copy(scalarBytes[:], scalar) // The scalar is already clamped, so use SetBytesWithClamping s, err := edwards25519.NewScalar().SetBytesWithClamping(scalarBytes[:]) if err != nil { return nil, fmt.Errorf("failed to create scalar: %v", err) } publicKey := edwards25519.NewIdentityPoint().ScalarBaseMult(s) return publicKey.Bytes(), nil } func (r *TorFamilyIdentityResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data TorFamilyIdentityResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { resp.Diagnostics.AddError("Failed to generate random seed", err.Error()) return } expandedSK := torExpandSeed(seed) publicKey, err := torComputePublicKey(expandedSK) if err != nil { resp.Diagnostics.AddError("Failed to compute public key", err.Error()) return } // Format secret key file content following Tor's tagged format const FAMILY_KEY_FILE_TAG = "fmly-id" header := fmt.Sprintf("== ed25519v1-secret: %s ==\x00", FAMILY_KEY_FILE_TAG) secretKeyContent := append([]byte(header), expandedSK...) // Encode public key as base64 without padding (matching Tor's format) publicKeyBase64 := base64.RawStdEncoding.EncodeToString(publicKey) data.Id = types.StringValue(publicKeyBase64) data.SecretKey = types.StringValue(base64.StdEncoding.EncodeToString(secretKeyContent)) tflog.Trace(ctx, "created a family identity resource") resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *TorFamilyIdentityResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data TorFamilyIdentityResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *TorFamilyIdentityResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { resp.Diagnostics.AddError("Update not supported", "All changes to family identity require resource replacement") } func (r *TorFamilyIdentityResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { }