First working version

This commit is contained in:
Abel Luck 2025-06-03 13:23:45 +02:00
parent 63ed6316bc
commit d8eda81e0e
31 changed files with 3134 additions and 0 deletions

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 (
"context"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/function"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
)
// Ensure ScaffoldingProvider satisfies various provider interfaces.
var _ provider.Provider = &ScaffoldingProvider{}
var _ provider.ProviderWithFunctions = &ScaffoldingProvider{}
// ScaffoldingProvider defines the provider implementation.
type ScaffoldingProvider struct {
// version is set to the provider version on release, "dev" when the
// provider is built and ran locally, and "test" when running acceptance
// testing.
version string
}
// ScaffoldingProviderModel describes the provider data model.
type ScaffoldingProviderModel struct {
}
func (p *ScaffoldingProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "tor"
resp.Version = p.version
}
func (p *ScaffoldingProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The Tor provider generates cryptographic identity materials for obfs4 Tor bridges, enabling stateless bridge deployments.",
Attributes: map[string]schema.Attribute{},
}
}
func (p *ScaffoldingProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var data ScaffoldingProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// No configuration needed for crypto generation provider
// Set nil for data sources and resources since no client is needed
resp.DataSourceData = nil
resp.ResourceData = nil
}
func (p *ScaffoldingProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewTorObfs4StateResource,
NewTorRelayIdentityRsaResource,
NewTorRelayIdentityEd25519Resource,
}
}
func (p *ScaffoldingProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
NewTorObfs4BridgeLineDataSource,
}
}
func (p *ScaffoldingProvider) Functions(ctx context.Context) []func() function.Function {
return []func() function.Function{
}
}
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &ScaffoldingProvider{
version: version,
}
}
}

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
)
// testAccProtoV6ProviderFactories are used to instantiate a provider during
// acceptance testing. The factory function will be invoked for every Terraform
// CLI command executed to create a provider server to which the CLI can
// reattach.
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"tor": providerserver.NewProtocol6WithError(New("test")()),
}
func testAccPreCheck(t *testing.T) {
// You can add code here to run prior to any test case execution, for example assertions
// about the appropriate environment variables being set are common to see in a pre-check
// function.
}

View file

