Implement the tor_family_identity resource

This commit is contained in:
Abel Luck 2025-06-06 10:51:40 +02:00
parent 0951242b32
commit ec57a47ba2
22 changed files with 558 additions and 67 deletions

View file

@ -61,6 +61,7 @@ func (p *ScaffoldingProvider) Resources(ctx context.Context) []func() resource.R
NewTorObfs4StateResource,
NewTorRelayIdentityRsaResource,
NewTorRelayIdentityEd25519Resource,
NewTorFamilyIdentityResource,
}
}

View file

@ -0,0 +1,161 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
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).",
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) {
}

View file

@ -0,0 +1,80 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"encoding/base64"
"encoding/hex"
"testing"
)
// Test data from a secret key generated by Tor version 0.4.9.2-alpha
// To reproduce:
// 1. Run: tor --keygen-family MyKey
// 2. This creates MyKey.secret_family_key and MyKey.public_family_id
// 3. Convert secret key to hex: xxd -p MyKey.secret_family_key | tr -d '\n'
const torGeneratedSecretKeyHex = "3d3d206564323535313976312d7365637265743a20666d6c792d6964203d3d00f84c620e227fcc5085eb538a29a11ac25abb052b6a36ddae008b307cca67fe792ecd73a67c0a7a28b2b747be2a59e5ef0c155e33217add6dac42dbc6f85a5162"
const torGeneratedPublicFamilyID = "xiHyFHgSU4/aaqPfRCrUk61jVBDQAv1hlHQd7lKIlI8"
func TestTorFamilyIdentity_SerializationAgainstTorGenerated(t *testing.T) {
secretKeyBytes, err := hex.DecodeString(torGeneratedSecretKeyHex)
if err != nil {
t.Fatalf("Failed to decode secret key hex: %v", err)
}
const expectedHeader = "== ed25519v1-secret: fmly-id ==\x00"
if len(secretKeyBytes) < len(expectedHeader) {
t.Fatalf("Secret key file too short: %d bytes", len(secretKeyBytes))
}
header := string(secretKeyBytes[:len(expectedHeader)])
if header != expectedHeader {
t.Fatalf("Invalid header: got %q, want %q", header, expectedHeader)
}
expandedSK := secretKeyBytes[len(expectedHeader):]
if len(expandedSK) != 64 {
t.Fatalf("Invalid expanded secret key length: got %d, want 64", len(expandedSK))
}
publicKey, err := torComputePublicKey(expandedSK)
if err != nil {
t.Fatalf("Failed to compute public key: %v", err)
}
computedFamilyID := base64.RawStdEncoding.EncodeToString(publicKey)
if computedFamilyID != torGeneratedPublicFamilyID {
t.Errorf("Family ID mismatch:\n computed: %s\n expected: %s",
computedFamilyID, torGeneratedPublicFamilyID)
}
}
func TestTorFamilyIdentity_ExpandSeed(t *testing.T) {
// Test that our seed expansion produces the same result as Tor
// We'll need to reverse-engineer the seed from the expanded key
// This is a secondary test to verify our torExpandSeed function
secretKeyBytes, err := hex.DecodeString(torGeneratedSecretKeyHex)
if err != nil {
t.Fatalf("Failed to decode secret key hex: %v", err)
}
const expectedHeader = "== ed25519v1-secret: fmly-id ==\x00"
expandedSK := secretKeyBytes[len(expectedHeader):]
// Verify the key is properly clamped according to ed25519 standards
// For ed25519, the clamping is:
// - bits 0,1,2 of first byte cleared (make it a multiple of 8)
// - bit 6 of byte 31 set
// - bit 7 of byte 31 cleared
if expandedSK[0]&0x07 != 0 {
t.Errorf("First byte not properly clamped: %02x (bits 0,1,2 should be cleared)", expandedSK[0])
}
if expandedSK[31]&0x40 == 0 {
t.Errorf("Byte 31 bit 6 should be set: %02x", expandedSK[31])
}
if expandedSK[31]&0x80 != 0 {
t.Errorf("Byte 31 bit 7 should be cleared: %02x", expandedSK[31])
}
}

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
)
func TestAccTorFamilyIdentityResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccTorFamilyIdentityResourceConfig("MyFamily"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("tor_family_identity.test", "family_name", "MyFamily"),
resource.TestMatchResourceAttr("tor_family_identity.test", "id", regexp.MustCompile(`^[A-Za-z0-9+/]{43}$`)),
resource.TestCheckResourceAttrSet("tor_family_identity.test", "secret_key"),
testAccCheckTorFamilyIdentitySecretKeyFormat("tor_family_identity.test", "secret_key"),
),
},
},
})
}
func testAccCheckTorFamilyIdentitySecretKeyFormat(resourceName, attributeName string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return fmt.Errorf("Resource not found: %s", resourceName)
}
secretKeyBase64 := rs.Primary.Attributes[attributeName]
if secretKeyBase64 == "" {
return fmt.Errorf("secret_key attribute is empty")
}
secretKey, err := base64.StdEncoding.DecodeString(secretKeyBase64)
if err != nil {
return fmt.Errorf("Failed to decode secret_key: %v", err)
}
if len(secretKey) != 96 {
return fmt.Errorf("secret_key has wrong size: expected 96 bytes, got %d", len(secretKey))
}
expectedHeader := "== ed25519v1-secret: fmly-id =="
actualHeader := string(secretKey[:32])
if !strings.HasPrefix(actualHeader, expectedHeader) {
return fmt.Errorf("secret_key has wrong header: expected %q, got %q", expectedHeader, actualHeader[:len(expectedHeader)])
}
if secretKey[31] != 0 {
return fmt.Errorf("secret_key header should end with null byte")
}
id := rs.Primary.Attributes["id"]
if _, err := base64.RawStdEncoding.DecodeString(id); err != nil {
return fmt.Errorf("id should be a valid base64 string without padding: %s", id)
}
if strings.HasSuffix(id, "=") {
return fmt.Errorf("id should not have padding characters: %s", id)
}
return nil
}
}
func testAccTorFamilyIdentityResourceConfig(familyName string) string {
return fmt.Sprintf(`
resource "tor_family_identity" "test" {
family_name = %[1]q
}
`, familyName)
}