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

8
.gitignore vendored
View file

@ -39,5 +39,9 @@ CLAUDE.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

View file

@ -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

View file

@ -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..."

View file

@ -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 generated by tfplugindocs -->
## 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)

View file

@ -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."

View file

@ -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
}

View file

@ -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/*"
]
}
}

7
e2e-tests/obfs4/test.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -e
../setup.sh
rm -f terraform.tfstate*
./tf init
./tf plan
./tf apply -auto-approve

3
e2e-tests/obfs4/tf Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
export TF_CLI_CONFIG_FILE=terraformrc
exec tofu "$@"

14
e2e-tests/setup.sh Executable file
View file

@ -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"

View file

@ -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 = <<EOF
FamilyId ${tor_family_identity.this.id}
BridgeRelay 1
DataDirectory data
ORPort 3333
ServerTransportListenAddr obfs4 0.0.0.0:3334
ExtORPort auto
ContactInfo <address@email.com>
Nickname PickANickname
EOF
}
output "family_id" {
description = "Family ID for the bridge"
value = tor_family_identity.this.id
}

View file

@ -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/*"
]
}
}

34
e2e-tests/tor-family/test.sh Executable file
View file

@ -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

View file

@ -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 "$@"

View file

@ -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"
}

View file

@ -57,6 +57,7 @@
default = pkgs.mkShell {
packages = [
pkgs.go
pkgs.gopls
pkgs.golangci-lint
pkgs.obfs4
pkgs.gnumake

2
go.mod
View file

@ -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

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)
}