@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
// Ensure provider defined types fully satisfy framework interfaces.
var _ datasource.DataSource = &TorObfs4BridgeLineDataSource{}
func NewTorObfs4BridgeLineDataSource() datasource.DataSource {
return &TorObfs4BridgeLineDataSource{}
}
// TorObfs4BridgeLineDataSource defines the data source implementation.
type TorObfs4BridgeLineDataSource struct{}
// TorObfs4BridgeLineDataSourceModel describes the data source data model.
type TorObfs4BridgeLineDataSourceModel struct {
Id types.String `tfsdk:"id"`
IpAddress types.String `tfsdk:"ip_address"`
Port types.Int64 `tfsdk:"port"`
IdentityFingerprintSha1 types.String `tfsdk:"identity_fingerprint_sha1"`
Obfs4StateCertificate types.String `tfsdk:"obfs4_state_certificate"`
Obfs4StateIatMode types.Int64 `tfsdk:"obfs4_state_iat_mode"`
BridgeLine types.String `tfsdk:"bridge_line"`
}
func (d *TorObfs4BridgeLineDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_obfs4_bridge_line"
}
func (d *TorObfs4BridgeLineDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Generates a complete Tor bridge line using obfs4 state and network details",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Data source identifier",
},
"ip_address": schema.StringAttribute{
Required: true,
MarkdownDescription: "Bridge IP address",
},
"port": schema.Int64Attribute{
Required: true,
MarkdownDescription: "Bridge port number",
},
"identity_fingerprint_sha1": schema.StringAttribute{
Required: true,
MarkdownDescription: "SHA1 fingerprint of the RSA identity key",
},
"obfs4_state_certificate": schema.StringAttribute{
Required: true,
MarkdownDescription: "Base64-encoded certificate from tor_obfs4_state resource",
},
"obfs4_state_iat_mode": schema.Int64Attribute{
Required: true,
MarkdownDescription: "Inter-Arrival Time mode from tor_obfs4_state resource",
},
"bridge_line": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Complete bridge line ready for client use",
},
},
}
}
func (d *TorObfs4BridgeLineDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// No configuration needed for this data source
}
func (d *TorObfs4BridgeLineDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data TorObfs4BridgeLineDataSourceModel
// Read Terraform configuration data into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Generate bridge line using the provided inputs
bridgeLine := fmt.Sprintf("Bridge obfs4 %s:%d %s cert=%s iat-mode=%d",
data.IpAddress.ValueString(),
data.Port.ValueInt64(),
data.IdentityFingerprintSha1.ValueString(),
data.Obfs4StateCertificate.ValueString(),
data.Obfs4StateIatMode.ValueInt64())
// Set computed values
data.Id = types.StringValue(fmt.Sprintf("bridge-%s-%d", data.IpAddress.ValueString(), data.Port.ValueInt64()))
data.BridgeLine = types.StringValue(bridgeLine)
tflog.Trace(ctx, "read bridge line data source")
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

View file

@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"fmt"
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
)
func TestTorObfs4BridgeLineDataSource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Read testing
{
Config: testTorObfs4BridgeLineDataSourceConfig(),
Check: resource.ComposeAggregateTestCheckFunc(
// Check that the data source was created
resource.TestCheckResourceAttrSet("data.tor_obfs4_bridge_line.test", "id"),
// Check input values are preserved
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "ip_address", "192.0.2.1"),
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "port", "443"),
// 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]$`)),
// 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"),
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "obfs4_state_certificate", "tor_obfs4_state.test", "certificate"),
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test", "obfs4_state_iat_mode", "tor_obfs4_state.test", "iat_mode"),
// Validate bridge line consistency
testTorObfs4BridgeLineConsistency("data.tor_obfs4_bridge_line.test"),
),
},
},
})
}
func TestTorObfs4BridgeLineDataSourceWithCustomIATMode(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testTorObfs4BridgeLineDataSourceConfigWithIATMode(2),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test", "obfs4_state_iat_mode", "2"),
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test", "bridge_line",
regexp.MustCompile(`iat-mode=2$`)),
testTorObfs4BridgeLineConsistency("data.tor_obfs4_bridge_line.test"),
),
},
},
})
}
func TestTorObfs4BridgeLineDataSourceMultiplePorts(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testTorObfs4BridgeLineDataSourceConfigMultiplePorts(),
Check: resource.ComposeAggregateTestCheckFunc(
// Check first bridge line
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test1", "ip_address", "192.0.2.1"),
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test1", "port", "443"),
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test1", "bridge_line",
regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:443`)),
// Check second bridge line (same obfs4_state, different port)
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test2", "ip_address", "192.0.2.1"),
resource.TestCheckResourceAttr("data.tor_obfs4_bridge_line.test2", "port", "80"),
resource.TestMatchResourceAttr("data.tor_obfs4_bridge_line.test2", "bridge_line",
regexp.MustCompile(`^Bridge obfs4 192\.0\.2\.1:80`)),
// Verify both use the same certificate and fingerprint (same obfs4_state)
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test1", "obfs4_state_certificate", "data.tor_obfs4_bridge_line.test2", "obfs4_state_certificate"),
resource.TestCheckResourceAttrPair("data.tor_obfs4_bridge_line.test1", "identity_fingerprint_sha1", "data.tor_obfs4_bridge_line.test2", "identity_fingerprint_sha1"),
),
},
},
})
}
// Test configuration functions
func testTorObfs4BridgeLineDataSourceConfig() string {
return `
resource "tor_relay_identity_rsa" "test" {}
resource "tor_relay_identity_ed25519" "test" {}
resource "tor_obfs4_state" "test" {
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
}
data "tor_obfs4_bridge_line" "test" {
ip_address = "192.0.2.1"
port = 443
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
obfs4_state_certificate = tor_obfs4_state.test.certificate
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
}
`
}
func testTorObfs4BridgeLineDataSourceConfigWithIATMode(iatMode int) string {
return fmt.Sprintf(`
resource "tor_relay_identity_rsa" "test" {}
resource "tor_relay_identity_ed25519" "test" {}
resource "tor_obfs4_state" "test" {
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
iat_mode = %d
}
data "tor_obfs4_bridge_line" "test" {
ip_address = "192.0.2.1"
port = 443
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
obfs4_state_certificate = tor_obfs4_state.test.certificate
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
}
`, iatMode)
}
func testTorObfs4BridgeLineDataSourceConfigMultiplePorts() string {
return `
resource "tor_relay_identity_rsa" "test" {}
resource "tor_relay_identity_ed25519" "test" {}
resource "tor_obfs4_state" "test" {
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
}
data "tor_obfs4_bridge_line" "test1" {
ip_address = "192.0.2.1"
port = 443
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
obfs4_state_certificate = tor_obfs4_state.test.certificate
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
}
data "tor_obfs4_bridge_line" "test2" {
ip_address = "192.0.2.1"
port = 80
identity_fingerprint_sha1 = tor_relay_identity_rsa.test.public_key_fingerprint_sha1
obfs4_state_certificate = tor_obfs4_state.test.certificate
obfs4_state_iat_mode = tor_obfs4_state.test.iat_mode
}
`
}
// Custom test check functions
func testTorObfs4BridgeLineConsistency(resourceName 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)
}
ipAddress := rs.Primary.Attributes["ip_address"]
port := rs.Primary.Attributes["port"]
fingerprint := rs.Primary.Attributes["identity_fingerprint_sha1"]
certificate := rs.Primary.Attributes["obfs4_state_certificate"]
iatMode := rs.Primary.Attributes["obfs4_state_iat_mode"]
bridgeLine := rs.Primary.Attributes["bridge_line"]
// Verify bridge line format matches expected pattern
expectedBridgeLine := fmt.Sprintf("Bridge obfs4 %s:%s %s cert=%s iat-mode=%s",
ipAddress, port, fingerprint, certificate, iatMode)
if bridgeLine != expectedBridgeLine {
return fmt.Errorf("Bridge line doesn't match expected format.\nExpected: %s\nActual: %s",
expectedBridgeLine, bridgeLine)
}
return nil
}
}

View file

@ -0,0 +1,425 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"context"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"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"
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/lyrebird/common/drbg"
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/lyrebird/common/ntor"
)
// Ensure provider defined types fully satisfy framework interfaces.
var _ resource.Resource = &TorObfs4StateResource{}
func NewTorObfs4StateResource() resource.Resource {
return &TorObfs4StateResource{}
}
// TorObfs4StateResource defines the resource implementation.
type TorObfs4StateResource struct{}
// TorObfs4StateResourceModel describes the resource data model.
type TorObfs4StateResourceModel struct {
Id types.String `tfsdk:"id"`
RsaIdentityPrivateKey types.String `tfsdk:"rsa_identity_private_key"`
Ed25519IdentityPrivateKey types.String `tfsdk:"ed25519_identity_private_key"`
NodeId types.String `tfsdk:"node_id"`
PrivateKey types.String `tfsdk:"private_key"`
PublicKey types.String `tfsdk:"public_key"`
DrbgSeed types.String `tfsdk:"drbg_seed"`
IatMode types.Int64 `tfsdk:"iat_mode"`
Certificate types.String `tfsdk:"certificate"`
StateJson types.String `tfsdk:"state_json"`
BridgeLine types.String `tfsdk:"bridge_line"`
}
// obfs4StateJson represents the JSON structure for the state file
type obfs4StateJson struct {
NodeId string `json:"node-id"`
PrivateKey string `json:"private-key"`
PublicKey string `json:"public-key"`
DrbgSeed string `json:"drbg-seed"`
IatMode int `json:"iat-mode"`
}
func (r *TorObfs4StateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_obfs4_state"
}
func (r *TorObfs4StateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Generates obfs4 state and certificate for Tor bridges using external relay identity keys",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Resource identifier",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"rsa_identity_private_key": schema.StringAttribute{
Required: true,
Sensitive: true,
MarkdownDescription: "RSA identity private key in PEM format (from tor_relay_identity_rsa resource)",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ed25519_identity_private_key": schema.StringAttribute{
Required: true,
Sensitive: true,
MarkdownDescription: "Ed25519 identity private key in PEM format (from tor_relay_identity_ed25519 resource)",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"node_id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "20-byte node ID in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"private_key": schema.StringAttribute{
Computed: true,
Sensitive: true,
MarkdownDescription: "32-byte Curve25519 private key in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key": schema.StringAttribute{
Computed: true,
MarkdownDescription: "32-byte Curve25519 public key in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"drbg_seed": schema.StringAttribute{
Computed: true,
Sensitive: true,
MarkdownDescription: "24-byte DRBG seed in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"iat_mode": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
MarkdownDescription: "Inter-Arrival Time mode (0=none, 1=enabled, 2=paranoid)",
},
"certificate": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Base64-encoded certificate for bridge lines",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"state_json": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Complete obfs4 state in JSON format",
},
"bridge_line": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Complete bridge line ready for client use (placeholder IP and fingerprint)",
},
},
}
}
func (r *TorObfs4StateResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// No configuration needed for this crypto generation resource
}
func (r *TorObfs4StateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data TorObfs4StateResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Generate obfs4 state
err := r.generateObfs4State(ctx, &data)
if err != nil {
resp.Diagnostics.AddError("obfs4 State Generation Error",
fmt.Sprintf("Unable to generate obfs4 state: %s", err))
return
}
// Set ID to a hash of the public key for uniqueness
data.Id = types.StringValue(fmt.Sprintf("obfs4-%s", data.PublicKey.ValueString()[:16]))
tflog.Trace(ctx, "created obfs4 state resource")
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorObfs4StateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data TorObfs4StateResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// For this resource, the state is static once created, so no external API calls needed
// Just ensure the computed fields are still consistent
if !data.NodeId.IsNull() && !data.PublicKey.IsNull() {
// Regenerate certificate to ensure consistency
cert, err := r.generateCertificate(data.NodeId.ValueString(), data.PublicKey.ValueString())
if err != nil {
resp.Diagnostics.AddError("Certificate Generation Error",
fmt.Sprintf("Unable to regenerate certificate: %s", err))
return
}
data.Certificate = types.StringValue(cert)
// Regenerate state JSON
stateJson, err := r.generateStateJson(&data)
if err != nil {
resp.Diagnostics.AddError("State JSON Generation Error",
fmt.Sprintf("Unable to regenerate state JSON: %s", err))
return
}
data.StateJson = types.StringValue(stateJson)
// Regenerate bridge line
bridgeLine := r.generateBridgeLine(data.Certificate.ValueString(), data.IatMode.ValueInt64())
data.BridgeLine = types.StringValue(bridgeLine)
}
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorObfs4StateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var currentState TorObfs4StateResourceModel
var plannedState TorObfs4StateResourceModel
// Read current state
resp.Diagnostics.Append(req.State.Get(ctx, &currentState)...)
if resp.Diagnostics.HasError() {
return
}
// Read planned state
resp.Diagnostics.Append(req.Plan.Get(ctx, &plannedState)...)
if resp.Diagnostics.HasError() {
return
}
// Only iat_mode can be updated; preserve all crypto fields from current state
currentState.IatMode = plannedState.IatMode
// Regenerate computed fields with new iat_mode
stateJson, err := r.generateStateJson(&currentState)
if err != nil {
resp.Diagnostics.AddError("State JSON Generation Error",
fmt.Sprintf("Unable to regenerate state JSON: %s", err))
return
}
currentState.StateJson = types.StringValue(stateJson)
// Regenerate bridge line with new iat_mode
bridgeLine := r.generateBridgeLine(currentState.Certificate.ValueString(), currentState.IatMode.ValueInt64())
currentState.BridgeLine = types.StringValue(bridgeLine)
tflog.Trace(ctx, "updated obfs4 state resource")
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &currentState)...)
}
func (r *TorObfs4StateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// No external resources to clean up - this is a stateless resource
tflog.Trace(ctx, "deleted obfs4 state resource")
}
// generateObfs4State generates all the obfs4 state fields
func (r *TorObfs4StateResource) generateObfs4State(ctx context.Context, data *TorObfs4StateResourceModel) error {
// Derive node ID from RSA identity key (required)
nodeId, err := r.deriveNodeIdFromRsaKey(data.RsaIdentityPrivateKey.ValueString())
if err != nil {
return fmt.Errorf("failed to derive node ID from RSA key: %w", err)
}
data.NodeId = types.StringValue(nodeId.Hex())
// Generate Curve25519 keypair
keypair, err := ntor.NewKeypair(false)
if err != nil {
return fmt.Errorf("failed to generate keypair: %w", err)
}
data.PrivateKey = types.StringValue(keypair.Private().Hex())
data.PublicKey = types.StringValue(keypair.Public().Hex())
// Generate DRBG seed
seed, err := drbg.NewSeed()
if err != nil {
return fmt.Errorf("failed to generate DRBG seed: %w", err)
}
data.DrbgSeed = types.StringValue(seed.Hex())
// Set default iat_mode if not specified
if data.IatMode.IsNull() {
data.IatMode = types.Int64Value(0)
}
// Generate certificate
cert, err := r.generateCertificate(data.NodeId.ValueString(), data.PublicKey.ValueString())
if err != nil {
return fmt.Errorf("failed to generate certificate: %w", err)
}
data.Certificate = types.StringValue(cert)
// Generate state JSON
stateJson, err := r.generateStateJson(data)
if err != nil {
return fmt.Errorf("failed to generate state JSON: %w", err)
}
data.StateJson = types.StringValue(stateJson)
// Generate bridge line
bridgeLine := r.generateBridgeLine(data.Certificate.ValueString(), data.IatMode.ValueInt64())
data.BridgeLine = types.StringValue(bridgeLine)
return nil
}
// generateCertificate creates the base64-encoded certificate from node ID and public key
func (r *TorObfs4StateResource) generateCertificate(nodeIdHex, publicKeyHex string) (string, error) {
// Decode hex strings to bytes
nodeIdBytes, err := hex.DecodeString(nodeIdHex)
if err != nil {
return "", fmt.Errorf("failed to decode node ID: %w", err)
}
publicKeyBytes, err := hex.DecodeString(publicKeyHex)
if err != nil {
return "", fmt.Errorf("failed to decode public key: %w", err)
}
// Validate lengths
if len(nodeIdBytes) != 20 {
return "", fmt.Errorf("node ID must be 20 bytes, got %d", len(nodeIdBytes))
}
if len(publicKeyBytes) != 32 {
return "", fmt.Errorf("public key must be 32 bytes, got %d", len(publicKeyBytes))
}
// Concatenate node ID + public key (52 bytes total)
certBytes := make([]byte, 52)
copy(certBytes[:20], nodeIdBytes)
copy(certBytes[20:], publicKeyBytes)
// Base64 encode and remove padding
cert := base64.StdEncoding.EncodeToString(certBytes)
cert = strings.TrimSuffix(cert, "==")
return cert, nil
}
// generateStateJson creates the JSON representation of the obfs4 state
func (r *TorObfs4StateResource) generateStateJson(data *TorObfs4StateResourceModel) (string, error) {
state := obfs4StateJson{
NodeId: data.NodeId.ValueString(),
PrivateKey: data.PrivateKey.ValueString(),
PublicKey: data.PublicKey.ValueString(),
DrbgSeed: data.DrbgSeed.ValueString(),
IatMode: int(data.IatMode.ValueInt64()),
}
jsonBytes, err := json.Marshal(state)
if err != nil {
return "", fmt.Errorf("failed to marshal state JSON: %w", err)
}
return string(jsonBytes), nil
}
// generateBridgeLine creates a complete bridge line with placeholder IP and fingerprint
func (r *TorObfs4StateResource) generateBridgeLine(certificate string, iatMode int64) string {
// Use placeholder values that users can easily replace
placeholderIP := "<IP:PORT>"
placeholderFingerprint := "<FINGERPRINT>"
return fmt.Sprintf("Bridge obfs4 %s %s cert=%s iat-mode=%d",
placeholderIP, placeholderFingerprint, certificate, iatMode)
}
// deriveNodeIdFromRsaKey derives a 20-byte node ID from an RSA private key PEM
// This follows the Tor specification for relay identity
func (r *TorObfs4StateResource) deriveNodeIdFromRsaKey(rsaPrivateKeyPem string) (*ntor.NodeID, error) {
// Parse the PEM-encoded RSA private key
block, _ := pem.Decode([]byte(rsaPrivateKeyPem))
if block == nil {
return nil, fmt.Errorf("failed to parse PEM block")
}
var privateKey *rsa.PrivateKey
var err error
// Try different RSA private key formats
if block.Type == "RSA PRIVATE KEY" {
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
} else if block.Type == "PRIVATE KEY" {
parsedKey, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
if err2 == nil {
var ok bool
privateKey, ok = parsedKey.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("parsed key is not an RSA private key")
}
} else {
err = err2
}
} else {
return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
}
if err != nil {
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)
}
// Generate SHA1 hash of public key (this is the relay fingerprint/node ID)
hash := sha1.Sum(publicKeyBytes)
// Create node ID from the first 20 bytes (which is all of SHA1)
nodeId, err := ntor.NewNodeID(hash[:])
if err != nil {
return nil, fmt.Errorf("failed to create node ID: %w", err)
}
return nodeId, nil
}

View file

@ -0,0 +1,455 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
)
func TestTorObfs4StateResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read testing
{
Config: testTorObfs4StateResourceConfigWithIdentityKeys(),
Check: resource.ComposeAggregateTestCheckFunc(
// Check that the resource was created
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "id"),
// Check that all fields are generated
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "node_id"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "private_key"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "public_key"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "drbg_seed"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "certificate"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "state_json"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "bridge_line"),
// Check default iat_mode
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "0"),
// Check field lengths (hex-encoded)
resource.TestMatchResourceAttr("tor_obfs4_state.test", "node_id", regexp.MustCompile(`^[0-9a-f]{40}$`)),
resource.TestMatchResourceAttr("tor_obfs4_state.test", "private_key", regexp.MustCompile(`^[0-9a-f]{64}$`)),
resource.TestMatchResourceAttr("tor_obfs4_state.test", "public_key", regexp.MustCompile(`^[0-9a-f]{64}$`)),
resource.TestMatchResourceAttr("tor_obfs4_state.test", "drbg_seed", regexp.MustCompile(`^[0-9a-f]{48}$`)),
// Check certificate format (base64 without padding)
resource.TestMatchResourceAttr("tor_obfs4_state.test", "certificate", regexp.MustCompile(`^[A-Za-z0-9+/]+$`)),
// Check bridge_line format
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=0$`)),
// Check that the JSON is valid
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
// Check that certificate is properly generated from node_id + public_key
testTorObfs4StateCertificateValidity("tor_obfs4_state.test"),
// Check that bridge_line contains the correct certificate and iat_mode
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
// Verify the external identity keys are being used
resource.TestCheckResourceAttrPair("tor_obfs4_state.test", "rsa_identity_private_key", "tor_relay_identity_rsa.test", "private_key_pem"),
resource.TestCheckResourceAttrPair("tor_obfs4_state.test", "ed25519_identity_private_key", "tor_relay_identity_ed25519.test", "private_key_pem"),
),
},
// Update testing (iat_mode)
{
Config: testTorObfs4StateResourceConfigWithIATMode(1),
Check: resource.ComposeAggregateTestCheckFunc(
// Check that iat_mode was updated
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "1"),
// Check that crypto fields remain unchanged (immutable)
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "node_id"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "private_key"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "public_key"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "drbg_seed"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test", "certificate"),
// Check that bridge_line reflects the updated iat_mode
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=1$`)),
// Check that state_json reflects the updated iat_mode
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
// Check that bridge_line contains the correct certificate and iat_mode
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
),
},
},
})
}
func TestTorObfs4StateResourceWithCustomIATMode(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testTorObfs4StateResourceConfigWithIATMode(2),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("tor_obfs4_state.test", "iat_mode", "2"),
// Check that bridge_line reflects the custom iat_mode
resource.TestMatchResourceAttr("tor_obfs4_state.test", "bridge_line", regexp.MustCompile(`^Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=[A-Za-z0-9+/]+ iat-mode=2$`)),
testTorObfs4StateValidateJSON("tor_obfs4_state.test"),
testTorObfs4StateBridgeLineValidity("tor_obfs4_state.test"),
),
},
},
})
}
func TestTorObfs4StateResourceMultiple(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testTorObfs4StateResourceConfigMultiple(),
Check: resource.ComposeAggregateTestCheckFunc(
// Check first resource
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "node_id"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "certificate"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test1", "bridge_line"),
// Check second resource
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "node_id"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "certificate"),
resource.TestCheckResourceAttrSet("tor_obfs4_state.test2", "bridge_line"),
// Verify they have different keys (uniqueness)
testTorObfs4StateResourcesAreDifferent("tor_obfs4_state.test1", "tor_obfs4_state.test2"),
),
},
},
})
}
// Unit tests for helper functions
func TestTorObfs4StateGenerateCertificate(t *testing.T) {
resource := &TorObfs4StateResource{}
// Test with known values
nodeIdHex := "9be04232642c50ee2864c2724500870f5fce6d2d"
publicKeyHex := "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911"
cert, err := resource.generateCertificate(nodeIdHex, publicKeyHex)
if err != nil {
t.Fatalf("generateCertificate failed: %v", err)
}
// Certificate should be base64 encoded 52 bytes (node_id + public_key)
if len(cert) != 70 { // 52 bytes base64 encoded without padding = 70 chars
t.Errorf("Expected certificate length 70, got %d", len(cert))
}
// Verify the certificate decodes correctly
decoded, err := base64.StdEncoding.DecodeString(cert + "==") // Add padding for decode
if err != nil {
t.Fatalf("Certificate is not valid base64: %v", err)
}
if len(decoded) != 52 {
t.Errorf("Expected decoded certificate length 52, got %d", len(decoded))
}
// Verify node_id and public_key are correctly concatenated
nodeIdBytes, _ := hex.DecodeString(nodeIdHex)
publicKeyBytes, _ := hex.DecodeString(publicKeyHex)
for i := 0; i < 20; i++ {
if decoded[i] != nodeIdBytes[i] {
t.Errorf("Node ID mismatch at byte %d", i)
}
}
for i := 0; i < 32; i++ {
if decoded[20+i] != publicKeyBytes[i] {
t.Errorf("Public key mismatch at byte %d", i)
}
}
}
func TestTorObfs4StateGenerateCertificateInvalidInput(t *testing.T) {
resource := &TorObfs4StateResource{}
// Test with invalid node ID length
_, err := resource.generateCertificate("invalid", "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911")
if err == nil {
t.Error("Expected error for invalid node ID length")
}
// Test with invalid public key length
_, err = resource.generateCertificate("9be04232642c50ee2864c2724500870f5fce6d2d", "invalid")
if err == nil {
t.Error("Expected error for invalid public key length")
}
// Test with non-hex input
_, err = resource.generateCertificate("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911")
if err == nil {
t.Error("Expected error for non-hex node ID")
}
}
func TestTorObfs4StateGenerateBridgeLine(t *testing.T) {
resource := &TorObfs4StateResource{}
// Test with known values
certificate := "Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag"
iatMode := int64(0)
bridgeLine := resource.generateBridgeLine(certificate, iatMode)
expectedBridgeLine := "Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag iat-mode=0"
if bridgeLine != expectedBridgeLine {
t.Errorf("Expected bridge line:\n%s\nGot:\n%s", expectedBridgeLine, bridgeLine)
}
// Test with different iat_mode
iatMode = int64(2)
bridgeLine = resource.generateBridgeLine(certificate, iatMode)
expectedBridgeLine = "Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=Y+m2Eny/3H4JeW2RwRYGlNXpdYk8MXRbRFuv5AaTZfxnAUbhEnWCTmJM+VTMssTCYrxyag iat-mode=2"
if bridgeLine != expectedBridgeLine {
t.Errorf("Expected bridge line with iat-mode=2:\n%s\nGot:\n%s", expectedBridgeLine, bridgeLine)
}
}
func TestTorObfs4StateGenerateStateJson(t *testing.T) {
resource := &TorObfs4StateResource{}
// Create test data
data := &TorObfs4StateResourceModel{
NodeId: typeStringValue("9be04232642c50ee2864c2724500870f5fce6d2d"),
PrivateKey: typeStringValue("c858003d52fea6be52c6d7f8a3a44692fb3b9ad1bc3b7434871efd9acb8b1554"),
PublicKey: typeStringValue("45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911"),
DrbgSeed: typeStringValue("fae8faa79b74d08933251727aab6ca9627736a2387231b03"),
IatMode: typeInt64Value(0),
}
jsonStr, err := resource.generateStateJson(data)
if err != nil {
t.Fatalf("generateStateJson failed: %v", err)
}
// Parse the JSON to verify structure
var parsed obfs4StateJson
err = json.Unmarshal([]byte(jsonStr), &parsed)
if err != nil {
t.Fatalf("Generated JSON is invalid: %v", err)
}
// Verify fields
if parsed.NodeId != "9be04232642c50ee2864c2724500870f5fce6d2d" {
t.Errorf("Node ID mismatch in JSON")
}
if parsed.PrivateKey != "c858003d52fea6be52c6d7f8a3a44692fb3b9ad1bc3b7434871efd9acb8b1554" {
t.Errorf("Private key mismatch in JSON")
}
if parsed.PublicKey != "45c9411955294f9b4306e4a65941ea546dee63e1c8a83dd984d1095c4a2f3911" {
t.Errorf("Public key mismatch in JSON")
}
if parsed.DrbgSeed != "fae8faa79b74d08933251727aab6ca9627736a2387231b03" {
t.Errorf("DRBG seed mismatch in JSON")
}
if parsed.IatMode != 0 {
t.Errorf("IAT mode mismatch in JSON")
}
}
// Test configuration functions
func testTorObfs4StateResourceConfigWithIdentityKeys() string {
return `
resource "tor_relay_identity_rsa" "test" {}
resource "tor_relay_identity_ed25519" "test" {}
resource "tor_obfs4_state" "test" {
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
}
`
}
func testTorObfs4StateResourceConfigWithIATMode(iatMode int) string {
return fmt.Sprintf(`
resource "tor_relay_identity_rsa" "test" {}
resource "tor_relay_identity_ed25519" "test" {}
resource "tor_obfs4_state" "test" {
rsa_identity_private_key = tor_relay_identity_rsa.test.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test.private_key_pem
iat_mode = %d
}
`, iatMode)
}
func testTorObfs4StateResourceConfigMultiple() string {
return `
resource "tor_relay_identity_rsa" "test1" {}
resource "tor_relay_identity_ed25519" "test1" {}
resource "tor_relay_identity_rsa" "test2" {}
resource "tor_relay_identity_ed25519" "test2" {}
resource "tor_obfs4_state" "test1" {
rsa_identity_private_key = tor_relay_identity_rsa.test1.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test1.private_key_pem
iat_mode = 0
}
resource "tor_obfs4_state" "test2" {
rsa_identity_private_key = tor_relay_identity_rsa.test2.private_key_pem
ed25519_identity_private_key = tor_relay_identity_ed25519.test2.private_key_pem
iat_mode = 1
}
`
}
// Custom test check functions
func testTorObfs4StateValidateJSON(resourceName 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)
}
stateJsonStr := rs.Primary.Attributes["state_json"]
if stateJsonStr == "" {
return fmt.Errorf("state_json is empty")
}
// Parse JSON to verify it's valid
var parsed obfs4StateJson
err := json.Unmarshal([]byte(stateJsonStr), &parsed)
if err != nil {
return fmt.Errorf("state_json is not valid JSON: %v", err)
}
// Verify fields match attributes
if parsed.NodeId != rs.Primary.Attributes["node_id"] {
return fmt.Errorf("JSON node_id doesn't match attribute")
}
if parsed.PrivateKey != rs.Primary.Attributes["private_key"] {
return fmt.Errorf("JSON private_key doesn't match attribute")
}
if parsed.PublicKey != rs.Primary.Attributes["public_key"] {
return fmt.Errorf("JSON public_key doesn't match attribute")
}
if parsed.DrbgSeed != rs.Primary.Attributes["drbg_seed"] {
return fmt.Errorf("JSON drbg_seed doesn't match attribute")
}
return nil
}
}
func testTorObfs4StateCertificateValidity(resourceName 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)
}
nodeIdHex := rs.Primary.Attributes["node_id"]
publicKeyHex := rs.Primary.Attributes["public_key"]
certificate := rs.Primary.Attributes["certificate"]
// Regenerate certificate and compare
resource := &TorObfs4StateResource{}
expectedCert, err := resource.generateCertificate(nodeIdHex, publicKeyHex)
if err != nil {
return fmt.Errorf("Failed to generate expected certificate: %v", err)
}
if certificate != expectedCert {
return fmt.Errorf("Certificate doesn't match expected value")
}
return nil
}
}
func testTorObfs4StateBridgeLineValidity(resourceName 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)
}
certificate := rs.Primary.Attributes["certificate"]
iatMode := rs.Primary.Attributes["iat_mode"]
bridgeLine := rs.Primary.Attributes["bridge_line"]
// Expected bridge line format
expectedBridgeLine := fmt.Sprintf("Bridge obfs4 <IP:PORT> <FINGERPRINT> cert=%s iat-mode=%s", certificate, iatMode)
if bridgeLine != expectedBridgeLine {
return fmt.Errorf("Bridge line doesn't match expected format.\nExpected: %s\nActual: %s", expectedBridgeLine, bridgeLine)
}
return nil
}
}
func testTorObfs4StateResourcesAreDifferent(resource1, resource2 string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs1, ok := s.RootModule().Resources[resource1]
if !ok {
return fmt.Errorf("Resource not found: %s", resource1)
}
rs2, ok := s.RootModule().Resources[resource2]
if !ok {
return fmt.Errorf("Resource not found: %s", resource2)
}
// Check that crypto fields are different
if rs1.Primary.Attributes["node_id"] == rs2.Primary.Attributes["node_id"] {
return fmt.Errorf("Resources have same node_id")
}
if rs1.Primary.Attributes["private_key"] == rs2.Primary.Attributes["private_key"] {
return fmt.Errorf("Resources have same private_key")
}
if rs1.Primary.Attributes["public_key"] == rs2.Primary.Attributes["public_key"] {
return fmt.Errorf("Resources have same public_key")
}
if rs1.Primary.Attributes["certificate"] == rs2.Primary.Attributes["certificate"] {
return fmt.Errorf("Resources have same certificate")
}
if rs1.Primary.Attributes["bridge_line"] == rs2.Primary.Attributes["bridge_line"] {
return fmt.Errorf("Resources have same bridge_line")
}
return nil
}
}
// Helper functions for creating types.String and types.Int64 values in tests
func typeStringValue(value string) types.String {
return types.StringValue(value)
}
func typeInt64Value(value int64) types.Int64 {
return types.Int64Value(value)
}

View file

@ -0,0 +1,188 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"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 = &TorRelayIdentityEd25519Resource{}
func NewTorRelayIdentityEd25519Resource() resource.Resource {
return &TorRelayIdentityEd25519Resource{}
}
type TorRelayIdentityEd25519Resource struct{}
type TorRelayIdentityEd25519ResourceModel 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"`
PublicKeyFingerprintSha256 types.String `tfsdk:"public_key_fingerprint_sha256"`
}
func (r *TorRelayIdentityEd25519Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_relay_identity_ed25519"
}
func (r *TorRelayIdentityEd25519Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Generates Ed25519 private key for Tor relay identity as required by the Tor specification.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Unique identifier based on public key fingerprint",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"algorithm": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Name of the algorithm used when generating the private key (always 'Ed25519')",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"private_key_pem": schema.StringAttribute{
Computed: true,
Sensitive: true,
MarkdownDescription: "Private key data in PEM (RFC 1421) format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key_pem": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Public key data in PEM (RFC 1421) format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key_fingerprint_sha256": schema.StringAttribute{
Computed: true,
MarkdownDescription: "SHA256 fingerprint of the public key in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *TorRelayIdentityEd25519Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
}
func (r *TorRelayIdentityEd25519Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data TorRelayIdentityEd25519ResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Set algorithm
data.Algorithm = types.StringValue("Ed25519")
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
resp.Diagnostics.AddError("Ed25519 Key Generation Error",
fmt.Sprintf("Unable to generate Ed25519 private key: %s", err))
return
}
// Encode private key as PEM
privateKeyPem, err := r.encodePrivateKeyPEM(privateKey)
if err != nil {
resp.Diagnostics.AddError("Private Key Encoding Error",
fmt.Sprintf("Unable to encode private key as PEM: %s", err))
return
}
data.PrivateKeyPem = types.StringValue(privateKeyPem)
// Encode public key as PEM
publicKeyPem, err := r.encodePublicKeyPEM(publicKey)
if err != nil {
resp.Diagnostics.AddError("Public Key Encoding Error",
fmt.Sprintf("Unable to encode public key as PEM: %s", err))
return
}
data.PublicKeyPem = types.StringValue(publicKeyPem)
// Generate SHA256 fingerprint
sha256Fingerprint := r.generateSha256Fingerprint(publicKey)
data.PublicKeyFingerprintSha256 = types.StringValue(sha256Fingerprint)
// Generate ID from SHA256 fingerprint
data.Id = types.StringValue(fmt.Sprintf("ed25519-%s", sha256Fingerprint[:16]))
tflog.Trace(ctx, "created tor relay identity Ed25519 resource")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorRelayIdentityEd25519Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data TorRelayIdentityEd25519ResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorRelayIdentityEd25519Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
resp.Diagnostics.AddError("Update Not Supported",
"Ed25519 relay identity keys are immutable. Any changes require resource replacement.")
}
func (r *TorRelayIdentityEd25519Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
tflog.Trace(ctx, "deleted tor relay identity Ed25519 resource")
}
func (r *TorRelayIdentityEd25519Resource) encodePrivateKeyPEM(privateKey ed25519.PrivateKey) (string, error) {
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", err
}
privateKeyPem := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyBytes,
})
return string(privateKeyPem), nil
}
func (r *TorRelayIdentityEd25519Resource) encodePublicKeyPEM(publicKey ed25519.PublicKey) (string, error) {
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", err
}
publicKeyPem := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
return string(publicKeyPem), nil
}
func (r *TorRelayIdentityEd25519Resource) generateSha256Fingerprint(publicKey ed25519.PublicKey) string {
publicKeyBytes, _ := x509.MarshalPKIXPublicKey(publicKey)
sha256Sum := sha256.Sum256(publicKeyBytes)
return fmt.Sprintf("%x", sha256Sum)
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
func TestAccTorRelayIdentityEd25519Resource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read testing
{
Config: testAccTorRelayIdentityEd25519ResourceConfig,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("tor_relay_identity_ed25519.test", "algorithm", "Ed25519"),
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "id"),
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "private_key_pem"),
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "public_key_pem"),
resource.TestCheckResourceAttrSet("tor_relay_identity_ed25519.test", "public_key_fingerprint_sha256"),
// 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}$`)),
),
},
},
})
}
const testAccTorRelayIdentityEd25519ResourceConfig = `
resource "tor_relay_identity_ed25519" "test" {}
`

