forked from ops/terraform-provider-tor
The fingerprint calculation was using PKIX encoding instead of the required PKCS1 DER encoding for RSA public keys. This affected both the relay identity resource and obfs4 node ID derivation. - Use x509.MarshalPKCS1PublicKey instead of x509.MarshalPKIXPublicKey - Add test case with known fingerprint vector to prevent regression - Update both generateFingerprints and deriveNodeIdFromRsaKey functions fixes #2
421 lines
14 KiB
Go
421 lines
14 KiB
Go
// 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, ¤tState)...)
|
|
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(¤tState)
|
|
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, ¤tState)...)
|
|
}
|
|
|
|
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
|
|
switch block.Type {
|
|
case "RSA PRIVATE KEY":
|
|
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
case "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
|
|
}
|
|
default:
|
|
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)
|
|
}
|
|
|
|
publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
|
|
|
|
// 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
|
|
}
|