From 53f019906ed1717c68d65d8b0f1ab20bd3fd467e Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Fri, 6 Jun 2025 10:51:40 +0200 Subject: [PATCH 1/6] Implement the tor_family_identity resource --- .gitignore | 20 ++- CONTRIBUTING.md | 9 + GNUmakefile | 27 +-- docs/resources/family_identity.md | 42 +++++ e2e-test/setup-dev.sh | 48 ------ {e2e-test => e2e-tests/obfs4}/README.md | 0 {e2e-test => e2e-tests/obfs4}/main.tf | 12 +- e2e-tests/obfs4/terraformrc | 15 ++ e2e-tests/obfs4/test.sh | 7 + e2e-tests/obfs4/tf | 3 + e2e-tests/setup.sh | 14 ++ e2e-tests/tor-family/main.tf | 44 +++++ e2e-tests/tor-family/terraformrc | 15 ++ e2e-tests/tor-family/test.sh | 34 ++++ {e2e-test => e2e-tests/tor-family}/tf | 3 +- .../resources/tor_family_identity/resource.tf | 14 ++ flake.nix | 1 + go.mod | 2 +- internal/provider/provider.go | 1 + .../provider/tor_family_identity_resource.go | 161 ++++++++++++++++++ ...ly_identity_resource_serialization_test.go | 80 +++++++++ .../tor_family_identity_resource_test.go | 85 +++++++++ 22 files changed, 563 insertions(+), 74 deletions(-) create mode 100644 docs/resources/family_identity.md delete mode 100755 e2e-test/setup-dev.sh rename {e2e-test => e2e-tests/obfs4}/README.md (100%) rename {e2e-test => e2e-tests/obfs4}/main.tf (87%) create mode 100644 e2e-tests/obfs4/terraformrc create mode 100755 e2e-tests/obfs4/test.sh create mode 100755 e2e-tests/obfs4/tf create mode 100755 e2e-tests/setup.sh create mode 100644 e2e-tests/tor-family/main.tf create mode 100644 e2e-tests/tor-family/terraformrc create mode 100755 e2e-tests/tor-family/test.sh rename {e2e-test => e2e-tests/tor-family}/tf (68%) create mode 100644 examples/resources/tor_family_identity/resource.tf create mode 100644 internal/provider/tor_family_identity_resource.go create mode 100644 internal/provider/tor_family_identity_resource_serialization_test.go create mode 100644 internal/provider/tor_family_identity_resource_test.go diff --git a/.gitignore b/.gitignore index 23b12a9..e4c5140 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ +.* *.dll *.exe -.DS_Store example.tf terraform.tfplan terraform.tfstate @@ -15,12 +15,9 @@ website/node_modules .vagrant/ *.backup ./*.tfstate -.terraform/ *.log *.bak *~ -.*.swp -.idea *.iml *.test *.iml @@ -35,9 +32,14 @@ website/vendor *.winfile eol=crlf terraform-provider-tor dev/ -CLAUDE.md +*.md +!README.md +!CHANGELOG.md +!CONTRIBUTING.md extra -.direnv -.claude -e2e-test/*tfstate* -e2e-test/.terraformrc +e2e-tests/**/*tfstate* +e2e-tests/**/providers +e2e-tests/**/.terraform.lock.hcl +e2e-tests/**/torrc +e2e-tests/**/*.secret_family_key +e2e-tests/**/data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8d705a..51541aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,14 @@ make lint # Run acceptance tests (creates real resources) make testacc + +# Run e2e tests +make clean build e2e/tor-family # Run the tor-family test with latest binary +make clean build e2e/obfs4 # Run the obfs4 e2e test with latest binary +make e2e # Run all e2e tests + +# Important: Always use 'make clean build' before e2e tests to ensure +# the test uses the latest provider binary ``` ## Project Structure @@ -49,6 +57,7 @@ make testacc │ ├── provider/ # Provider configuration examples │ └── resources/ # Resource examples ├── docs/ # Generated documentation +├── e2e-tests/ # End-to-end tests ``` ## Security Considerations diff --git a/GNUmakefile b/GNUmakefile index a705903..4a0b2eb 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,13 +1,19 @@ default: fmt lint install generate -clean: +dist-clean: go clean -cache -modcache -testcache +clean: + rm -f ./terraform-provider-tor ./provider.test + find e2e-tests -type f -name '*.tfstate*' -delete + find e2e-tests -type d -name '.terraform' -prune -exec rm -rf ./{} \; + find e2e-tests -type d -name 'providers' -prune -exec rm -rf ./{} \; + build: - go build -v ./... + go build install: build - go install -v ./... + go install lint: go mod tidy @@ -33,16 +39,11 @@ test: testacc: TF_ACC=1 go test -v -cover -timeout 120m ./... -e2e: - @echo "Running end-to-end test..." - cd e2e-test && \ - rm -rf .terraform/ .terraform.lock.hcl terraform.tfstate* && \ - ./setup-dev.sh && \ - TF_CLI_CONFIG_FILE=.terraformrc tofu plan && \ - TF_CLI_CONFIG_FILE=.terraformrc tofu apply -auto-approve && \ - echo "✓ E2E test passed! Cleaning up..." && \ - TF_CLI_CONFIG_FILE=.terraformrc tofu destroy -auto-approve && \ - echo "✓ E2E test completed successfully" +e2e/%: + @echo "Executing end-to-end test: $*" + cd e2e-tests/$* && ./test.sh + +e2e: clean build e2e/obfs4 e2e/tor-family ci: @echo "Running CI pipeline..." diff --git a/docs/resources/family_identity.md b/docs/resources/family_identity.md new file mode 100644 index 0000000..1301075 --- /dev/null +++ b/docs/resources/family_identity.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "tor_family_identity Resource - tor" +subcategory: "" +description: |- + Generates a Tor family identity key as described in proposal 321 (Happy Families). +--- + +# tor_family_identity (Resource) + +Generates a Tor family identity key as described in proposal 321 (Happy Families). + +## Example Usage + +```terraform +resource "tor_family_identity" "example" { + family_name = "MyFamily" +} + +# Access the generated family identity +output "family_id" { + value = tor_family_identity.example.id +} + +# Use the secret key to configure your Tor relay +resource "local_sensitive_file" "family_key" { + content_base64 = tor_family_identity.example.secret_key + filename = "MyFamily.secret_family_key" +} +``` + + +## Schema + +### Required + +- `family_name` (String) Name of the family + +### Read-Only + +- `id` (String) Base64-encoded public key (as stored in public_family_id file) +- `secret_key` (String, Sensitive) Binary contents of the secret family key file (base64 encoded) diff --git a/e2e-test/setup-dev.sh b/e2e-test/setup-dev.sh deleted file mode 100755 index 1b98732..0000000 --- a/e2e-test/setup-dev.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "Setting up development environment for terraform-provider-tor..." - -# Get the Go bin path -GOBIN=$(go env GOPATH)/bin -if [ -z "$GOBIN" ]; then - GOBIN=$(go env GOROOT)/bin -fi - -echo "Go bin path: $GOBIN" - -# Create local .terraformrc with dev overrides -TERRAFORMRC="$(pwd)/.terraformrc" -echo "Creating $TERRAFORMRC..." - -# Create local .terraformrc with dev overrides -cat > "$TERRAFORMRC" << EOF -provider_installation { - dev_overrides { - "guardianproject/tor" = "$GOBIN" - } - direct {} -} -EOF - -echo "✓ Created local $TERRAFORMRC with dev overrides" - -# Build and install the provider -echo "Building and installing provider..." -cd "$(dirname "$0")/.." - -# Build with proper naming for dev overrides -go build -o "$GOBIN/terraform-provider-tor_v99.0.0" - -echo "✓ Provider built and installed to $GOBIN/terraform-provider-tor_v99.0.0" - -echo "" -echo "Setup complete! You can now run:" -echo " cd e2e-test" -echo " ./tf plan" -echo " ./tf apply" -echo "" -echo "Or use the full command:" -echo " TF_CLI_CONFIG_FILE=.terraformrc tofu plan" -echo "" -echo "Note: Using local .terraformrc to avoid modifying your global configuration." diff --git a/e2e-test/README.md b/e2e-tests/obfs4/README.md similarity index 100% rename from e2e-test/README.md rename to e2e-tests/obfs4/README.md diff --git a/e2e-test/main.tf b/e2e-tests/obfs4/main.tf similarity index 87% rename from e2e-test/main.tf rename to e2e-tests/obfs4/main.tf index 8c3ecea..08a3d97 100644 --- a/e2e-test/main.tf +++ b/e2e-tests/obfs4/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { tor = { - source = "guardianproject/tor" + source = "guardianproject/tor" version = "99.0.0" } } @@ -15,6 +15,11 @@ resource "tor_relay_identity_rsa" "bridge" {} # Generate Ed25519 identity key for the bridge resource "tor_relay_identity_ed25519" "bridge" {} +# Generate family identity for the bridge +resource "tor_family_identity" "bridge" { + family_name = "MyBridgeFamily" +} + # Generate obfs4 state using the identity keys resource "tor_obfs4_state" "bridge" { rsa_identity_private_key = tor_relay_identity_rsa.bridge.private_key_pem @@ -60,3 +65,8 @@ output "bridge_line" { description = "Complete bridge line for clients" value = data.tor_obfs4_bridge_line.bridge.bridge_line } + +output "family_id" { + description = "Family ID for the bridge" + value = tor_family_identity.bridge.id +} diff --git a/e2e-tests/obfs4/terraformrc b/e2e-tests/obfs4/terraformrc new file mode 100644 index 0000000..553f6dd --- /dev/null +++ b/e2e-tests/obfs4/terraformrc @@ -0,0 +1,15 @@ + provider_installation { + filesystem_mirror { + path = "./providers" + include = [ + "registry.terraform.io/guardianproject/*", + "registry.opentofu.org/guardianproject/*" + ] + } + direct { + exclude = [ + "registry.terraform.io/guardianproject/*", + "registry.opentofu.org/guardianproject/*" + ] + } + } diff --git a/e2e-tests/obfs4/test.sh b/e2e-tests/obfs4/test.sh new file mode 100755 index 0000000..5a94526 --- /dev/null +++ b/e2e-tests/obfs4/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -e +../setup.sh +rm -f terraform.tfstate* +./tf init +./tf plan +./tf apply -auto-approve diff --git a/e2e-tests/obfs4/tf b/e2e-tests/obfs4/tf new file mode 100755 index 0000000..1b3ad19 --- /dev/null +++ b/e2e-tests/obfs4/tf @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +export TF_CLI_CONFIG_FILE=terraformrc +exec tofu "$@" diff --git a/e2e-tests/setup.sh b/e2e-tests/setup.sh new file mode 100755 index 0000000..fdd911f --- /dev/null +++ b/e2e-tests/setup.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e +rm -f ./.terraform.lock.hcl + +mkdir -p providers/registry.terraform.io/guardianproject/tor/99.0.0/linux_amd64 +mkdir -p providers/registry.opentofu.org/guardianproject/tor/99.0.0/linux_amd64 +cp ../../terraform-provider-tor providers/registry.terraform.io/guardianproject/tor/99.0.0/linux_amd64 +cp ../../terraform-provider-tor providers/registry.opentofu.org/guardianproject/tor/99.0.0/linux_amd64 + +echo "" +echo "Setup complete! You can now run:" +echo " ./tf init" +echo " ./tf plan" +echo " ./tf apply" diff --git a/e2e-tests/tor-family/main.tf b/e2e-tests/tor-family/main.tf new file mode 100644 index 0000000..e851a18 --- /dev/null +++ b/e2e-tests/tor-family/main.tf @@ -0,0 +1,44 @@ +terraform { + required_providers { + tor = { + source = "guardianproject/tor" + version = "99.0.0" + } + local = { + source = "hashicorp/local" + version = "2.5.3" + } + } +} + +provider "tor" {} + +resource "tor_family_identity" "this" { + family_name = "MyFamily" +} + +resource "local_sensitive_file" "family_key" { + content_base64 = tor_family_identity.this.secret_key + filename = "./data/keys/MyKey.secret_family_key" + file_permission = "0600" +} + +resource "local_file" "this" { + filename = "./torrc" + content = < +Nickname PickANickname +EOF +} + + +output "family_id" { + description = "Family ID for the bridge" + value = tor_family_identity.this.id +} diff --git a/e2e-tests/tor-family/terraformrc b/e2e-tests/tor-family/terraformrc new file mode 100644 index 0000000..553f6dd --- /dev/null +++ b/e2e-tests/tor-family/terraformrc @@ -0,0 +1,15 @@ + provider_installation { + filesystem_mirror { + path = "./providers" + include = [ + "registry.terraform.io/guardianproject/*", + "registry.opentofu.org/guardianproject/*" + ] + } + direct { + exclude = [ + "registry.terraform.io/guardianproject/*", + "registry.opentofu.org/guardianproject/*" + ] + } + } diff --git a/e2e-tests/tor-family/test.sh b/e2e-tests/tor-family/test.sh new file mode 100755 index 0000000..5071b57 --- /dev/null +++ b/e2e-tests/tor-family/test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env sh +set -e +../setup.sh +rm -f terraform.tfstate* +./tf init +./tf plan +./tf apply -auto-approve + +set +e + +# Start tor and let it run for a few seconds +echo "Starting Tor to verify family key..." +timeout 5 tor -f ./torrc >tor.log 2>&1 +TOR_EXIT_CODE=$? + +set -e + +# Check if tor exited with an error (not due to timeout) +# timeout returns 124 when it kills the process +if [ $TOR_EXIT_CODE -ne 0 ] && [ $TOR_EXIT_CODE -ne 124 ]; then + echo "ERROR: Tor exited with error code $TOR_EXIT_CODE" + cat tor.log + exit 1 +fi + +# Check if tor started bootstrapping (indicates successful key loading) +if grep -q "Bootstrapped [0-9]" tor.log; then + echo "SUCCESS: Tor started bootstrapping with generated family key" + exit 0 +else + echo "ERROR: Tor did not start bootstrapping" + cat tor.log + exit 1 +fi diff --git a/e2e-test/tf b/e2e-tests/tor-family/tf similarity index 68% rename from e2e-test/tf rename to e2e-tests/tor-family/tf index 2df7ce0..e4fd76b 100755 --- a/e2e-test/tf +++ b/e2e-tests/tor-family/tf @@ -1,5 +1,4 @@ #!/usr/bin/env bash # Wrapper script to run terraform with local config - -export TF_CLI_CONFIG_FILE=.terraformrc +export TF_CLI_CONFIG_FILE=terraformrc exec tofu "$@" diff --git a/examples/resources/tor_family_identity/resource.tf b/examples/resources/tor_family_identity/resource.tf new file mode 100644 index 0000000..fa4b1dd --- /dev/null +++ b/examples/resources/tor_family_identity/resource.tf @@ -0,0 +1,14 @@ +resource "tor_family_identity" "example" { + family_name = "MyFamily" +} + +# Access the generated family identity +output "family_id" { + value = tor_family_identity.example.id +} + +# Use the secret key to configure your Tor relay +resource "local_sensitive_file" "family_key" { + content_base64 = tor_family_identity.example.secret_key + filename = "MyFamily.secret_family_key" +} diff --git a/flake.nix b/flake.nix index da879d6..363a18f 100644 --- a/flake.nix +++ b/flake.nix @@ -57,6 +57,7 @@ default = pkgs.mkShell { packages = [ pkgs.go + pkgs.gopls pkgs.golangci-lint pkgs.obfs4 pkgs.gnumake diff --git a/go.mod b/go.mod index d1e3b10..29b37a2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.24.2 require ( + filippo.io/edwards25519 v1.1.0 github.com/hashicorp/terraform-plugin-framework v1.12.0 github.com/hashicorp/terraform-plugin-go v0.24.0 github.com/hashicorp/terraform-plugin-log v0.9.0 @@ -13,7 +14,6 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/ProtonMail/go-crypto v1.1.0-alpha.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a81f6e3..eca8392 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -61,6 +61,7 @@ func (p *ScaffoldingProvider) Resources(ctx context.Context) []func() resource.R NewTorObfs4StateResource, NewTorRelayIdentityRsaResource, NewTorRelayIdentityEd25519Resource, + NewTorFamilyIdentityResource, } } diff --git a/internal/provider/tor_family_identity_resource.go b/internal/provider/tor_family_identity_resource.go new file mode 100644 index 0000000..f68bbd7 --- /dev/null +++ b/internal/provider/tor_family_identity_resource.go @@ -0,0 +1,161 @@ +// 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).", + + 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) { +} diff --git a/internal/provider/tor_family_identity_resource_serialization_test.go b/internal/provider/tor_family_identity_resource_serialization_test.go new file mode 100644 index 0000000..328e450 --- /dev/null +++ b/internal/provider/tor_family_identity_resource_serialization_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 Abel Luck + +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]) + } +} diff --git a/internal/provider/tor_family_identity_resource_test.go b/internal/provider/tor_family_identity_resource_test.go new file mode 100644 index 0000000..6e742b2 --- /dev/null +++ b/internal/provider/tor_family_identity_resource_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2025 Abel Luck + +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) +} From f60d0d655b28e09235c49750cecfab0a5d6c95b1 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Fri, 6 Jun 2025 13:02:05 +0200 Subject: [PATCH 2/6] Update documentation --- docs/resources/family_identity.md | 19 ++++++++++++++----- .../resources/tor_family_identity/resource.tf | 13 ++++++++----- .../provider/tor_family_identity_resource.go | 7 ++++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/resources/family_identity.md b/docs/resources/family_identity.md index 1301075..8fe89f0 100644 --- a/docs/resources/family_identity.md +++ b/docs/resources/family_identity.md @@ -4,12 +4,18 @@ page_title: "tor_family_identity Resource - tor" subcategory: "" description: |- 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 --- # tor_family_identity (Resource) 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 + ## Example Usage ```terraform @@ -17,16 +23,19 @@ resource "tor_family_identity" "example" { family_name = "MyFamily" } -# Access the generated family identity -output "family_id" { - value = tor_family_identity.example.id -} -# Use the secret key to configure your Tor relay resource "local_sensitive_file" "family_key" { content_base64 = tor_family_identity.example.secret_key filename = "MyFamily.secret_family_key" } + +resource "local_file" "torrc" { + filename = "./torrc" + content = < diff --git a/examples/resources/tor_family_identity/resource.tf b/examples/resources/tor_family_identity/resource.tf index fa4b1dd..3bccb40 100644 --- a/examples/resources/tor_family_identity/resource.tf +++ b/examples/resources/tor_family_identity/resource.tf @@ -2,13 +2,16 @@ resource "tor_family_identity" "example" { family_name = "MyFamily" } -# Access the generated family identity -output "family_id" { - value = tor_family_identity.example.id -} -# Use the secret key to configure your Tor relay resource "local_sensitive_file" "family_key" { content_base64 = tor_family_identity.example.secret_key filename = "MyFamily.secret_family_key" } + +resource "local_file" "torrc" { + filename = "./torrc" + content = < Date: Fri, 6 Jun 2025 13:04:33 +0200 Subject: [PATCH 3/6] Update README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 34ee6de..3ff504d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,22 @@ terraform { provider "tor" {} +# Generate family (tor 0.4.9.2-alpha or later) +resource "tor_family_identity" "example" { + family_name = "MyFamily" +} + +output "family_id" { + description = "Family ID for the bridge" + value = tor_family_identity.this.id +} + +resource "local_sensitive_file" "family_key" { + content_base64 = tor_family_identity.this.secret_key + filename = "./data/keys/MyKey.secret_family_key" + file_permission = "0600" +} + # Generate relay identity keys resource "tor_relay_identity_rsa" "bridge" {} From 83df31ec800d25fd49cbb8a931e3b4d3e081f561 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Wed, 10 Sep 2025 13:01:01 +0200 Subject: [PATCH 4/6] Fix incorrect RSA identity key fingerprint generation The fingerprint calculation was using PKIX encoding instead of the required PKCS1 DER encoding for RSA public keys. This affected both the relay identity resource and obfs4 node ID derivation. - Use x509.MarshalPKCS1PublicKey instead of x509.MarshalPKIXPublicKey - Add test case with known fingerprint vector to prevent regression - Update both generateFingerprints and deriveNodeIdFromRsaKey functions fixes #2 --- .../tor_obfs4_bridge_line_data_source_test.go | 1 - internal/provider/tor_obfs4_state_resource.go | 6 +-- ...or_relay_identity_ed25519_resource_test.go | 1 - .../tor_relay_identity_rsa_resource.go | 5 +-- .../tor_relay_identity_rsa_resource_test.go | 44 ++++++++++++++++++- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/internal/provider/tor_obfs4_bridge_line_data_source_test.go b/internal/provider/tor_obfs4_bridge_line_data_source_test.go index d600fe0..d4032a2 100644 --- a/internal/provider/tor_obfs4_bridge_line_data_source_test.go +++ b/internal/provider/tor_obfs4_bridge_line_data_source_test.go @@ -31,7 +31,6 @@ func TestTorObfs4BridgeLineDataSource(t *testing.T) { // Check computed values are generated resource.TestCheckResourceAttrSet("data.tor_obfs4_bridge_line.test", "bridge_line"), - // Check bridge line format resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443 [0-9a-f]{40} cert=[A-Za-z0-9+/]+ iat-mode=[0-2]$`)), diff --git a/internal/provider/tor_obfs4_state_resource.go b/internal/provider/tor_obfs4_state_resource.go index 97e8dd5..7fe87b3 100644 --- a/internal/provider/tor_obfs4_state_resource.go +++ b/internal/provider/tor_obfs4_state_resource.go @@ -406,11 +406,7 @@ func (r *TorObfs4StateResource) deriveNodeIdFromRsaKey(rsaPrivateKeyPem string) return nil, fmt.Errorf("failed to parse RSA private key: %w", err) } - // Extract the public key and encode it - publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal public key: %w", err) - } + publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) // Generate SHA1 hash of public key (this is the relay fingerprint/node ID) hash := sha1.Sum(publicKeyBytes) diff --git a/internal/provider/tor_relay_identity_ed25519_resource_test.go b/internal/provider/tor_relay_identity_ed25519_resource_test.go index 7b0873e..50396ea 100644 --- a/internal/provider/tor_relay_identity_ed25519_resource_test.go +++ b/internal/provider/tor_relay_identity_ed25519_resource_test.go @@ -27,7 +27,6 @@ func TestAccTorRelayIdentityEd25519Resource(t *testing.T) { // Verify PEM format resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN PRIVATE KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)), - // Verify fingerprint format (64 hex characters for SHA256) resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)), ), }, diff --git a/internal/provider/tor_relay_identity_rsa_resource.go b/internal/provider/tor_relay_identity_rsa_resource.go index 2a6e453..f7f5e00 100644 --- a/internal/provider/tor_relay_identity_rsa_resource.go +++ b/internal/provider/tor_relay_identity_rsa_resource.go @@ -196,10 +196,7 @@ func (r *TorRelayIdentityRsaResource) encodePublicKeyPEM(publicKey *rsa.PublicKe } func (r *TorRelayIdentityRsaResource) generateFingerprints(publicKey *rsa.PublicKey) (string, string, error) { - publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey) - if err != nil { - return "", "", err - } + publicKeyBytes := x509.MarshalPKCS1PublicKey(publicKey) sha1Sum := sha1.Sum(publicKeyBytes) sha256Sum := sha256.Sum256(publicKeyBytes) diff --git a/internal/provider/tor_relay_identity_rsa_resource_test.go b/internal/provider/tor_relay_identity_rsa_resource_test.go index f670f89..a3df3cd 100644 --- a/internal/provider/tor_relay_identity_rsa_resource_test.go +++ b/internal/provider/tor_relay_identity_rsa_resource_test.go @@ -4,6 +4,8 @@ package provider import ( + "crypto/x509" + "encoding/pem" "regexp" "testing" @@ -28,7 +30,6 @@ func TestAccTorRelayIdentityRsaResource(t *testing.T) { // Verify PEM format resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)), - // Verify fingerprint formats (hex strings) resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1", regexp.MustCompile(`^[0-9a-f]{40}$`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)), ), @@ -37,6 +38,47 @@ func TestAccTorRelayIdentityRsaResource(t *testing.T) { }) } +func TestRsaFingerprintGeneration(t *testing.T) { + testRsaPrivateKeyPem := `-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDxZrR1gsB00rE5Rift0y35j/F5Jt03ExlW0H7fUI+4W2MyZ+pE +0CM512o1tshfpDVxtVrnJuI8dFAVgO9Ct7aEKYTNk++g9llF+Q0M4nIrKftv/4Fo +MlFHwa+C41LGiucauaagiatiESNWNK3FhOLx1Who9duUN5SVVFyXpApPrQIDAQAB +AoGAJya2G9zh49CMB7L2JN88NJ6A1lpURGtnj6nu+b7yID9KHlG2MATlwarLQfzs +EH7sYA2+uYCX7qAaoPIxW8u54PLKbgVD3Kh7eXBO0isIx4FPMHD4khv5CLiNVopI +VwStg9Fv3FZ6h2+2FTVfLK4+xuwfyoShUwfEp3eV7c8YRSECQQD1ntdJrokM2zWR +hf+Cl/L62tl58hSYVZoSrR+b0cTQlbN1rnYTvY+1jXbBP8fKFBoAHKZ1xMCY/m67 +H7qt+nalAkEA+5o4GQb6YCHYJZ/lhGQFaSGnWnKE16MsW1xLimQY8gOMbg3AYcXk +B8fylmp/XpNG1/PC+M6m5C0DjI85eKYuaQJBAJ2eOPmHj1s4sL+aBcWATOS93CFt +P9oh1KV3g3kyu+I+rtMuCYfRdY9EIJkSnNsI20aHHCsm/5EudVCPo/RRbiECQEQu +psUhfvhOM6T+j9QwxsaWuCNqpVVKgtq/SDlYpunuzD+GunvEhOcW6Eaa1alrf+dF +x7BlUBTFnhCZP5nSbwECQGgUr7jW/xrwbkDAP3+ql6o0yyhLMtvIqAKk3fUWxPXO +OhEqFiIYW5mI//JWsqSZZxy4nMqgejKkrRgOOQbL0NE= +-----END RSA PRIVATE KEY-----` + + expectedSha1Fingerprint := "da5cec632a9a544394403bd533e1a7bde2f26edd" + + block, _ := pem.Decode([]byte(testRsaPrivateKeyPem)) + if block == nil { + t.Fatal("failed to decode PEM block") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("failed to parse RSA private key: %v", err) + } + + resource := &TorRelayIdentityRsaResource{} + sha1Fingerprint, _, err := resource.generateFingerprints(&privateKey.PublicKey) + if err != nil { + t.Fatalf("failed to generate fingerprint: %v", err) + } + + if sha1Fingerprint != expectedSha1Fingerprint { + t.Errorf("SHA1 fingerprint mismatch:\nExpected: %s\nActual: %s", + expectedSha1Fingerprint, sha1Fingerprint) + } +} + const testAccTorRelayIdentityRsaResourceConfig = ` resource "tor_relay_identity_rsa" "test" {} ` From 62b243c8e4c6ff31ad238012c5aef4027accdb39 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Wed, 10 Sep 2025 13:22:11 +0200 Subject: [PATCH 5/6] Fix RSA fingerprint generation to use PKCS1 encoding and uppercase format related #2 --- internal/provider/tor_obfs4_bridge_line_data_source_test.go | 2 +- internal/provider/tor_relay_identity_ed25519_resource.go | 2 +- .../provider/tor_relay_identity_ed25519_resource_test.go | 2 +- internal/provider/tor_relay_identity_rsa_resource.go | 4 ++-- internal/provider/tor_relay_identity_rsa_resource_test.go | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/provider/tor_obfs4_bridge_line_data_source_test.go b/internal/provider/tor_obfs4_bridge_line_data_source_test.go index d4032a2..3f30230 100644 --- a/internal/provider/tor_obfs4_bridge_line_data_source_test.go +++ b/internal/provider/tor_obfs4_bridge_line_data_source_test.go @@ -32,7 +32,7 @@ func TestTorObfs4BridgeLineDataSource(t *testing.T) { resource.TestCheckResourceAttrSet("data.tor_obfs4_bridge_line.test", "bridge_line"), resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test", "bridge_line", - regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443 [0-9a-f]{40} cert=[A-Za-z0-9+/]+ iat-mode=[0-2]$`)), + regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443 [0-9A-F]{40} cert=[A-Za-z0-9+/]+ iat-mode=[0-2]$`)), // Check that input values are used correctly resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "identity_fingerprint_sha1", "tor_relay_identity_rsa.test", "public_key_fingerprint_sha1"), diff --git a/internal/provider/tor_relay_identity_ed25519_resource.go b/internal/provider/tor_relay_identity_ed25519_resource.go index 4a3f29d..3a58a8f 100644 --- a/internal/provider/tor_relay_identity_ed25519_resource.go +++ b/internal/provider/tor_relay_identity_ed25519_resource.go @@ -184,5 +184,5 @@ func (r *TorRelayIdentityEd25519Resource) encodePublicKeyPEM(publicKey ed25519.P func (r *TorRelayIdentityEd25519Resource) generateSha256Fingerprint(publicKey ed25519.PublicKey) string { publicKeyBytes, _ := x509.MarshalPKIXPublicKey(publicKey) sha256Sum := sha256.Sum256(publicKeyBytes) - return fmt.Sprintf("%x", sha256Sum) + return fmt.Sprintf("%X", sha256Sum) } diff --git a/internal/provider/tor_relay_identity_ed25519_resource_test.go b/internal/provider/tor_relay_identity_ed25519_resource_test.go index 50396ea..77f2e07 100644 --- a/internal/provider/tor_relay_identity_ed25519_resource_test.go +++ b/internal/provider/tor_relay_identity_ed25519_resource_test.go @@ -27,7 +27,7 @@ func TestAccTorRelayIdentityEd25519Resource(t *testing.T) { // Verify PEM format resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN PRIVATE KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)), - resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)), + resource.TestMatchResourceAttr("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9A-F]{64}$`)), ), }, }, diff --git a/internal/provider/tor_relay_identity_rsa_resource.go b/internal/provider/tor_relay_identity_rsa_resource.go index f7f5e00..ed34264 100644 --- a/internal/provider/tor_relay_identity_rsa_resource.go +++ b/internal/provider/tor_relay_identity_rsa_resource.go @@ -201,8 +201,8 @@ func (r *TorRelayIdentityRsaResource) generateFingerprints(publicKey *rsa.Public sha1Sum := sha1.Sum(publicKeyBytes) sha256Sum := sha256.Sum256(publicKeyBytes) - sha1Fingerprint := fmt.Sprintf("%x", sha1Sum) - sha256Fingerprint := fmt.Sprintf("%x", sha256Sum) + sha1Fingerprint := fmt.Sprintf("%X", sha1Sum) + sha256Fingerprint := fmt.Sprintf("%X", sha256Sum) return sha1Fingerprint, sha256Fingerprint, nil } diff --git a/internal/provider/tor_relay_identity_rsa_resource_test.go b/internal/provider/tor_relay_identity_rsa_resource_test.go index a3df3cd..757615f 100644 --- a/internal/provider/tor_relay_identity_rsa_resource_test.go +++ b/internal/provider/tor_relay_identity_rsa_resource_test.go @@ -30,8 +30,8 @@ func TestAccTorRelayIdentityRsaResource(t *testing.T) { // Verify PEM format resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)), - resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1", regexp.MustCompile(`^[0-9a-f]{40}$`)), - resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9a-f]{64}$`)), + resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1", regexp.MustCompile(`^[0-9A-F]{40}$`)), + resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9A-F]{64}$`)), ), }, }, @@ -55,7 +55,7 @@ x7BlUBTFnhCZP5nSbwECQGgUr7jW/xrwbkDAP3+ql6o0yyhLMtvIqAKk3fUWxPXO OhEqFiIYW5mI//JWsqSZZxy4nMqgejKkrRgOOQbL0NE= -----END RSA PRIVATE KEY-----` - expectedSha1Fingerprint := "da5cec632a9a544394403bd533e1a7bde2f26edd" + expectedSha1Fingerprint := "DA5CEC632A9A544394403BD533E1A7BDE2F26EDD" block, _ := pem.Decode([]byte(testRsaPrivateKeyPem)) if block == nil { From 4eadc8416e79f0d4a1b90dbdc5b6f747db2a2719 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Wed, 10 Sep 2025 13:38:05 +0200 Subject: [PATCH 6/6] Add hashed fingerprint functionality to RSA relay identity keys fixes #3 --- README.md | 5 +++ docs/resources/relay_identity_rsa.md | 6 +++ .../tor_relay_identity_rsa/resource.tf | 5 +++ .../tor_relay_identity_rsa_resource.go | 42 ++++++++++++++++--- .../tor_relay_identity_rsa_resource_test.go | 18 ++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3ff504d..5a7a157 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ output "rsa_identity_pem" { sensitive = true } +output "rsa_fingerprint_hashed" { + description = "Hashed RSA fingerprint for privacy in monitoring systems" + value = tor_relay_identity_rsa.bridge.public_key_fingerprint_sha1_hashed +} + output "ed25519_identity_pem" { description = "Ed25519 identity private key for bridge configuration" value = tor_relay_identity_ed25519.bridge.private_key_pem diff --git a/docs/resources/relay_identity_rsa.md b/docs/resources/relay_identity_rsa.md index b67920d..6b1599b 100644 --- a/docs/resources/relay_identity_rsa.md +++ b/docs/resources/relay_identity_rsa.md @@ -49,6 +49,11 @@ output "public_key_fingerprint_sha256" { description = "SHA256 fingerprint of the RSA public key" value = tor_relay_identity_rsa.example.public_key_fingerprint_sha256 } + +output "public_key_fingerprint_sha1_hashed" { + description = "Hashed SHA1 fingerprint of the RSA public key for privacy in monitoring systems" + value = tor_relay_identity_rsa.example.public_key_fingerprint_sha1_hashed +} ``` @@ -60,5 +65,6 @@ output "public_key_fingerprint_sha256" { - `id` (String) Unique identifier based on public key fingerprint - `private_key_pem` (String, Sensitive) Private key data in PEM (RFC 1421) format - `public_key_fingerprint_sha1` (String) SHA1 fingerprint of the public key in hex format +- `public_key_fingerprint_sha1_hashed` (String) SHA1 hash of the binary form of the SHA1 fingerprint in hex format - `public_key_fingerprint_sha256` (String) SHA256 fingerprint of the public key in hex format - `public_key_pem` (String) Public key data in PEM (RFC 1421) format diff --git a/examples/resources/tor_relay_identity_rsa/resource.tf b/examples/resources/tor_relay_identity_rsa/resource.tf index d88113f..76c6203 100644 --- a/examples/resources/tor_relay_identity_rsa/resource.tf +++ b/examples/resources/tor_relay_identity_rsa/resource.tf @@ -33,4 +33,9 @@ output "public_key_fingerprint_sha1" { output "public_key_fingerprint_sha256" { description = "SHA256 fingerprint of the RSA public key" value = tor_relay_identity_rsa.example.public_key_fingerprint_sha256 +} + +output "public_key_fingerprint_sha1_hashed" { + description = "Hashed SHA1 fingerprint of the RSA public key for privacy in monitoring systems" + value = tor_relay_identity_rsa.example.public_key_fingerprint_sha1_hashed } \ No newline at end of file diff --git a/internal/provider/tor_relay_identity_rsa_resource.go b/internal/provider/tor_relay_identity_rsa_resource.go index ed34264..7cc1e8b 100644 --- a/internal/provider/tor_relay_identity_rsa_resource.go +++ b/internal/provider/tor_relay_identity_rsa_resource.go @@ -10,6 +10,7 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/x509" + "encoding/hex" "encoding/pem" "fmt" @@ -30,12 +31,13 @@ func NewTorRelayIdentityRsaResource() resource.Resource { type TorRelayIdentityRsaResource struct{} type TorRelayIdentityRsaResourceModel struct { - Id types.String `tfsdk:"id"` - Algorithm types.String `tfsdk:"algorithm"` - PrivateKeyPem types.String `tfsdk:"private_key_pem"` - PublicKeyPem types.String `tfsdk:"public_key_pem"` - PublicKeyFingerprintSha1 types.String `tfsdk:"public_key_fingerprint_sha1"` - PublicKeyFingerprintSha256 types.String `tfsdk:"public_key_fingerprint_sha256"` + Id types.String `tfsdk:"id"` + Algorithm types.String `tfsdk:"algorithm"` + PrivateKeyPem types.String `tfsdk:"private_key_pem"` + PublicKeyPem types.String `tfsdk:"public_key_pem"` + PublicKeyFingerprintSha1 types.String `tfsdk:"public_key_fingerprint_sha1"` + PublicKeyFingerprintSha256 types.String `tfsdk:"public_key_fingerprint_sha256"` + PublicKeyFingerprintSha1Hashed types.String `tfsdk:"public_key_fingerprint_sha1_hashed"` } func (r *TorRelayIdentityRsaResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -90,6 +92,13 @@ func (r *TorRelayIdentityRsaResource) Schema(ctx context.Context, req resource.S stringplanmodifier.UseStateForUnknown(), }, }, + "public_key_fingerprint_sha1_hashed": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "SHA1 hash of the binary form of the SHA1 fingerprint in hex format", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -146,6 +155,15 @@ func (r *TorRelayIdentityRsaResource) Create(ctx context.Context, req resource.C data.PublicKeyFingerprintSha1 = types.StringValue(sha1Fingerprint) data.PublicKeyFingerprintSha256 = types.StringValue(sha256Fingerprint) + // Generate hashed fingerprint + hashedFingerprint, err := r.generateHashedFingerprint(sha1Fingerprint) + if err != nil { + resp.Diagnostics.AddError("Hashed Fingerprint Generation Error", + fmt.Sprintf("Unable to generate hashed fingerprint: %s", err)) + return + } + data.PublicKeyFingerprintSha1Hashed = types.StringValue(hashedFingerprint) + // Generate ID from SHA1 fingerprint data.Id = types.StringValue(fmt.Sprintf("rsa-%s", sha1Fingerprint[:16])) @@ -206,3 +224,15 @@ func (r *TorRelayIdentityRsaResource) generateFingerprints(publicKey *rsa.Public return sha1Fingerprint, sha256Fingerprint, nil } + +func (r *TorRelayIdentityRsaResource) generateHashedFingerprint(fingerprint string) (string, error) { + fingerprintBytes, err := hex.DecodeString(fingerprint) + if err != nil { + return "", fmt.Errorf("failed to decode fingerprint hex: %w", err) + } + + hashedSum := sha1.Sum(fingerprintBytes) + hashedFingerprint := fmt.Sprintf("%X", hashedSum) + + return hashedFingerprint, nil +} diff --git a/internal/provider/tor_relay_identity_rsa_resource_test.go b/internal/provider/tor_relay_identity_rsa_resource_test.go index 757615f..58da2b9 100644 --- a/internal/provider/tor_relay_identity_rsa_resource_test.go +++ b/internal/provider/tor_relay_identity_rsa_resource_test.go @@ -27,11 +27,13 @@ func TestAccTorRelayIdentityRsaResource(t *testing.T) { resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_pem"), resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1"), resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256"), + resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1_hashed"), // Verify PEM format resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "private_key_pem", regexp.MustCompile(`^-----BEGIN RSA PRIVATE KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_pem", regexp.MustCompile(`^-----BEGIN PUBLIC KEY-----`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1", regexp.MustCompile(`^[0-9A-F]{40}$`)), resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha256", regexp.MustCompile(`^[0-9A-F]{64}$`)), + resource.TestMatchResourceAttr("tor_relay_identity_rsa.test", "public_key_fingerprint_sha1_hashed", regexp.MustCompile(`^[0-9A-F]{40}$`)), ), }, }, @@ -79,6 +81,22 @@ OhEqFiIYW5mI//JWsqSZZxy4nMqgejKkrRgOOQbL0NE= } } +func TestHashedFingerprintGeneration(t *testing.T) { + testFingerprint := "DA5CEC632A9A544394403BD533E1A7BDE2F26EDD" + expectedHashedFingerprint := "922650D27357BE307B3B322A5ABC3E9585AF776F" + + resource := &TorRelayIdentityRsaResource{} + hashedFingerprint, err := resource.generateHashedFingerprint(testFingerprint) + if err != nil { + t.Fatalf("failed to generate hashed fingerprint: %v", err) + } + + if hashedFingerprint != expectedHashedFingerprint { + t.Errorf("Hashed fingerprint mismatch:\nExpected: %s\nActual: %s", + expectedHashedFingerprint, hashedFingerprint) + } +} + const testAccTorRelayIdentityRsaResourceConfig = ` resource "tor_relay_identity_rsa" "test" {} `