View file

@ -0,0 +1,211 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"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 = &TorRelayIdentityRsaResource{}
func NewTorRelayIdentityRsaResource() resource.Resource {
return &TorRelayIdentityRsaResource{}
}
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"`
}
func (r *TorRelayIdentityRsaResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_relay_identity_rsa"
}
func (r *TorRelayIdentityRsaResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Generates 1024-bit RSA private key for Tor relay identity as required by the Tor specification.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Unique identifier based on public key fingerprint",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"algorithm": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Name of the algorithm used when generating the private key (always 'RSA')",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"private_key_pem": schema.StringAttribute{
Computed: true,
Sensitive: true,
MarkdownDescription: "Private key data in PEM (RFC 1421) format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key_pem": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Public key data in PEM (RFC 1421) format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key_fingerprint_sha1": schema.StringAttribute{
Computed: true,
MarkdownDescription: "SHA1 fingerprint of the public key in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"public_key_fingerprint_sha256": schema.StringAttribute{
Computed: true,
MarkdownDescription: "SHA256 fingerprint of the public key in hex format",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *TorRelayIdentityRsaResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
}
func (r *TorRelayIdentityRsaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data TorRelayIdentityRsaResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Set algorithm
data.Algorithm = types.StringValue("RSA")
// Generate 1024-bit RSA private key as required by Tor specification
// See: https://spec.torproject.org/tor-spec/preliminaries.html#ciphers
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
resp.Diagnostics.AddError("RSA Key Generation Error",
fmt.Sprintf("Unable to generate RSA private key: %s", err))
return
}
// Encode private key as PEM
privateKeyPem, err := r.encodePrivateKeyPEM(privateKey)
if err != nil {
resp.Diagnostics.AddError("Private Key Encoding Error",
fmt.Sprintf("Unable to encode private key as PEM: %s", err))
return
}
data.PrivateKeyPem = types.StringValue(privateKeyPem)
// Extract and encode public key
publicKey := &privateKey.PublicKey
publicKeyPem, err := r.encodePublicKeyPEM(publicKey)
if err != nil {
resp.Diagnostics.AddError("Public Key Encoding Error",
fmt.Sprintf("Unable to encode public key as PEM: %s", err))
return
}
data.PublicKeyPem = types.StringValue(publicKeyPem)
// Generate fingerprints
sha1Fingerprint, sha256Fingerprint, err := r.generateFingerprints(publicKey)
if err != nil {
resp.Diagnostics.AddError("Fingerprint Generation Error",
fmt.Sprintf("Unable to generate public key fingerprints: %s", err))
return
}
data.PublicKeyFingerprintSha1 = types.StringValue(sha1Fingerprint)
data.PublicKeyFingerprintSha256 = types.StringValue(sha256Fingerprint)
// Generate ID from SHA1 fingerprint
data.Id = types.StringValue(fmt.Sprintf("rsa-%s", sha1Fingerprint[:16]))
tflog.Trace(ctx, "created tor relay identity RSA resource")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorRelayIdentityRsaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data TorRelayIdentityRsaResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TorRelayIdentityRsaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
resp.Diagnostics.AddError("Update Not Supported",
"RSA relay identity keys are immutable. Any changes require resource replacement.")
}
func (r *TorRelayIdentityRsaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
tflog.Trace(ctx, "deleted tor relay identity RSA resource")
}
func (r *TorRelayIdentityRsaResource) encodePrivateKeyPEM(privateKey *rsa.PrivateKey) (string, error) {
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPem := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
return string(privateKeyPem), nil
}
func (r *TorRelayIdentityRsaResource) encodePublicKeyPEM(publicKey *rsa.PublicKey) (string, error) {
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", err
}
publicKeyPem := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
return string(publicKeyPem), nil
}
func (r *TorRelayIdentityRsaResource) generateFingerprints(publicKey *rsa.PublicKey) (string, string, error) {
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", "", err
}
sha1Sum := sha1.Sum(publicKeyBytes)
sha256Sum := sha256.Sum256(publicKeyBytes)
sha1Fingerprint := fmt.Sprintf("%x", sha1Sum)
sha256Fingerprint := fmt.Sprintf("%x", sha256Sum)
return sha1Fingerprint, sha256Fingerprint, nil
}

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Abel Luck <abel@guardianproject.info>
package provider
import (
"regexp"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
func TestAccTorRelayIdentityRsaResource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read testing
{
Config: testAccTorRelayIdentityRsaResourceConfig,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("tor_relay_identity_rsa.test", "algorithm", "RSA"),
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "id"),
resource.TestCheckResourceAttrSet("tor_relay_identity_rsa.test", "private_key_pem"),
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"),
// 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}$`)),
),
},
},
})
}
const testAccTorRelayIdentityRsaResourceConfig = `
resource "tor_relay_identity_rsa" "test" {}
`