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/README.md b/README.md index 34ee6de..5a7a157 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" {} @@ -64,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/family_identity.md b/docs/resources/family_identity.md new file mode 100644 index 0000000..8fe89f0 --- /dev/null +++ b/docs/resources/family_identity.md @@ -0,0 +1,51 @@ +--- +# 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). + 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 +resource "tor_family_identity" "example" { + family_name = "MyFamily" +} + + +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 = < +## 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/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/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..3bccb40 --- /dev/null +++ b/examples/resources/tor_family_identity/resource.tf @@ -0,0 +1,17 @@ +resource "tor_family_identity" "example" { + family_name = "MyFamily" +} + + +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 = < + +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) { +} 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) +} 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..3f30230 100644 --- a/internal/provider/tor_obfs4_bridge_line_data_source_test.go +++ b/internal/provider/tor_obfs4_bridge_line_data_source_test.go @@ -31,9 +31,8 @@ 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]$`)), + 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_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.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 7b0873e..77f2e07 100644 --- a/internal/provider/tor_relay_identity_ed25519_resource_test.go +++ b/internal/provider/tor_relay_identity_ed25519_resource_test.go @@ -27,8 +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-----`)), - // 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}$`)), + 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..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])) @@ -196,16 +214,25 @@ 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) - sha1Fingerprint := fmt.Sprintf("%x", sha1Sum) - sha256Fingerprint := fmt.Sprintf("%x", sha256Sum) + sha1Fingerprint := fmt.Sprintf("%X", sha1Sum) + sha256Fingerprint := fmt.Sprintf("%X", sha256Sum) 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 f670f89..58da2b9 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" @@ -25,18 +27,76 @@ 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-----`)), - // 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}$`)), + 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}$`)), ), }, }, }) } +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) + } +} + +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" {} `