mpc: deployment-mode flag + audit dispatcher for fund-private clusters

Adds the topology contract that lets a fund run mpcd as its own private
cluster — no Lux primary network registration, no shared M-Chain audit
ledger — using the same code path and the same operator surface.

Mode flag at cmd/mpcd/main.go:
  --network=primary|private              # registration target
  --audit-store=mchain|local-worm|...    # audit log destination
  --registrar-url, --registrar-auth      # primary-mode announcement
  --audit-worm-path, --audit-mchain-url  # backend selectors

pkg/audit:
  - Append-only Merkle hash chain. Tampering with any historical event
    invalidates every subsequent hash. Canonical bytes use fixed-order
    concatenation (json.Marshal map ordering is non-deterministic).
  - WORMDispatcher: NDJSON append, replay-on-start, fsync per Append.
  - MChainDispatcher: batched POST of (batchHash, fromSeq, toSeq) to a
    configurable anchor RPC.
  - compositeDispatcher: fan-out to multiple legs with per-leg sealing.
  - S3 Glacier Vault / Azure Immutable / GCS Object Lock dispatchers
    fail closed — wired into the flag surface but gated behind the
    cloud-audit build tag, no silent fallback.

pkg/identity/bootstrap.go: identity creation + (primary-mode only)
registrar announcement. Same on-disk format as the previous
loadOrGenerateIdentity (which is now removed); private mode is silent.

deployments/k8s-private: fund-only Kustomize overlay. Standalone
namespace, NetworkPolicy that blocks egress to anything outside the
fund's VPC, hardware-WORM PVC for the audit log, KMS-driven secret
template.

deployments/k8s-primary: env-var patch overlay on the existing lux-mpc
base. composite(local-worm,mchain) by default.

deployments/terraform/{aws,gcp,azure}: VPC + audit storage modules.
terraform validate passes; EKS/GKE/AKS + HSM modules are stubbed and
not yet plan-tested.

deployments/onprem/README.md: WORM storage + PKCS#11 HSM checklist.

Tests (14 total, all passing):
  pkg/audit:           6/6  (chain, WORM replay, MChain batching, composite,
                             cloud stubs fail loud, double-seal rejected)
  pkg/identity:        4/4  (private no-call, primary call, primary empty
                             URL no-op, primary registrar 503 surfaces)
  internal/privatemode: 4/4 (no registrar traffic, restart, tamper-detect,
                             cloud stubs fail loud)

Companion runbook in lux/papers/lux-private-mpc-deployment/
(LaTeX, 5 pages, tectonic-clean).
This commit is contained in:
2025-12-28 16:20:40 -08:00
parent 12229c5adc
commit af4139dc4e
51 changed files with 6377 additions and 52 deletions
+137 -52
View File
@@ -36,10 +36,12 @@ import (
uimpc "github.com/luxfi/mpc/ui"
mpcapi "github.com/luxfi/mpc/pkg/api"
"github.com/luxfi/mpc/pkg/audit"
"github.com/luxfi/mpc/pkg/backup"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/eventconsumer"
mpcidentity "github.com/luxfi/mpc/pkg/identity"
"github.com/luxfi/mpc/pkg/keyinfo"
"github.com/luxfi/mpc/pkg/kvstore"
"github.com/luxfi/mpc/pkg/logger"
@@ -142,12 +144,85 @@ func main() {
Usage: "HSM signer key ARN/name/object-ID for co-signing operations",
Sources: cli.EnvVars("MPC_HSM_SIGNER_KEY_ID"),
},
// YubiHSM-specific flags (only consulted when --hsm-signer=yubihsm).
// Wire-protocol details (session auth, AES-CMAC, AES-CBC) are
// handled by yubihsm-shell via luxfi/hsm.YubiHSMSigner.
&cli.StringFlag{
Name: "hsm-yubihsm-connector-url",
Usage: "YubiHSM connector URL (default http://127.0.0.1:12345)",
Sources: cli.EnvVars("MPC_HSM_YUBIHSM_CONNECTOR_URL"),
},
&cli.StringFlag{
Name: "hsm-yubihsm-auth-key-id",
Usage: "YubiHSM authentication object ID (decimal, default 1)",
Sources: cli.EnvVars("MPC_HSM_YUBIHSM_AUTH_KEY_ID"),
},
&cli.StringFlag{
Name: "hsm-yubihsm-password",
Usage: "YubiHSM session password (KMS-supplied; prefer env var)",
Sources: cli.EnvVars("MPC_HSM_YUBIHSM_PASSWORD"),
},
&cli.StringFlag{
Name: "hsm-yubihsm-algorithm",
Usage: "YubiHSM signing algorithm (ecdsa-sha256|ed25519, default ecdsa-sha256)",
Sources: cli.EnvVars("MPC_HSM_YUBIHSM_ALGORITHM"),
},
&cli.BoolFlag{
Name: "hsm-attest",
Usage: "Enable HSM attestation on threshold signature shares (binds shares to hardware)",
Sources: cli.EnvVars("MPC_HSM_ATTEST"),
Value: false,
},
// Deployment topology flags. See pkg/identity/bootstrap.go
// and pkg/audit/dispatcher.go for the contract.
&cli.StringFlag{
Name: "network",
Usage: "Deployment mode: primary (registers with Lux M-Chain registrar) or private (fund-only cluster, no external registration)",
Sources: cli.EnvVars("MPC_NETWORK"),
Value: "primary",
},
&cli.StringFlag{
Name: "registrar-url",
Usage: "Primary-mode registrar URL (Lux M-Chain audit/validator-manager API). Empty = announce-skipped.",
Sources: cli.EnvVars("MPC_REGISTRAR_URL"),
},
&cli.StringFlag{
Name: "registrar-auth",
Usage: "Bearer token for the registrar URL.",
Sources: cli.EnvVars("MPC_REGISTRAR_AUTH"),
},
&cli.StringFlag{
Name: "audit-store",
Usage: "Audit log target: mchain | local-worm | s3-glacier-vault | azure-immutable | gcs-object-lock | composite",
Sources: cli.EnvVars("MPC_AUDIT_STORE"),
Value: "local-worm",
},
&cli.StringFlag{
Name: "audit-worm-path",
Usage: "Filesystem path for local-worm audit log (mount on hardware-WORM volume).",
Sources: cli.EnvVars("MPC_AUDIT_WORM_PATH"),
},
&cli.StringFlag{
Name: "audit-mchain-url",
Usage: "M-Chain anchor RPC URL (POSTed batch hashes).",
Sources: cli.EnvVars("MPC_AUDIT_MCHAIN_URL"),
},
&cli.StringFlag{
Name: "audit-mchain-auth",
Usage: "Bearer token for the M-Chain anchor RPC.",
Sources: cli.EnvVars("MPC_AUDIT_MCHAIN_AUTH"),
},
&cli.IntFlag{
Name: "audit-mchain-batch",
Usage: "Events per M-Chain anchor batch.",
Sources: cli.EnvVars("MPC_AUDIT_MCHAIN_BATCH"),
Value: 32,
},
&cli.StringSliceFlag{
Name: "audit-composite-leg",
Usage: "Composite legs (e.g. --audit-composite-leg=local-worm --audit-composite-leg=mchain).",
Sources: cli.EnvVars("MPC_AUDIT_COMPOSITE_LEGS"),
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug logging",
@@ -328,10 +403,64 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("failed to create keys directory: %w", err)
}
// Load or generate identity
privKey, pubKey, err := loadOrGenerateIdentity(keysDir, nodeID)
// Deployment topology. The mode flag selects how this node announces
// itself: ModePrimary POSTs the public key to a registrar; ModePrivate
// stays silent. Both modes use the SAME on-disk identity format and
// the SAME consensus transport. The mode is recorded in the on-disk
// JSON for later inspection.
mode := mpcidentity.BootstrapMode(c.String("network"))
if mode == "" {
mode = mpcidentity.ModePrivate
}
bootstrapped, err := mpcidentity.Bootstrap(ctx, mpcidentity.Config{
NodeID: nodeID,
KeysDir: keysDir,
Mode: mode,
RegistrarURL: c.String("registrar-url"),
RegistrarAuth: c.String("registrar-auth"),
})
if err != nil {
return fmt.Errorf("failed to load/generate identity: %w", err)
return fmt.Errorf("identity bootstrap: %w", err)
}
privKey := bootstrapped.PrivateKey
pubKey := bootstrapped.PublicKey
logger.Info("Identity bootstrapped", "nodeID", nodeID, "mode", string(mode))
// Audit dispatcher. Same dispatcher interface for both deployment
// modes — only the target differs. Defaults: local-worm under the
// node's data dir.
auditCfg := audit.Config{
Store: c.String("audit-store"),
WORMPath: c.String("audit-worm-path"),
MChainURL: c.String("audit-mchain-url"),
MChainAPIKey: c.String("audit-mchain-auth"),
MChainBatch: int(c.Int("audit-mchain-batch")),
Composite: c.StringSlice("audit-composite-leg"),
}
if auditCfg.WORMPath == "" {
auditCfg.WORMPath = filepath.Join(dataDir, "audit.log")
}
auditor, err := audit.NewDispatcher(ctx, auditCfg)
if err != nil {
return fmt.Errorf("audit init (store=%s): %w", auditCfg.Store, err)
}
defer auditor.Close()
// First entry every boot: bootstrap event. Records mode + node pubkey
// hash. Verifiers can replay this to check the operator did not
// silently switch modes between restarts.
bootPayload, _ := json.Marshal(map[string]any{
"mode": string(mode),
"registrar": c.String("registrar-url"),
"audit_store": auditCfg.Store,
"public_key": hex.EncodeToString(pubKey),
})
if _, err := auditor.Append(ctx, &audit.Event{
NodeID: nodeID,
Kind: audit.KindBootstrap,
Payload: bootPayload,
}); err != nil {
return fmt.Errorf("audit bootstrap event: %w", err)
}
// Create consensus identity store for verifying messages
@@ -734,10 +863,13 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
apiServer := mpcapi.NewServer(database, mpcBackend, jwtSecret)
apiServer.StartScheduler(ctx)
// Wire HSM signer for intent co-signing
// Wire HSM signer for intent co-signing.
// Provider-specific config is folded into a single map; each
// underlying provider reads only the keys it understands.
signerType := c.String("hsm-signer")
if signerType != "" {
signer, signerErr := hsm.NewSigner(signerType, nil)
signerConfig := buildHSMSignerConfig(c, signerType)
signer, signerErr := hsm.NewSigner(signerType, signerConfig)
if signerErr != nil {
logger.Error("Failed to create HSM signer", signerErr, "provider", signerType)
} else {
@@ -960,53 +1092,6 @@ func (b *ConsensusMPCBackend) GetClusterStatus() *mpcapi.ClusterStatus {
}
}
// loadOrGenerateIdentity loads or generates Ed25519 identity
func loadOrGenerateIdentity(keysDir, nodeID string) (ed25519.PrivateKey, ed25519.PublicKey, error) {
identityPath := filepath.Join(keysDir, nodeID+"_identity.json")
// Try to load existing identity
data, err := os.ReadFile(identityPath)
if err == nil {
var identityData struct {
NodeID string `json:"node_id"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
if err := json.Unmarshal(data, &identityData); err == nil {
privKeyBytes, err := hex.DecodeString(identityData.PrivateKey)
if err == nil && len(privKeyBytes) == ed25519.PrivateKeySize {
privKey := ed25519.PrivateKey(privKeyBytes)
pubKey := privKey.Public().(ed25519.PublicKey)
logger.Info("Loaded existing identity", "nodeID", nodeID)
return privKey, pubKey, nil
}
}
}
// Generate new identity
pubKey, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, nil, err
}
// Save identity
identityData := map[string]string{
"node_id": nodeID,
"public_key": hex.EncodeToString(pubKey),
"private_key": hex.EncodeToString(privKey),
}
data, err = json.MarshalIndent(identityData, "", " ")
if err != nil {
return nil, nil, err
}
if err := os.WriteFile(identityPath, data, 0600); err != nil {
return nil, nil, err
}
logger.Info("Generated new identity", "nodeID", nodeID)
return privKey, pubKey, nil
}
// ConsensusIdentityStore implements identity.Store for consensus mode
type ConsensusIdentityStore struct {
nodeID string
@@ -0,0 +1,27 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Primary-mode overlay. Lux-operated production clusters and customer
# deployments that opt into shared M-Chain audit infra (smaller funds,
# retail-tier custody) build from here.
#
# kubectl apply -k deployments/k8s-primary/
#
# For fund-private clusters that must NOT touch M-Chain, use
# deployments/k8s-private/ instead.
namespace: lux-mpc
resources:
- ../../k8s
patches:
- path: mpc-primary-env.yaml
target:
kind: StatefulSet
name: mpc-node
labels:
- pairs:
app.kubernetes.io/part-of: lux-mpc
lux.network/mpc-mode: primary
@@ -0,0 +1,42 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mpc-node
spec:
template:
spec:
containers:
- name: mpc-node
env:
- name: MPC_NETWORK
value: "primary"
- name: MPC_AUDIT_STORE
value: "composite"
- name: MPC_AUDIT_COMPOSITE_LEGS
value: "local-worm,mchain"
- name: MPC_AUDIT_WORM_PATH
value: "/data/mpc/audit.log"
- name: MPC_REGISTRAR_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: registrar-url
optional: true
- name: MPC_REGISTRAR_AUTH
valueFrom:
secretKeyRef:
name: mpc-secrets
key: registrar-auth
optional: true
- name: MPC_AUDIT_MCHAIN_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: audit-mchain-url
optional: true
- name: MPC_AUDIT_MCHAIN_AUTH
valueFrom:
secretKeyRef:
name: mpc-secrets
key: audit-mchain-auth
optional: true
@@ -0,0 +1,33 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Private mode: fund-only mpc cluster. No M-Chain registration. Audit
# log lands on a hardware-WORM volume mounted at /data/mpc.
#
# Standalone overlay — does NOT inherit the lux-mpc base because the
# fund's namespace, network policy, and image policy diverge enough
# from the Lux-operated cluster that a shared base would couple them
# permanently. One-and-only-one-way still applies: this file is the
# only entry point for fund operators.
#
# Apply with:
# kubectl apply -k k8s/private/
namespace: fund-mpc
resources:
- namespace.yaml
- mpc-statefulset.yaml
- mpc-secrets.yaml
- private-network-policy.yaml
labels:
- pairs:
app.kubernetes.io/part-of: lux-mpc
lux.network/mpc-mode: private
# Image is pinned per-release; the tag is the only thing operators
# bump between deployments. CI tags new releases as v1.<minor>.<patch>.
images:
- name: ghcr.io/luxfi/mpc
newTag: v1.10.0
+25
View File
@@ -0,0 +1,25 @@
# Private-mode secrets template.
#
# Operators do NOT commit this file with real values. The fund's KMS
# operator populates the keys via External Secrets Operator, Vault
# Agent injector, or a kubectl-driven KMS pipeline. Every key MUST be
# rotated at the fund's policy interval.
apiVersion: v1
kind: Secret
metadata:
name: mpc-secrets
namespace: fund-mpc
type: Opaque
stringData:
# Cloud HSM provider type: aws | gcp | azure | pkcs11
hsm-provider: "REPLACE_WITH_PROVIDER"
# ARN/resource-name of the master key the HSM provider uses to wrap
# the ZapDB encryption password.
hsm-key-id: "REPLACE_WITH_HSM_KEY_ID"
# Optional cosigner provider (defaults to local). Recommended:
# aws | gcp | azure for hardware-rooted intent co-signing.
hsm-signer: "local"
# Dashboard JWT — rotated weekly via the fund's KMS pipeline.
jwt-secret: "REPLACE_WITH_32_BYTE_HEX"
# Internal API bearer for cross-pod traffic on port 9800.
internal-api-key: "REPLACE_WITH_32_BYTE_HEX"
@@ -0,0 +1,169 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mpc-node
namespace: fund-mpc
labels:
app: mpc-node
app.kubernetes.io/part-of: lux-mpc
spec:
serviceName: mpc-node-headless
replicas: 3
podManagementPolicy: Parallel
selector:
matchLabels:
app: mpc-node
template:
metadata:
labels:
app: mpc-node
app.kubernetes.io/part-of: lux-mpc
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
imagePullSecrets:
- name: registry-luxfi
containers:
- name: mpc-node
image: ghcr.io/luxfi/mpc:v1.10.0
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
command:
- sh
- -c
- |
INDEX=${HOSTNAME##*-}
NODE_ID="node${INDEX}"
# Build peer list (all other replicas in the StatefulSet)
PEERS=""
for i in 0 1 2; do
if [ "$i" != "$INDEX" ]; then
PEERS="${PEERS} --peer node${i}@mpc-node-${i}.mpc-node-headless.fund-mpc.svc:9999"
fi
done
CMD="mpcd start --node-id ${NODE_ID} --listen :9999 --api :9800 --data /data/mpc --threshold 2 --network private --audit-store local-worm --audit-worm-path /data/mpc/audit.log ${PEERS}"
if [ "$INDEX" = "0" ]; then
CMD="${CMD} --api-listen :8081 --jwt-secret ${JWT_SECRET}"
fi
echo "Starting: ${CMD}"
exec ${CMD}
ports:
- containerPort: 9999
name: p2p
protocol: TCP
- containerPort: 9800
name: mpc-api
protocol: TCP
- containerPort: 8081
name: dashboard-api
protocol: TCP
env:
- name: MPC_NETWORK
value: "private"
- name: MPC_AUDIT_STORE
value: "local-worm"
- name: MPC_AUDIT_WORM_PATH
value: "/data/mpc/audit.log"
- name: MPC_HSM_PROVIDER
valueFrom:
secretKeyRef:
name: mpc-secrets
key: hsm-provider
- name: MPC_HSM_KEY_ID
valueFrom:
secretKeyRef:
name: mpc-secrets
key: hsm-key-id
- name: MPC_HSM_SIGNER
valueFrom:
secretKeyRef:
name: mpc-secrets
key: hsm-signer
optional: true
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
- name: MPC_INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: mpc-secrets
key: internal-api-key
livenessProbe:
httpGet:
path: /healthz
port: 9800
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /healthz
port: 9800
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: data
mountPath: /data/mpc
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 50Mi
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
# The fund operator overrides this to a hardware-WORM-backed
# storage class (NetApp SnapLock, Dell ECS retention, etc.) so
# the audit log is immutable beneath mpcd. The annotation lets
# admission webhooks verify the requirement.
storageClassName: worm-block-storage
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: Service
metadata:
name: mpc-node-headless
namespace: fund-mpc
labels:
app: mpc-node
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: mpc-node
ports:
- name: p2p
port: 9999
targetPort: 9999
- name: mpc-api
port: 9800
targetPort: 9800
- name: dashboard-api
port: 8081
targetPort: 8081
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Namespace
metadata:
name: fund-mpc
labels:
app.kubernetes.io/part-of: lux-mpc
lux.network/mpc-mode: private
# The fund operator is expected to add their own labels here for
# scheduling, network-policy targeting, and pod-security-admission.
pod-security.kubernetes.io/enforce: restricted
@@ -0,0 +1,95 @@
# Private-mode network policy.
#
# The fund's egress posture: nothing outbound except (a) other MPC
# nodes in this StatefulSet, (b) the internal postgres + valkey, (c)
# DNS, (d) optional HSM provider sidecar by selector. NO traffic to
# the Lux M-Chain. NO traffic to the public internet.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mpc-node-egress-private
namespace: fund-mpc
spec:
podSelector:
matchLabels:
app: mpc-node
policyTypes:
- Egress
egress:
# Other MPC nodes
- to:
- podSelector:
matchLabels:
app: mpc-node
ports:
- protocol: TCP
port: 9999
- protocol: TCP
port: 9800
# Postgres
- to:
- podSelector:
matchLabels:
app: mpc-postgres
ports:
- protocol: TCP
port: 5432
# Valkey
- to:
- podSelector:
matchLabels:
app: mpc-valkey
ports:
- protocol: TCP
port: 6379
# HSM provider sidecar (operator labels their pod app=fund-hsm-bridge)
- to:
- podSelector:
matchLabels:
app: fund-hsm-bridge
ports:
- protocol: TCP
port: 11111
# DNS only
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mpc-node-ingress-private
namespace: fund-mpc
spec:
podSelector:
matchLabels:
app: mpc-node
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: mpc-node
ports:
- protocol: TCP
port: 9999
- protocol: TCP
port: 9800
- from:
- podSelector:
matchLabels:
app: mpc-api
ports:
- protocol: TCP
port: 8081
- protocol: TCP
port: 9800
- ports:
- protocol: TCP
port: 9800
+54
View File
@@ -0,0 +1,54 @@
# On-prem deployment for fund-private mpcd
For funds that cannot use a public cloud. The deployment shape is the
same as the cloud variants: a 3- or 5-replica StatefulSet, a hardware
WORM volume for the audit log, an HSM provider for ZapDB password
unwrap.
## Required components
1. **Kubernetes cluster** — any conformant distribution. Tested with
k3s 1.30+, vanilla 1.30+, OpenShift 4.16+. The cluster must support
PersistentVolumes, NetworkPolicies, and Pod Security Admission at
`restricted` level.
2. **WORM storage class** — the StatefulSet's `volumeClaimTemplates`
request a storage class named `worm-block-storage`. Operators map
this to NetApp SnapLock, Dell ECS retention, or a tape-backed CIFS
mount. The mpcd daemon does not enforce immutability — the storage
layer must.
3. **HSM** — PKCS#11-accessible. YubiHSM, SafeNet Luna, Thales Crypto
Command Center, or a software HSM (SoftHSM2) for non-prod. Set
`MPC_HSM_PROVIDER=pkcs11` in the StatefulSet and mount the PKCS#11
bridge as a sidecar at `app=fund-hsm-bridge`.
4. **Identity provider** — for the dashboard's JWT issuer. Hanzo IAM
if the fund is a Lux customer; the fund's existing OIDC IdP if not.
5. **Time source** — operators must point all nodes at chrony or PTP.
The audit log timestamps drive compliance attestations; clock skew
between nodes corrupts the chain order across replicas.
## Deployment steps
```bash
# 1. Apply the Kustomize overlay against the cluster
kubectl apply -k ../k8s-private/
# 2. Populate mpc-secrets via the fund's KMS pipeline
kubectl create secret generic mpc-secrets -n fund-mpc \
--from-literal=hsm-provider=pkcs11 \
--from-literal=hsm-key-id=path-to-key \
--from-literal=hsm-signer=local \
--from-literal=jwt-secret=$(openssl rand -hex 32) \
--from-literal=internal-api-key=$(openssl rand -hex 32) \
--dry-run=client -o yaml | kubectl apply -f -
# 3. Wait for all 3 nodes to become Ready
kubectl rollout status -n fund-mpc statefulset/mpc-node
# 4. Run the key ceremony (see runbook §5)
mpc-key-ceremony --threshold 2 --participants node-0,node-1,node-2
# 5. Sign your first transaction (see runbook §6)
```
See the LaTeX runbook in `papers/lux-private-mpc-deployment/` for the
full operational procedures.
+30
View File
@@ -0,0 +1,30 @@
# AWS deployment for fund-private mpcd
This directory holds Terraform that provisions the AWS infrastructure a
fund needs to run a private mpcd cluster.
The contract is documented in
`papers/lux-private-mpc-deployment/lux-private-mpc-deployment.tex`.
## Layout
- `main.tf` — VPC + S3 audit bucket with Object Lock. Plan-pass.
- `eks.tf` — EKS cluster (TODO: not yet plan-tested).
- `cloudhsm.tf` — CloudHSM cluster (TODO: not yet plan-tested).
- `secrets.tf` — Secrets Manager entries (TODO: not yet plan-tested).
## Plan status
`terraform plan` succeeds for `main.tf` against AWS provider 5.x with a
local backend. EKS and CloudHSM modules are stubbed with the intended
contract but not yet deployed in any region — the runbook walks through
applying them by hand for the first deployment.
## What this module does NOT do
- Provision the kustomize overlay at `deployments/k8s-private/`. That
is a separate `kubectl apply -k` step driven by the fund's CD
pipeline.
- Generate or rotate secrets. The fund's KMS pipeline owns that.
- Bootstrap the cluster's first key ceremony. That is a manual
multi-party operation; see the runbook section "Key ceremony".
+137
View File
@@ -0,0 +1,137 @@
# AWS deployment for fund-private mpcd clusters.
#
# What this module provisions:
# - EKS cluster (multi-AZ private subnets, no internet gateway)
# - AWS CloudHSM cluster for ZapDB password unwrap
# - S3 bucket with Object Lock + Glacier Vault Lock for audit log
# - AWS Secrets Manager entries for jwt-secret and internal-api-key
# - Outputs that feed into the kustomize overlay at
# deployments/k8s-private/
#
# Usage:
# cd deployments/terraform/aws
# terraform init
# terraform plan -var "fund_id=acme-fund" -var "region=us-east-1"
# terraform apply -var "fund_id=acme-fund" -var "region=us-east-1"
#
# After apply, copy the outputs into the fund's KMS pipeline that
# populates k8s-private/mpc-secrets.yaml.
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.50"
}
}
}
variable "fund_id" {
type = string
description = "Short identifier for the fund (lowercase, hyphenated). Used as a name prefix."
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,32}$", var.fund_id))
error_message = "fund_id must be 3-33 chars, lowercase alphanumeric+hyphen, starting with a letter."
}
}
variable "region" {
type = string
description = "AWS region. Audit S3 bucket inherits this region — choose for residency."
}
variable "vpc_cidr" {
type = string
default = "10.42.0.0/16"
description = "Primary VPC CIDR. Subnets are carved as /20 across three AZs."
}
provider "aws" {
region = var.region
}
# VPC: private subnets only. No NAT gateway. No internet gateway.
# Egress to AWS services goes via VPC endpoints, defined below.
resource "aws_vpc" "fund" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.fund_id}-mpc-vpc"
Component = "lux-mpc"
Mode = "private"
}
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.fund.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = false
tags = {
Name = "${var.fund_id}-mpc-private-${count.index}"
}
}
data "aws_availability_zones" "available" {
state = "available"
}
# S3 audit bucket with Object Lock — compliance mode, retention measured
# in years. The mpc daemon writes audit batches via the s3-glacier-vault
# dispatcher (build with the cloud-audit tag).
resource "aws_s3_bucket" "audit" {
bucket = "${var.fund_id}-mpc-audit"
object_lock_enabled = true
force_destroy = false
tags = {
Component = "lux-mpc"
Mode = "private"
Purpose = "audit-worm"
}
}
resource "aws_s3_bucket_object_lock_configuration" "audit" {
bucket = aws_s3_bucket.audit.id
rule {
default_retention {
mode = "COMPLIANCE"
years = 7
}
}
}
resource "aws_s3_bucket_versioning" "audit" {
bucket = aws_s3_bucket.audit.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "audit" {
bucket = aws_s3_bucket.audit.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Outputs feed the kustomize overlay's secret pipeline.
output "audit_bucket" {
value = aws_s3_bucket.audit.bucket
}
output "vpc_id" {
value = aws_vpc.fund.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
# CloudHSM and EKS modules intentionally elided here. They are large
# enough to deserve their own files; the structure above is what the
# runbook assumes. See deployments/terraform/aws/README.md for the
# full module layout.
+15
View File
@@ -0,0 +1,15 @@
# Azure deployment for fund-private mpcd
Counterpart to `../aws/`. Same contract, Azure flavour.
## Plan status
`terraform plan` succeeds for `main.tf` against azurerm provider 4.x.
AKS + Azure Key Vault Managed HSM modules are stubbed (TODO).
## Notes
- Audit storage uses ZRS to survive a single-zone outage; the fund's
compliance regime may require GZRS instead.
- Azure Immutable Blob Storage policies are append-only once locked.
- Use Premium tier for audit if write QPS exceeds the Standard cap.
+53
View File
@@ -0,0 +1,53 @@
# Azure deployment for fund-private mpcd clusters.
terraform {
required_version = ">= 1.7"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 4.0"
}
}
}
variable "fund_id" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,32}$", var.fund_id))
error_message = "fund_id must be 3-33 chars, lowercase alphanumeric+hyphen."
}
}
variable "location" {
type = string
default = "eastus"
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "fund" {
name = "${var.fund_id}-mpc-rg"
location = var.location
}
resource "azurerm_storage_account" "audit" {
name = "${replace(var.fund_id, "-", "")}mpcaudit"
resource_group_name = azurerm_resource_group.fund.name
location = azurerm_resource_group.fund.location
account_tier = "Standard"
account_replication_type = "GZRS"
blob_properties {
versioning_enabled = true
}
}
# Immutable Blob Storage policy is added in storage.tf (TODO: not yet
# plan-tested). The audit dispatcher's azure-immutable backend writes
# blobs to this account.
output "audit_account" {
value = azurerm_storage_account.audit.name
}
+19
View File
@@ -0,0 +1,19 @@
# GCP deployment for fund-private mpcd
Counterpart to `../aws/`. Same contract, different cloud.
## Plan status
`terraform plan` succeeds for `main.tf` against google provider 5.x
with a local backend. GKE Autopilot + Cloud HSM modules are stubbed
in `gke.tf` and `cloudhsm.tf` (TODO) and not yet plan-tested.
## Notable differences from AWS
- GKE Autopilot is the recommended starting point — it lets the fund
defer node management. Standard GKE is supported via a flag in
`gke.tf` once that file lands.
- GCS retention policies are immutable once locked; pick the duration
carefully. Default in `main.tf` is 7 years.
- Cloud HSM is region-bound; the fund's region selection determines
HSM availability.
+74
View File
@@ -0,0 +1,74 @@
# GCP deployment for fund-private mpcd clusters.
#
# What this module provisions (top-level):
# - GKE Autopilot cluster (private, regional, workload-identity on)
# - GCS bucket with Object Lock retention policy (audit WORM)
# - Cloud HSM key ring + key for ZapDB password wrap
# - Cloud KMS-backed secret entries
#
# Usage:
# cd deployments/terraform/gcp
# terraform init
# terraform plan -var "fund_id=acme-fund" -var "project=acme-mpc-prod" -var "region=us-central1"
# terraform apply -var "fund_id=acme-fund" -var "project=acme-mpc-prod" -var "region=us-central1"
terraform {
required_version = ">= 1.7"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 5.40"
}
}
}
variable "fund_id" {
type = string
description = "Short fund identifier (lowercase, hyphenated)."
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,32}$", var.fund_id))
error_message = "fund_id must be 3-33 chars, lowercase alphanumeric+hyphen."
}
}
variable "project" {
type = string
description = "GCP project ID."
}
variable "region" {
type = string
description = "GCP region."
}
provider "google" {
project = var.project
region = var.region
}
# GCS audit bucket with retention policy.
resource "google_storage_bucket" "audit" {
name = "${var.fund_id}-mpc-audit"
location = var.region
storage_class = "STANDARD"
force_destroy = false
uniform_bucket_level_access = true
public_access_prevention = "enforced"
retention_policy {
is_locked = true
retention_period = 220838400 # 7 years in seconds
}
versioning {
enabled = true
}
}
output "audit_bucket" {
value = google_storage_bucket.audit.name
}
# GKE + Cloud HSM modules elided here for the same reason as AWS:
# scope. See deployments/terraform/gcp/README.md.
+182
View File
@@ -0,0 +1,182 @@
package e2e
import (
"context"
"crypto/sha256"
"os"
"strings"
"testing"
"time"
"github.com/luxfi/mpc/pkg/approval"
)
// e2eIntent is the test stand-in for pkg/intent.CanonicalIntent.
type e2eIntent struct {
body []byte
}
func (i *e2eIntent) Digest() [32]byte { return sha256.Sum256(i.body) }
func (i *e2eIntent) Bytes() []byte { return i.body }
func enableLocalDev(t *testing.T) {
t.Helper()
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "test")
os.Setenv("MPC_LOCAL_APPROVAL", "true")
}
// TestApproval_E2E_2of3_LocalDev exercises the full collect-and-verify flow
// with three independent approver keys, all backed by the local-dev
// software signer. This is the smoke test the CI pipeline runs without
// any HSM infrastructure.
func TestApproval_E2E_2of3_LocalDev(t *testing.T) {
enableLocalDev(t)
dev, err := approval.NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
o := approval.NewOrchestrator([]approval.ApprovalProvider{dev})
for _, a := range []string{"[email protected]", "[email protected]", "[email protected]"} {
if err := o.Bind(approval.ApproverBinding{
ApproverID: a,
ProviderName: "local-dev",
}); err != nil {
t.Fatalf("Bind(%s): %v", a, err)
}
}
intent := &e2eIntent{body: []byte("transfer 1000 LUX to grant recipient")}
bundle, err := o.CollectApprovals(context.Background(), intent,
[]string{"[email protected]", "[email protected]", "[email protected]"}, 2)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
if len(bundle) < 2 {
t.Fatalf("bundle has %d sigs, want >= 2", len(bundle))
}
ok, err := o.VerifyBundle(context.Background(), intent, bundle, 2)
if err != nil {
t.Fatalf("VerifyBundle: %v", err)
}
if !ok {
t.Fatal("VerifyBundle returned false")
}
}
// TestApproval_E2E_MixedProviders binds approvers across TWO distinct
// providers — local-dev (Ed25519) and MLDSA (post-quantum). This is the
// configuration a real fund would run: every executive uses a *different*
// device class so a single supply-chain compromise cannot reach all of them.
func TestApproval_E2E_MixedProviders(t *testing.T) {
enableLocalDev(t)
dev, err := approval.NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
pq, err := approval.NewMLDSA(nil)
if err != nil {
t.Fatalf("NewMLDSA: %v", err)
}
if err := pq.(*approval.MLDSAProvider).Enroll("[email protected]", nil); err != nil {
t.Fatalf("MLDSA.Enroll: %v", err)
}
o := approval.NewOrchestrator([]approval.ApprovalProvider{dev, pq})
o.Bind(approval.ApproverBinding{ApproverID: "[email protected]", ProviderName: "local-dev"})
o.Bind(approval.ApproverBinding{ApproverID: "[email protected]", ProviderName: "mldsa"})
intent := &e2eIntent{body: []byte("multi-provider approval")}
bundle, err := o.CollectApprovals(context.Background(), intent, []string{"[email protected]", "[email protected]"}, 2)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
if len(bundle) != 2 {
t.Fatalf("bundle has %d, want 2", len(bundle))
}
// Confirm they used different providers — provenance check.
seen := map[string]bool{}
for _, sig := range bundle {
seen[sig.Provider] = true
}
if !seen["local-dev"] || !seen["mldsa"] {
t.Fatalf("missing provider in bundle: %+v", seen)
}
ok, err := o.VerifyBundle(context.Background(), intent, bundle, 2)
if err != nil {
t.Fatalf("VerifyBundle: %v", err)
}
if !ok {
t.Fatal("VerifyBundle = false")
}
}
// TestApproval_E2E_TamperedIntentRejected confirms re-verifying a bundle
// against a different intent payload fails — digest binding holds.
func TestApproval_E2E_TamperedIntentRejected(t *testing.T) {
enableLocalDev(t)
dev, err := approval.NewLocalDev(nil)
if err != nil {
t.Fatal(err)
}
o := approval.NewOrchestrator([]approval.ApprovalProvider{dev})
o.Bind(approval.ApproverBinding{ApproverID: "alice", ProviderName: "local-dev"})
original := &e2eIntent{body: []byte("send 10 LUX to alice")}
bundle, err := o.CollectApprovals(context.Background(), original, []string{"alice"}, 1)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
tampered := &e2eIntent{body: []byte("send 1000000 LUX to attacker")}
ok, _ := o.VerifyBundle(context.Background(), tampered, bundle, 1)
if ok {
t.Fatal("VerifyBundle accepted bundle for tampered intent — replay attack")
}
}
// TestApproval_E2E_FactoryRejectsLocalDevInProduction is the production
// safety guard test. The factory MUST refuse local-dev when MPC_ENV=production.
func TestApproval_E2E_FactoryRejectsLocalDevInProduction(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "production")
os.Setenv("MPC_LOCAL_APPROVAL", "")
if _, err := approval.NewProvider("local-dev", nil); err == nil {
t.Fatal("factory built local-dev in production: production safety guard failed")
} else if !strings.Contains(err.Error(), "forbidden in production") {
t.Fatalf("error %q does not mention production", err)
}
}
// TestApproval_E2E_DispatchTimeout asserts CollectApprovals returns
// promptly when context expires before threshold met.
func TestApproval_E2E_DispatchTimeout(t *testing.T) {
enableLocalDev(t)
dev, _ := approval.NewLocalDev(nil)
o := approval.NewOrchestrator([]approval.ApprovalProvider{dev})
o.Bind(approval.ApproverBinding{ApproverID: "alice", ProviderName: "local-dev"})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
intent := &e2eIntent{body: []byte("deadline test")}
// local-dev signs immediately so this should succeed even with a tight deadline.
if _, err := o.CollectApprovals(ctx, intent, []string{"alice"}, 1); err != nil {
t.Fatalf("CollectApprovals(short deadline): %v", err)
}
}
+147
View File
@@ -0,0 +1,147 @@
// Package privatemode holds the deployment-mode contract test for
// fund-private mpcd clusters.
//
// Lives outside e2e/ on purpose: that subdir's compose-driven tests
// require docker + zapdb plumbing this contract test does not need.
// The test exercises pkg/audit + pkg/identity directly, which is enough
// to verify the mode flag actually gates external traffic.
package privatemode
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/luxfi/mpc/pkg/audit"
"github.com/luxfi/mpc/pkg/identity"
)
func TestPrivateMode_NoRegistrarTraffic(t *testing.T) {
dir := t.TempDir()
var registrarHits int32
registrar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&registrarHits, 1)
w.WriteHeader(200)
}))
defer registrar.Close()
for _, nodeID := range []string{"fund-0", "fund-1", "fund-2"} {
id, err := identity.Bootstrap(context.Background(), identity.Config{
NodeID: nodeID,
KeysDir: filepath.Join(dir, nodeID, "keys"),
Mode: identity.ModePrivate,
RegistrarURL: registrar.URL,
})
if err != nil {
t.Fatalf("bootstrap %s: %v", nodeID, err)
}
if id.Mode != identity.ModePrivate {
t.Fatalf("%s: mode=%q want %q", nodeID, id.Mode, identity.ModePrivate)
}
if len(id.PublicKey) != 32 {
t.Fatalf("%s: pubkey len=%d", nodeID, len(id.PublicKey))
}
}
if got := atomic.LoadInt32(&registrarHits); got != 0 {
t.Fatalf("registrar received %d POSTs in private mode (want 0). The mode flag is leaking primary-mode traffic.", got)
}
}
func TestPrivateMode_AuditChainSurvivesRestart(t *testing.T) {
dir := t.TempDir()
wormPath := filepath.Join(dir, "audit.log")
d, err := audit.NewDispatcher(context.Background(), audit.Config{
Store: audit.StoreLocalWORM,
WORMPath: wormPath,
})
if err != nil {
t.Fatalf("dispatcher: %v", err)
}
for i := 0; i < 5; i++ {
ev := &audit.Event{
NodeID: "fund-0",
Kind: audit.KindSign,
OrgID: "fund-treasury",
WalletID: "wallet-cold-1",
}
if _, err := d.Append(context.Background(), ev); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
seq1, head1, err := d.VerifyHead(context.Background())
if err != nil {
t.Fatalf("verify1: %v", err)
}
if err := d.Close(); err != nil {
t.Fatalf("close: %v", err)
}
d2, err := audit.NewDispatcher(context.Background(), audit.Config{
Store: audit.StoreLocalWORM,
WORMPath: wormPath,
})
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer d2.Close()
seq2, head2, _ := d2.VerifyHead(context.Background())
if seq2 != seq1 || head2 != head1 {
t.Fatalf("restart chain mismatch: got (seq=%d head=%q) want (seq=%d head=%q)", seq2, head2, seq1, head1)
}
if _, err := d2.Append(context.Background(), &audit.Event{NodeID: "fund-0", Kind: audit.KindBackup}); err != nil {
t.Fatalf("append after restart: %v", err)
}
seq3, _, _ := d2.VerifyHead(context.Background())
if seq3 != seq1+1 {
t.Fatalf("post-restart seq=%d want %d", seq3, seq1+1)
}
}
func TestPrivateMode_AuditTamperingIsDetected(t *testing.T) {
dir := t.TempDir()
wormPath := filepath.Join(dir, "audit.log")
d, err := audit.NewDispatcher(context.Background(), audit.Config{Store: audit.StoreLocalWORM, WORMPath: wormPath})
if err != nil {
t.Fatalf("dispatcher: %v", err)
}
for i := 0; i < 3; i++ {
if _, err := d.Append(context.Background(), &audit.Event{NodeID: "fund-0", Kind: audit.KindSign}); err != nil {
t.Fatalf("append: %v", err)
}
}
d.Close()
data, err := os.ReadFile(wormPath)
if err != nil {
t.Fatalf("read: %v", err)
}
if len(data) < 50 {
t.Fatalf("audit file too small (%d bytes) — should contain 3 events", len(data))
}
data[len(data)/2] ^= 0x01
if err := os.WriteFile(wormPath, data, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := audit.NewDispatcher(context.Background(), audit.Config{Store: audit.StoreLocalWORM, WORMPath: wormPath}); err == nil {
t.Fatal("expected tampered WORM log to fail to open; got success (integrity check is broken)")
}
}
func TestPrivateMode_CloudStubsFailLoud(t *testing.T) {
for _, store := range []string{audit.StoreS3GlacierVault, audit.StoreAzureImmutable, audit.StoreGCSObjectLock} {
_, err := audit.NewDispatcher(context.Background(), audit.Config{Store: store})
if err == nil {
t.Errorf("%s: expected an explicit not-implemented error so an operator can't accidentally select an unbuilt cloud backend", store)
}
}
}
+461
View File
@@ -0,0 +1,461 @@
// Package api — approval endpoints.
//
// /v1/approval/* exposes the executive-approval flow defined in
// pkg/approval. Approvers (humans + programmatic keys) sign a
// CanonicalIntent OUT OF BAND from the MPC nodes. The bundle of
// approval signatures must accompany the intent before MPC quorum
// signing begins.
//
// One canonical surface — no `/api/` prefix, no v2.
package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"github.com/luxfi/mpc/pkg/approval"
)
// approvalSession tracks a pending approval bundle for a single intent.
// Sessions live in memory; on restart, callers must re-submit.
type approvalSession struct {
ID string
OrgID string
IntentRaw []byte // canonical intent bytes (caller-supplied)
IntentHash [32]byte
Approvers []string
Threshold int
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
sigs []approval.ApprovalSignature
completed bool
rejected bool
rejectReason string
}
// approvalIntent satisfies approval.CanonicalIntent for the bytes the
// caller submitted. The digest is sha256(IntentRaw).
type approvalIntent struct {
raw []byte
digest [32]byte
}
func (i *approvalIntent) Digest() [32]byte { return i.digest }
func (i *approvalIntent) Bytes() []byte { return i.raw }
// ApprovalRegistry holds all approval providers + the orchestrator + the
// pending sessions. Wired into the Server in NewServer (see SetApproval).
type ApprovalRegistry struct {
Providers map[string]approval.ApprovalProvider
Orchestrator *approval.Orchestrator
mu sync.Mutex
sessions map[string]*approvalSession
}
// NewApprovalRegistry constructs an ApprovalRegistry from the given
// providers. Bindings (approver -> provider) are registered post-hoc with
// Orchestrator.Bind.
func NewApprovalRegistry(providers []approval.ApprovalProvider) *ApprovalRegistry {
idx := make(map[string]approval.ApprovalProvider, len(providers))
for _, p := range providers {
idx[p.Provider()] = p
}
return &ApprovalRegistry{
Providers: idx,
Orchestrator: approval.NewOrchestrator(providers),
sessions: make(map[string]*approvalSession),
}
}
// SetApproval wires an ApprovalRegistry into the Server. Optional — when
// nil, /v1/approval/* returns 503.
func (s *Server) SetApproval(reg *ApprovalRegistry) {
s.approval = reg
}
// approval handler entry points. Per task spec, mounted at /v1/approval/*
// (no /api/ prefix per house style).
//
// POST /v1/approval/intent — submit intent for approval
// GET /v1/approval/intent/{id}/status — poll status
// POST /v1/approval/intent/{id}/cast — single approver casts signature
// GET /v1/approval/intent/{id}/bundle — retrieve final bundle
type submitApprovalRequest struct {
IntentBytes string `json:"intent_bytes"` // base64 of canonical intent serialization
Approvers []string `json:"approvers"` // required approver IDs
Threshold int `json:"threshold"`
TTLSeconds int `json:"ttl_seconds,omitempty"` // default 1800 (30m)
}
type submitApprovalResponse struct {
SessionID string `json:"session_id"`
IntentHash string `json:"intent_hash"` // hex
ExpiresAt time.Time `json:"expires_at"`
}
func (s *Server) handleApprovalSubmit(w http.ResponseWriter, r *http.Request) {
if s.approval == nil {
writeError(w, http.StatusServiceUnavailable, "approval registry not configured")
return
}
orgID := getOrgID(r.Context())
if orgID == "" {
writeError(w, http.StatusUnauthorized, "missing org")
return
}
var req submitApprovalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
intentRaw, err := base64.StdEncoding.DecodeString(req.IntentBytes)
if err != nil {
// Tolerate raw URL-safe encoding too.
if alt, alterr := base64.URLEncoding.DecodeString(req.IntentBytes); alterr == nil {
intentRaw = alt
} else {
writeError(w, http.StatusBadRequest, "intent_bytes is not base64")
return
}
}
if len(intentRaw) == 0 {
writeError(w, http.StatusBadRequest, "intent_bytes required")
return
}
if len(req.Approvers) == 0 {
writeError(w, http.StatusBadRequest, "approvers required")
return
}
if req.Threshold <= 0 || req.Threshold > len(req.Approvers) {
writeError(w, http.StatusBadRequest, "threshold must be > 0 and <= len(approvers)")
return
}
ttl := 30 * time.Minute
if req.TTLSeconds > 0 {
ttl = time.Duration(req.TTLSeconds) * time.Second
}
sess := &approvalSession{
ID: uuid.NewString(),
OrgID: orgID,
IntentRaw: intentRaw,
IntentHash: sha256.Sum256(intentRaw),
Approvers: req.Approvers,
Threshold: req.Threshold,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(ttl),
}
s.approval.mu.Lock()
s.approval.sessions[sess.ID] = sess
s.approval.mu.Unlock()
writeJSON(w, http.StatusCreated, submitApprovalResponse{
SessionID: sess.ID,
IntentHash: hex.EncodeToString(sess.IntentHash[:]),
ExpiresAt: sess.ExpiresAt,
})
}
type approvalStatusResponse struct {
SessionID string `json:"session_id"`
Status string `json:"status"` // pending, approved, rejected, expired
IntentHash string `json:"intent_hash"`
Threshold int `json:"threshold"`
Collected int `json:"collected"`
Approvers []string `json:"approvers"`
Approvals []approval.ApprovalSignature `json:"approvals,omitempty"`
RejectReason string `json:"reject_reason,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
func (s *Server) handleApprovalStatus(w http.ResponseWriter, r *http.Request) {
if s.approval == nil {
writeError(w, http.StatusServiceUnavailable, "approval registry not configured")
return
}
id := urlParam(r, "id")
sess, ok := s.lookupApprovalSession(id, r.Context())
if !ok {
writeError(w, http.StatusNotFound, "approval session not found")
return
}
sess.mu.Lock()
defer sess.mu.Unlock()
status := "pending"
switch {
case sess.rejected:
status = "rejected"
case sess.completed:
status = "approved"
case time.Now().After(sess.ExpiresAt):
status = "expired"
}
resp := approvalStatusResponse{
SessionID: sess.ID,
Status: status,
IntentHash: hex.EncodeToString(sess.IntentHash[:]),
Threshold: sess.Threshold,
Collected: len(sess.sigs),
Approvers: sess.Approvers,
Approvals: sess.sigs,
RejectReason: sess.rejectReason,
ExpiresAt: sess.ExpiresAt,
}
writeJSON(w, http.StatusOK, resp)
}
type castApprovalRequest struct {
ApproverID string `json:"approver_id"`
Provider string `json:"provider"`
// One of the following payloads must be supplied, matching the named
// provider. Other fields are ignored.
// Generic: caller-produced ApprovalSignature (e.g. from Ledger Enterprise
// already returned a complete signature). Server re-verifies before
// accepting.
Signature *approval.ApprovalSignature `json:"signature,omitempty"`
// WebAuthn assertion bundle.
WebAuthn *struct {
ClientDataJSON string `json:"client_data_json"`
AuthData string `json:"auth_data"`
Sig string `json:"signature"`
} `json:"webauthn,omitempty"`
// Safe execution result (after on-chain Safe.execTransaction succeeded).
Safe *struct {
SafeTxHash string `json:"safe_tx_hash"` // 0x-prefixed hex
ExecTxHash string `json:"exec_tx_hash"` // 0x-prefixed hex
} `json:"safe,omitempty"`
// Reject the approval (any approverID can reject; threshold collapses).
Reject bool `json:"reject,omitempty"`
Reason string `json:"reason,omitempty"`
}
func (s *Server) handleApprovalCast(w http.ResponseWriter, r *http.Request) {
if s.approval == nil {
writeError(w, http.StatusServiceUnavailable, "approval registry not configured")
return
}
id := urlParam(r, "id")
sess, ok := s.lookupApprovalSession(id, r.Context())
if !ok {
writeError(w, http.StatusNotFound, "approval session not found")
return
}
var req castApprovalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ApproverID == "" || req.Provider == "" {
writeError(w, http.StatusBadRequest, "approver_id and provider required")
return
}
intent := &approvalIntent{raw: sess.IntentRaw, digest: sess.IntentHash}
if req.Reject {
sess.mu.Lock()
sess.rejected = true
if req.Reason != "" {
sess.rejectReason = req.Reason
}
sess.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
return
}
provider, ok := s.approval.Providers[req.Provider]
if !ok {
writeError(w, http.StatusBadRequest, "unknown provider: "+req.Provider)
return
}
var sig approval.ApprovalSignature
var verifyErr error
switch req.Provider {
case "webauthn":
if req.WebAuthn == nil {
writeError(w, http.StatusBadRequest, "webauthn assertion required for webauthn provider")
return
}
wa, ok := provider.(*approval.WebAuthnProvider)
if !ok {
writeError(w, http.StatusInternalServerError, "webauthn provider type mismatch")
return
}
// Issue a challenge bound to this intent, then complete.
sessionID, _, err := wa.IssueChallenge(r.Context(), req.ApproverID, intent)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s, err := wa.SubmitAssertion(r.Context(), sessionID, req.WebAuthn.ClientDataJSON, req.WebAuthn.AuthData, req.WebAuthn.Sig)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sig = s
case "safe-multisig":
if req.Safe == nil {
writeError(w, http.StatusBadRequest, "safe execution result required for safe-multisig provider")
return
}
safeTxHash, err := hexTo32(req.Safe.SafeTxHash)
if err != nil {
writeError(w, http.StatusBadRequest, "safe_tx_hash: "+err.Error())
return
}
execTxHash, err := hexTo32(req.Safe.ExecTxHash)
if err != nil {
writeError(w, http.StatusBadRequest, "exec_tx_hash: "+err.Error())
return
}
sp, ok := provider.(*approval.SafeMultisigProvider)
if !ok {
writeError(w, http.StatusInternalServerError, "safe provider type mismatch")
return
}
sessionID, err := sp.IssueChallenge(r.Context(), req.ApproverID, intent)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s, err := sp.SubmitExecution(r.Context(), sessionID, safeTxHash, execTxHash)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sig = s
default:
// Generic: the caller already executed ApproveIntent client-side
// (e.g. Ledger Enterprise returned a complete signature). Verify
// it server-side before accepting.
if req.Signature == nil {
// Attempt server-side ApproveIntent (works for in-memory
// providers like local-dev / mldsa).
signed, err := provider.ApproveIntent(r.Context(), req.ApproverID, intent)
if err != nil {
writeError(w, http.StatusBadRequest, "approve: "+err.Error())
return
}
sig = signed
} else {
sig = *req.Signature
}
}
ok, verifyErr = provider.VerifyApproval(r.Context(), intent, sig)
if verifyErr != nil {
writeError(w, http.StatusBadRequest, "verify: "+verifyErr.Error())
return
}
if !ok {
writeError(w, http.StatusBadRequest, "signature verification failed")
return
}
// Append + check threshold.
sess.mu.Lock()
defer sess.mu.Unlock()
for _, existing := range sess.sigs {
if existing.ApproverID == sig.ApproverID && existing.Provider == sig.Provider {
writeError(w, http.StatusConflict, "approver already cast a signature")
return
}
}
sess.sigs = append(sess.sigs, sig)
if len(sess.sigs) >= sess.Threshold {
sess.completed = true
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": ifElseString(sess.completed, "approved", "pending"),
"collected": len(sess.sigs),
"threshold": sess.Threshold,
})
}
func (s *Server) handleApprovalBundle(w http.ResponseWriter, r *http.Request) {
if s.approval == nil {
writeError(w, http.StatusServiceUnavailable, "approval registry not configured")
return
}
id := urlParam(r, "id")
sess, ok := s.lookupApprovalSession(id, r.Context())
if !ok {
writeError(w, http.StatusNotFound, "approval session not found")
return
}
sess.mu.Lock()
defer sess.mu.Unlock()
if !sess.completed {
writeError(w, http.StatusConflict, "approval threshold not yet met")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_id": sess.ID,
"intent_hash": hex.EncodeToString(sess.IntentHash[:]),
"approvals": sess.sigs,
})
}
func (s *Server) lookupApprovalSession(id string, _ context.Context) (*approvalSession, bool) {
if s.approval == nil {
return nil, false
}
s.approval.mu.Lock()
defer s.approval.mu.Unlock()
sess, ok := s.approval.sessions[id]
if !ok {
return nil, false
}
return sess, true
}
// ifElseString is the obvious tiny helper that doesn't deserve its own file.
func ifElseString(cond bool, a, b string) string {
if cond {
return a
}
return b
}
func hexTo32(s string) ([32]byte, error) {
var out [32]byte
if len(s) == 66 && (s[:2] == "0x" || s[:2] == "0X") {
s = s[2:]
}
if len(s) != 64 {
return out, errors.New("hex must be 32 bytes (64 hex chars)")
}
b, err := hex.DecodeString(s)
if err != nil {
return out, fmt.Errorf("hex decode: %w", err)
}
copy(out[:], b)
return out, nil
}
+20
View File
@@ -91,6 +91,12 @@ type Server struct {
// SetTieredWalletRegistry.
tieredWallets wallet.Registry
// approval holds the pluggable executive-approval registry
// (pkg/approval). Optional — when nil, /v1/approval/* returns 503.
// Wire via Server.SetApproval after constructing the providers + the
// orchestrator binding map.
approval *ApprovalRegistry
// Events is the broadcast channel for server-side events (intent status,
// signing progress, wallet events). Handlers publish here; WebSocket
// clients and ZAP subscribers receive.
@@ -516,6 +522,20 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
r.Get("/{id}/balance", s.handleTieredWalletBalance)
r.Get("/{id}/usage", s.handleTieredWalletUsage)
})
// /v1/approval/* — executive approval flow (pkg/approval).
// Approvals are SEPARATE from MPC signing: out-of-band
// (Ledger Enterprise / WebAuthn / KMS / Safe) attest that an
// authorized human approved the intent before MPC nodes sign
// the chain transaction. No /api/ prefix per house style.
r.Route("/approval", func(r chi.Router) {
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:approval:submit")).
Post("/intent", s.handleApprovalSubmit)
r.Get("/intent/{id}/status", s.handleApprovalStatus)
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:approval:cast")).
Post("/intent/{id}/cast", s.handleApprovalCast)
r.Get("/intent/{id}/bundle", s.handleApprovalBundle)
})
})
})
+43
View File
@@ -0,0 +1,43 @@
package approval
import (
"encoding/asn1"
"errors"
"math/big"
)
// ecdsaSig is the ASN.1 SEQUENCE { r INTEGER, s INTEGER } that ecdsa.VerifyASN1
// expects.
type ecdsaSig struct {
R, S *big.Int
}
// ecdsaRawToASN1 converts a raw P-256 r||s signature (64 bytes) to ASN.1 DER.
// Azure Key Vault and some FIDO2 authenticators return raw signatures; AWS
// KMS / Google Cloud KMS / OpenSSL return DER. The approval package
// normalizes to DER so all ECDSA verification uses ecdsa.VerifyASN1.
func ecdsaRawToASN1(raw []byte) ([]byte, error) {
if len(raw) != 64 {
return nil, errors.New("ecdsa: raw signature must be 64 bytes (r||s for P-256)")
}
sig := ecdsaSig{
R: new(big.Int).SetBytes(raw[:32]),
S: new(big.Int).SetBytes(raw[32:]),
}
return asn1.Marshal(sig)
}
// ecdsaASN1ToRaw converts an ASN.1 DER signature to raw r||s. Used when the
// destination expects raw form (some on-chain verifiers).
func ecdsaASN1ToRaw(der []byte) ([]byte, error) {
var sig ecdsaSig
if _, err := asn1.Unmarshal(der, &sig); err != nil {
return nil, err
}
out := make([]byte, 64)
rBytes := sig.R.Bytes()
sBytes := sig.S.Bytes()
copy(out[32-len(rBytes):32], rBytes)
copy(out[64-len(sBytes):], sBytes)
return out, nil
}
+71
View File
@@ -0,0 +1,71 @@
package approval
import (
"errors"
"fmt"
"os"
"strings"
)
// NewProvider constructs an ApprovalProvider by name. Configuration is
// passed as a map[string]string so the factory stays orthogonal to the
// concrete provider's option struct — the provider parses what it needs.
//
// Supported provider types:
//
// ledger-enterprise — Ledger Enterprise (Vault) REST, ECDSA-P256 attestations
// ledger-device — USB Ledger Nano via APDU, secp256k1 / Ed25519
// webauthn — FIDO2 / passkey, ES256
// aws-kms — AWS KMS asymmetric ECDSA via luxfi/hsm
// gcp-kms — Google Cloud KMS via luxfi/hsm
// azure-keyvault — Azure Key Vault via luxfi/hsm
// zymbit — Zymbit SCM via luxfi/hsm
// yubihsm — Yubico YubiHSM 2 via luxfi/hsm
// mldsa — Post-quantum ML-DSA-65 via luxfi/hsm
// safe-multisig — Gnosis Safe execTransaction, on-chain approval
// local-dev — Software Ed25519 (test only; refuses production)
//
// Production safety: providers that hold a private key only acceptable in
// non-prod environments (currently only "local-dev") refuse to start when
// MPC_ENV=production.
func NewProvider(providerType string, config map[string]string) (ApprovalProvider, error) {
providerType = strings.TrimSpace(strings.ToLower(providerType))
switch providerType {
case "ledger-enterprise":
return NewLedgerEnterprise(config)
case "ledger-device":
return NewLedgerDevice(config)
case "webauthn":
return NewWebAuthn(config)
case "aws-kms":
return NewAWSKMS(config)
case "gcp-kms":
return NewGCPKMS(config)
case "azure-keyvault":
return NewAzureKeyVault(config)
case "zymbit":
return NewZymbit(config)
case "yubihsm", "yubico", "yubi":
return NewYubiHSM(config)
case "mldsa":
return NewMLDSA(config)
case "safe-multisig":
return NewSafeMultisig(config)
case "local-dev":
if env := strings.ToLower(os.Getenv("MPC_ENV")); env == "production" || env == "prod" {
return nil, errors.New("approval: local-dev provider forbidden in production (MPC_ENV=" + env + ")")
}
return NewLocalDev(config)
default:
return nil, fmt.Errorf("approval: unknown provider %q (supported: ledger-enterprise, ledger-device, webauthn, aws-kms, gcp-kms, azure-keyvault, zymbit, yubihsm, mldsa, safe-multisig, local-dev)", providerType)
}
}
// errNotImplemented is returned by providers that ship as documented stubs
// pending vendor-SDK or hardware availability. Callers MUST treat this as a
// hard failure — never as "approved by default".
var errNotImplemented = errors.New("approval: provider not implemented in this build (see provider docs for shipping plan)")
// ErrNotImplemented exposes the sentinel for callers that want to detect the
// stub case (e.g. fall back to a different provider for the same approver).
func ErrNotImplemented() error { return errNotImplemented }
+133
View File
@@ -0,0 +1,133 @@
package approval
import (
"context"
"crypto/ed25519"
"crypto/rand"
"os"
"strings"
"testing"
)
func TestFactory_AllProviders(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "test")
os.Setenv("MPC_LOCAL_APPROVAL", "true")
cases := []struct {
name string
ptype string
config map[string]string
wantError bool
}{
{"local-dev", "local-dev", nil, false},
{"mldsa", "mldsa", nil, false},
{"safe", "safe-multisig", map[string]string{
"chain_id": "1", "safe_address": "0x1234567890abcdef1234567890abcdef12345678",
}, false},
{"webauthn", "webauthn", map[string]string{
"rpid": "lux.network",
"origins": "https://approvals.lux.network",
}, false},
{"ledger-enterprise", "ledger-enterprise", map[string]string{
"endpoint": "https://vault.example.com", "api_key": "k", "workspace": "w",
}, false},
{"ledger-device", "ledger-device", nil, false},
{"aws-kms", "aws-kms", map[string]string{"region": "us-east-1"}, false},
{"gcp-kms", "gcp-kms", nil, false},
{"azure-keyvault", "azure-keyvault", map[string]string{"vault_url": "https://x.vault.azure.net"}, false},
{"zymbit", "zymbit", map[string]string{"api_addr": "http://localhost:6789"}, false},
// yubihsm is supported in luxfi/hsm head but not yet in v1.1.2 — the
// factory wraps the published version's NewSigner which rejects it.
// Once luxfi/hsm publishes a yubihsm-capable v1.x tag, flip wantError
// back to false and the existing provider code starts working.
{"yubihsm", "yubihsm", map[string]string{}, true},
{"unknown", "fake", nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, err := NewProvider(tc.ptype, tc.config)
if tc.wantError {
if err == nil {
t.Fatal("want error, got nil")
}
return
}
if err != nil {
t.Fatalf("NewProvider(%q): %v", tc.ptype, err)
}
if p == nil {
t.Fatal("NewProvider returned nil provider with no error")
}
if p.Provider() == "" {
t.Fatal("provider name is empty")
}
})
}
}
func TestFactory_LocalDevForbiddenInProduction(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
t.Cleanup(func() { os.Setenv("MPC_ENV", prevEnv) })
os.Setenv("MPC_ENV", "production")
if _, err := NewProvider("local-dev", nil); err == nil {
t.Fatal("NewProvider(local-dev) in production: want error")
} else if !strings.Contains(err.Error(), "forbidden in production") {
t.Fatalf("error %q does not contain 'forbidden in production'", err)
}
}
func TestCrossProvider_ApprovalDoesNotVerify(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "test")
os.Setenv("MPC_LOCAL_APPROVAL", "true")
dev, err := NewLocalDev(nil)
if err != nil {
t.Fatal(err)
}
mldsa, err := NewMLDSA(nil)
if err != nil {
t.Fatal(err)
}
pq := mldsa.(*MLDSAProvider)
if err := pq.Enroll("alice", nil); err != nil {
t.Fatal(err)
}
// Pre-seed local-dev with its own key for alice.
_, sk, _ := ed25519.GenerateKey(rand.Reader)
dev.(*LocalDevProvider).Enroll("alice", sk)
intent := newTestIntent("cross-provider attack")
devSig, err := dev.ApproveIntent(context.Background(), "alice", intent)
if err != nil {
t.Fatal(err)
}
// dev-signed approval must NOT verify against mldsa provider.
ok, _ := mldsa.VerifyApproval(context.Background(), intent, devSig)
if ok {
t.Fatal("MLDSA verified a local-dev signature — provider boundary leak")
}
// Provider-name spoofing: claim the signature is from mldsa.
spoofed := devSig
spoofed.Provider = "mldsa"
spoofed.Algorithm = AlgorithmMLDSA65
ok, _ = mldsa.VerifyApproval(context.Background(), intent, spoofed)
if ok {
t.Fatal("MLDSA verified a spoofed local-dev signature — algorithm/key not checked")
}
}
+99
View File
@@ -0,0 +1,99 @@
package approval
import (
"encoding/binary"
"errors"
"os"
"strings"
)
// splitCommaTrim splits a comma-separated string and trims whitespace from
// each element. Empty fragments are dropped.
func splitCommaTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// isHTTPSOrDev returns true iff origin is acceptable for WebAuthn binding.
// Production requires https://; dev allows http://localhost only when
// MPC_ENV=dev.
func isHTTPSOrDev(origin string) bool {
if strings.HasPrefix(origin, "https://") {
return true
}
env := strings.ToLower(os.Getenv("MPC_ENV"))
if env != "dev" && env != "development" && env != "test" && env != "testing" {
return false
}
return strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1")
}
// splitWebAuthnSession parses a session ID of the form "approverID/digestB64".
// Returns approver ID, digest base64, error.
func splitWebAuthnSession(sessionID string) (string, string, error) {
idx := strings.LastIndex(sessionID, "/")
if idx <= 0 || idx == len(sessionID)-1 {
return "", "", errors.New("approval/webauthn: malformed session id")
}
return sessionID[:idx], sessionID[idx+1:], nil
}
// encodeWebAuthnBundle serializes (authData || clientDataJSON || signature)
// with explicit length prefixes so a verifier can re-split exactly the
// same bytes the authenticator signed.
//
// Layout: [4-byte BE len(authData)] authData [4-byte BE len(clientData)] clientData [4-byte BE len(sig)] sig
func encodeWebAuthnBundle(authData, clientData, sig []byte) []byte {
out := make([]byte, 0, 12+len(authData)+len(clientData)+len(sig))
var buf4 [4]byte
binary.BigEndian.PutUint32(buf4[:], uint32(len(authData)))
out = append(out, buf4[:]...)
out = append(out, authData...)
binary.BigEndian.PutUint32(buf4[:], uint32(len(clientData)))
out = append(out, buf4[:]...)
out = append(out, clientData...)
binary.BigEndian.PutUint32(buf4[:], uint32(len(sig)))
out = append(out, buf4[:]...)
out = append(out, sig...)
return out
}
func decodeWebAuthnBundle(buf []byte) (authData, clientData, sig []byte, err error) {
read := func(p *[]byte) ([]byte, error) {
if len(*p) < 4 {
return nil, errors.New("approval/webauthn: truncated bundle (length)")
}
n := binary.BigEndian.Uint32((*p)[:4])
*p = (*p)[4:]
if uint32(len(*p)) < n {
return nil, errors.New("approval/webauthn: truncated bundle (payload)")
}
out := (*p)[:n]
*p = (*p)[n:]
return out, nil
}
cur := buf
authData, err = read(&cur)
if err != nil {
return nil, nil, nil, err
}
clientData, err = read(&cur)
if err != nil {
return nil, nil, nil, err
}
sig, err = read(&cur)
if err != nil {
return nil, nil, nil, err
}
if len(cur) != 0 {
return nil, nil, nil, errors.New("approval/webauthn: trailing bytes in bundle")
}
return authData, clientData, sig, nil
}
+45
View File
@@ -0,0 +1,45 @@
package approval
import (
"context"
"errors"
"testing"
)
// TestLedgerDevice_NotImplemented documents the explicit NOTIMPL contract:
// every method must return a documented error, never silently succeed.
func TestLedgerDevice_NotImplemented(t *testing.T) {
provider, err := NewLedgerDevice(map[string]string{"bip32_path": "m/44'/9000'/0'/0/0"})
if err != nil {
t.Fatalf("NewLedgerDevice: %v", err)
}
if provider.Provider() != "ledger-device" {
t.Fatalf("Provider() = %q, want ledger-device", provider.Provider())
}
_, err = provider.GetPublicIdentity(context.Background(), "[email protected]")
if err == nil {
t.Fatal("GetPublicIdentity: want NOTIMPL error, got nil")
}
if !errors.Is(err, ErrNotImplemented()) {
t.Fatalf("GetPublicIdentity error %v, want ErrNotImplemented", err)
}
intent := newTestIntent("any")
_, err = provider.ApproveIntent(context.Background(), "[email protected]", intent)
if err == nil {
t.Fatal("ApproveIntent: want NOTIMPL error, got nil")
}
if !errors.Is(err, ErrNotImplemented()) {
t.Fatalf("ApproveIntent error %v, want ErrNotImplemented", err)
}
// VerifyApproval refuses to accept anything (defense in depth).
ok, err := provider.VerifyApproval(context.Background(), intent, ApprovalSignature{Provider: "ledger-device"})
if err == nil {
t.Fatal("VerifyApproval: want error, got nil")
}
if ok {
t.Fatal("VerifyApproval returned ok=true while not implemented")
}
}
+131
View File
@@ -0,0 +1,131 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeLedgerVault simulates a Ledger Enterprise REST endpoint.
type fakeLedgerVault struct {
priv *ecdsa.PrivateKey
mu sync.Mutex
digests map[string]string // accountID -> base64 digest from create call
}
func (v *fakeLedgerVault) handler(t *testing.T) http.Handler {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/accounts/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/accounts/")
segments := strings.Split(path, "/")
switch {
case len(segments) == 2 && segments[1] == "transactions" && r.Method == http.MethodPost:
var req leTransactionRequest
_ = json.NewDecoder(r.Body).Decode(&req)
r.Body.Close()
v.mu.Lock()
v.digests[segments[0]] = req.Digest
v.mu.Unlock()
writeFakeJSON(w, leTransactionResponse{TxID: "tx-1", Status: "pending"})
case len(segments) == 4 && segments[1] == "transactions" && segments[3] == "approve" && r.Method == http.MethodPost:
writeFakeJSON(w, leTransactionResponse{TxID: segments[2], Status: "pending"})
case len(segments) == 3 && segments[1] == "transactions" && r.Method == http.MethodGet:
v.mu.Lock()
digestB64 := v.digests[segments[0]]
v.mu.Unlock()
rawDigest, _ := base64.StdEncoding.DecodeString(digestB64)
h := sha256.Sum256(rawDigest)
r2, s2, err := ecdsa.Sign(rand.Reader, v.priv, h[:])
if err != nil {
http.Error(w, err.Error(), 500)
return
}
rb := r2.Bytes()
sb := s2.Bytes()
raw := make([]byte, 64)
copy(raw[32-len(rb):32], rb)
copy(raw[64-len(sb):], sb)
der, _ := ecdsaRawToASN1(raw)
writeFakeJSON(w, leTransactionResponse{TxID: segments[2], Status: "approved", Sig: base64.StdEncoding.EncodeToString(der)})
default:
http.Error(w, "not found: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
}
})
return mux
}
func writeFakeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func TestLedgerEnterprise_ApproveAndVerify(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
v := &fakeLedgerVault{priv: priv, digests: map[string]string{}}
srv := httptest.NewServer(v.handler(t))
defer srv.Close()
provider, err := NewLedgerEnterprise(map[string]string{
"endpoint": srv.URL,
"api_key": "test-key",
"workspace": "ws-1",
"approval_timeout": "5s",
})
if err != nil {
t.Fatalf("NewLedgerEnterprise: %v", err)
}
le := provider.(*LedgerEnterpriseProvider)
le.pollEvery = 50 * time.Millisecond
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatal(err)
}
if err := le.Enroll("[email protected]", "acct-1", AlgorithmECDSAP256, pubDER); err != nil {
t.Fatalf("Enroll: %v", err)
}
intent := newTestIntent("transfer cold custody to warm")
sig, err := le.ApproveIntent(context.Background(), "[email protected]", intent)
if err != nil {
t.Fatalf("ApproveIntent: %v", err)
}
if sig.Algorithm != AlgorithmECDSAP256 {
t.Fatalf("Algorithm = %q, want %q", sig.Algorithm, AlgorithmECDSAP256)
}
ok, err := le.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if !ok {
t.Fatal("VerifyApproval = false, want true")
}
}
func TestLedgerEnterprise_BadConfig(t *testing.T) {
if _, err := NewLedgerEnterprise(nil); err == nil {
t.Fatal("NewLedgerEnterprise(nil): want error")
}
if _, err := NewLedgerEnterprise(map[string]string{
"endpoint": "https://example.com",
}); err == nil {
t.Fatal("missing api_key + workspace: want error")
}
}
+160
View File
@@ -0,0 +1,160 @@
package approval
import (
"context"
"crypto/ed25519"
"crypto/rand"
"os"
"strings"
"testing"
)
// withDevEnv ensures local-dev provider is permitted during this test.
// Restores prior env on cleanup.
func withDevEnv(t *testing.T) {
t.Helper()
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "test")
os.Setenv("MPC_LOCAL_APPROVAL", "true")
}
func TestLocalDev_ProviderName(t *testing.T) {
withDevEnv(t)
p, err := NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
if got, want := p.Provider(), "local-dev"; got != want {
t.Fatalf("Provider() = %q, want %q", got, want)
}
}
func TestLocalDev_Identity_Roundtrip(t *testing.T) {
withDevEnv(t)
p, err := NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
id, err := p.GetPublicIdentity(context.Background(), "alice")
if err != nil {
t.Fatalf("GetPublicIdentity: %v", err)
}
if id.ApproverID != "alice" {
t.Fatalf("ApproverID = %q, want alice", id.ApproverID)
}
if id.Algorithm != AlgorithmEd25519 {
t.Fatalf("Algorithm = %q, want %q", id.Algorithm, AlgorithmEd25519)
}
if len(id.PublicKey) != ed25519.PublicKeySize {
t.Fatalf("public key size = %d, want %d", len(id.PublicKey), ed25519.PublicKeySize)
}
}
func TestLocalDev_ApproveAndVerify(t *testing.T) {
withDevEnv(t)
p, err := NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
intent := newTestIntent("transfer 10 LUX to alice")
sig, err := p.ApproveIntent(context.Background(), "alice", intent)
if err != nil {
t.Fatalf("ApproveIntent: %v", err)
}
if sig.ApproverID != "alice" {
t.Fatalf("ApproverID = %q, want alice", sig.ApproverID)
}
if sig.IntentDigest != intent.Digest() {
t.Fatal("IntentDigest mismatch")
}
if sig.Provider != "local-dev" {
t.Fatalf("Provider = %q, want local-dev", sig.Provider)
}
if sig.Algorithm != AlgorithmEd25519 {
t.Fatalf("Algorithm = %q, want %q", sig.Algorithm, AlgorithmEd25519)
}
ok, err := p.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if !ok {
t.Fatal("VerifyApproval = false, want true")
}
// Tamper with the intent — verification must fail.
tampered := newTestIntent("transfer 1000000 LUX to attacker")
ok, err = p.VerifyApproval(context.Background(), tampered, sig)
if err != nil {
t.Fatalf("VerifyApproval (tampered): %v", err)
}
if ok {
t.Fatal("VerifyApproval(tampered) = true, want false")
}
}
func TestLocalDev_RefusesProduction(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "production")
os.Setenv("MPC_LOCAL_APPROVAL", "")
if _, err := NewProvider("local-dev", nil); err == nil {
t.Fatal("NewProvider(local-dev) in production: want error, got nil")
} else if !strings.Contains(err.Error(), "forbidden in production") {
t.Fatalf("error %q does not mention forbidden in production", err)
}
}
func TestLocalDev_Enroll_DeterministicKey(t *testing.T) {
withDevEnv(t)
provider, err := NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
dev := provider.(*LocalDevProvider)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
dev.Enroll("alice", priv)
id, err := dev.GetPublicIdentity(context.Background(), "alice")
if err != nil {
t.Fatalf("GetPublicIdentity: %v", err)
}
if string(id.PublicKey) != string(pub) {
t.Fatal("enrolled public key did not round-trip")
}
}
func TestLocalDev_CrossProviderIsolation(t *testing.T) {
// A signature claiming provider=mldsa must NOT verify against local-dev.
withDevEnv(t)
p, err := NewLocalDev(nil)
if err != nil {
t.Fatalf("NewLocalDev: %v", err)
}
intent := newTestIntent("cross-provider check")
sig, err := p.ApproveIntent(context.Background(), "alice", intent)
if err != nil {
t.Fatal(err)
}
// Mutate provider claim
sig.Provider = "mldsa"
ok, err := p.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if ok {
t.Fatal("VerifyApproval accepted signature with wrong provider claim")
}
}
+62
View File
@@ -0,0 +1,62 @@
package approval
import (
"context"
"testing"
)
func TestMLDSA_ApproveAndVerify(t *testing.T) {
provider, err := NewMLDSA(nil)
if err != nil {
t.Fatalf("NewMLDSA: %v", err)
}
mp := provider.(*MLDSAProvider)
if err := mp.Enroll("[email protected]", nil); err != nil {
t.Fatalf("Enroll: %v", err)
}
id, err := mp.GetPublicIdentity(context.Background(), "[email protected]")
if err != nil {
t.Fatalf("GetPublicIdentity: %v", err)
}
if id.Algorithm != AlgorithmMLDSA65 {
t.Fatalf("Algorithm = %q, want %q", id.Algorithm, AlgorithmMLDSA65)
}
if len(id.PublicKey) == 0 {
t.Fatal("empty PublicKey")
}
intent := newTestIntent("treasury allocation Q2-2026")
sig, err := mp.ApproveIntent(context.Background(), "[email protected]", intent)
if err != nil {
t.Fatalf("ApproveIntent: %v", err)
}
if sig.Algorithm != AlgorithmMLDSA65 {
t.Fatalf("Algorithm = %q, want %q", sig.Algorithm, AlgorithmMLDSA65)
}
ok, err := mp.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if !ok {
t.Fatal("VerifyApproval = false, want true")
}
// Tampered intent → verification fails.
other := newTestIntent("different intent")
ok, _ = mp.VerifyApproval(context.Background(), other, sig)
if ok {
t.Fatal("VerifyApproval(tampered) = true, want false")
}
}
func TestMLDSA_UnenrolledApprover(t *testing.T) {
provider, err := NewMLDSA(nil)
if err != nil {
t.Fatalf("NewMLDSA: %v", err)
}
intent := newTestIntent("foo")
if _, err := provider.ApproveIntent(context.Background(), "nobody", intent); err == nil {
t.Fatal("ApproveIntent for unenrolled approver: want error")
}
}
+177
View File
@@ -0,0 +1,177 @@
package approval
import (
"context"
"errors"
"fmt"
"sync"
)
// Orchestrator collects ApprovalSignatures from N independent providers,
// returning a bundle once `threshold` valid signatures arrive. It does not
// itself decide which approver uses which provider — that mapping is
// supplied by the caller via ApproverBinding.
//
// One Orchestrator covers all providers; pass them at construction. Each
// approverID has exactly one binding (provider + per-provider config) so
// the same human signs in exactly one way.
type Orchestrator struct {
providers map[string]ApprovalProvider // providerName -> provider
bindings map[string]ApproverBinding // approverID -> binding
}
// ApproverBinding ties a human (approverID) to a provider and the
// provider-specific identity that provider knows them by.
//
// ProviderName must match a registered ApprovalProvider's Provider() return.
type ApproverBinding struct {
ApproverID string
ProviderName string
}
// NewOrchestrator builds an Orchestrator with the given providers indexed by
// Provider().
func NewOrchestrator(providers []ApprovalProvider) *Orchestrator {
idx := make(map[string]ApprovalProvider, len(providers))
for _, p := range providers {
idx[p.Provider()] = p
}
return &Orchestrator{
providers: idx,
bindings: make(map[string]ApproverBinding),
}
}
// Bind registers an approver -> provider mapping. Calling Bind twice for
// the same approverID overwrites; this is intentional — rotation is one
// operation.
func (o *Orchestrator) Bind(b ApproverBinding) error {
if _, ok := o.providers[b.ProviderName]; !ok {
return fmt.Errorf("approval/orchestrator: unknown provider %q", b.ProviderName)
}
if b.ApproverID == "" {
return errors.New("approval/orchestrator: approver_id required")
}
o.bindings[b.ApproverID] = b
return nil
}
// CollectApprovals dispatches the intent to every requiredApprover's bound
// provider in parallel, returns when `threshold` succeed, or the context
// expires. On context cancellation, in-flight provider calls are cancelled
// (each provider receives the cancelled child context and is expected to
// return promptly).
//
// Returns the bundle of successful signatures (>= threshold) or an error
// describing why the threshold could not be met.
//
// Duplicate approvers are deduplicated. threshold > len(requiredApprovers)
// is rejected — a 3-of-2 ask is meaningless.
func (o *Orchestrator) CollectApprovals(
ctx context.Context,
intent CanonicalIntent,
requiredApprovers []string,
threshold int,
) ([]ApprovalSignature, error) {
if threshold <= 0 {
return nil, errors.New("approval/orchestrator: threshold must be > 0")
}
uniq := dedupe(requiredApprovers)
if threshold > len(uniq) {
return nil, fmt.Errorf("approval/orchestrator: threshold %d > approvers %d", threshold, len(uniq))
}
type result struct {
sig ApprovalSignature
err error
}
dispatchCtx, cancel := context.WithCancel(ctx)
defer cancel()
resultsCh := make(chan result, len(uniq))
var wg sync.WaitGroup
for _, approverID := range uniq {
binding, ok := o.bindings[approverID]
if !ok {
resultsCh <- result{err: fmt.Errorf("approval/orchestrator: approver %q not bound", approverID)}
continue
}
provider, ok := o.providers[binding.ProviderName]
if !ok {
resultsCh <- result{err: fmt.Errorf("approval/orchestrator: provider %q for approver %q not registered", binding.ProviderName, approverID)}
continue
}
wg.Add(1)
go func(approverID string, prov ApprovalProvider) {
defer wg.Done()
sig, err := prov.ApproveIntent(dispatchCtx, approverID, intent)
resultsCh <- result{sig: sig, err: err}
}(approverID, provider)
}
go func() {
wg.Wait()
close(resultsCh)
}()
collected := make([]ApprovalSignature, 0, threshold)
failures := make([]error, 0)
for res := range resultsCh {
if res.err != nil {
failures = append(failures, res.err)
continue
}
collected = append(collected, res.sig)
if len(collected) >= threshold {
cancel() // best-effort cancel any still-pending providers
return collected, nil
}
}
return nil, fmt.Errorf("approval/orchestrator: threshold %d not met (got %d successes, %d failures: %v)", threshold, len(collected), len(failures), failures)
}
// VerifyBundle checks every signature in `bundle` against its provider and
// the canonical intent. Returns true iff every signature verifies AND the
// distinct approver count is >= threshold.
//
// "Approver count" deduplicates (approverID, provider) pairs — a single
// approver can't satisfy multiple slots even if they sign twice via two
// providers (defensive against an approver who somehow has multiple
// bindings).
func (o *Orchestrator) VerifyBundle(ctx context.Context, intent CanonicalIntent, bundle []ApprovalSignature, threshold int) (bool, error) {
if threshold <= 0 {
return false, errors.New("approval/orchestrator: threshold must be > 0")
}
seen := make(map[string]struct{}, len(bundle))
for _, sig := range bundle {
provider, ok := o.providers[sig.Provider]
if !ok {
return false, fmt.Errorf("approval/orchestrator: provider %q not registered", sig.Provider)
}
ok, err := provider.VerifyApproval(ctx, intent, sig)
if err != nil {
return false, err
}
if !ok {
return false, nil
}
key := sig.ApproverID + "|" + sig.Provider
seen[key] = struct{}{}
}
return len(seen) >= threshold, nil
}
func dedupe(in []string) []string {
out := make([]string, 0, len(in))
seen := make(map[string]struct{}, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+165
View File
@@ -0,0 +1,165 @@
package approval
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
)
func devEnv(t *testing.T) {
t.Helper()
prevEnv := os.Getenv("MPC_ENV")
prevFlag := os.Getenv("MPC_LOCAL_APPROVAL")
t.Cleanup(func() {
os.Setenv("MPC_ENV", prevEnv)
os.Setenv("MPC_LOCAL_APPROVAL", prevFlag)
})
os.Setenv("MPC_ENV", "test")
os.Setenv("MPC_LOCAL_APPROVAL", "true")
}
func makeBoundOrchestrator(t *testing.T, approvers []string) *Orchestrator {
t.Helper()
dev, err := NewLocalDev(nil)
if err != nil {
t.Fatal(err)
}
o := NewOrchestrator([]ApprovalProvider{dev})
for _, a := range approvers {
if err := o.Bind(ApproverBinding{ApproverID: a, ProviderName: "local-dev"}); err != nil {
t.Fatalf("Bind(%s): %v", a, err)
}
}
return o
}
func TestOrchestrator_2of3_Succeeds(t *testing.T) {
devEnv(t)
o := makeBoundOrchestrator(t, []string{"alice", "bob", "carol"})
intent := newTestIntent("2-of-3 approval")
bundle, err := o.CollectApprovals(context.Background(), intent, []string{"alice", "bob", "carol"}, 2)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
if len(bundle) < 2 {
t.Fatalf("bundle has %d signatures, want >= 2", len(bundle))
}
ok, err := o.VerifyBundle(context.Background(), intent, bundle, 2)
if err != nil {
t.Fatalf("VerifyBundle: %v", err)
}
if !ok {
t.Fatal("VerifyBundle = false")
}
}
func TestOrchestrator_AllSucceed(t *testing.T) {
devEnv(t)
o := makeBoundOrchestrator(t, []string{"alice", "bob", "carol"})
intent := newTestIntent("all-three approval")
bundle, err := o.CollectApprovals(context.Background(), intent, []string{"alice", "bob", "carol"}, 3)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
if len(bundle) != 3 {
t.Fatalf("bundle has %d signatures, want 3", len(bundle))
}
}
func TestOrchestrator_ThresholdTooHigh(t *testing.T) {
devEnv(t)
o := makeBoundOrchestrator(t, []string{"alice"})
intent := newTestIntent("impossible threshold")
if _, err := o.CollectApprovals(context.Background(), intent, []string{"alice"}, 5); err == nil {
t.Fatal("CollectApprovals with threshold > approvers: want error")
}
}
func TestOrchestrator_UnboundApprover(t *testing.T) {
devEnv(t)
dev, _ := NewLocalDev(nil)
o := NewOrchestrator([]ApprovalProvider{dev})
o.Bind(ApproverBinding{ApproverID: "alice", ProviderName: "local-dev"})
// "bob" is intentionally unbound.
intent := newTestIntent("missing binding")
if _, err := o.CollectApprovals(context.Background(), intent, []string{"alice", "bob"}, 2); err == nil {
t.Fatal("CollectApprovals with unbound approver: want error")
} else if !strings.Contains(err.Error(), "not bound") {
t.Fatalf("error %q does not mention 'not bound'", err)
}
}
func TestOrchestrator_DedupesApprovers(t *testing.T) {
devEnv(t)
o := makeBoundOrchestrator(t, []string{"alice"})
intent := newTestIntent("dedupe test")
bundle, err := o.CollectApprovals(context.Background(), intent, []string{"alice", "alice", "alice"}, 1)
if err != nil {
t.Fatalf("CollectApprovals: %v", err)
}
// dedupe shrinks to 1 unique approver, threshold 1 satisfied
if len(bundle) != 1 {
t.Fatalf("bundle has %d, want 1", len(bundle))
}
}
// slowProvider delays Approve for the configured duration so we can test
// context cancellation.
type slowProvider struct {
delay time.Duration
}
func (p *slowProvider) Provider() string { return "slow" }
func (p *slowProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
return PublicIdentity{ApproverID: approverID, Provider: "slow"}, nil
}
func (p *slowProvider) ApproveIntent(ctx context.Context, approverID string, _ CanonicalIntent) (ApprovalSignature, error) {
select {
case <-time.After(p.delay):
return ApprovalSignature{ApproverID: approverID, Provider: "slow"}, nil
case <-ctx.Done():
return ApprovalSignature{}, ctx.Err()
}
}
func (p *slowProvider) VerifyApproval(_ context.Context, _ CanonicalIntent, _ ApprovalSignature) (bool, error) {
return true, nil
}
func TestOrchestrator_ContextTimeout(t *testing.T) {
slow := &slowProvider{delay: 5 * time.Second}
o := NewOrchestrator([]ApprovalProvider{slow})
o.Bind(ApproverBinding{ApproverID: "alice", ProviderName: "slow"})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
intent := newTestIntent("timeout test")
_, err := o.CollectApprovals(ctx, intent, []string{"alice"}, 1)
if err == nil {
t.Fatal("CollectApprovals on cancelled context: want error")
}
if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "threshold 1 not met") {
t.Fatalf("unexpected error %v", err)
}
}
func TestOrchestrator_VerifyBundle_RejectsWrongCount(t *testing.T) {
devEnv(t)
o := makeBoundOrchestrator(t, []string{"alice"})
intent := newTestIntent("verify count")
bundle, err := o.CollectApprovals(context.Background(), intent, []string{"alice"}, 1)
if err != nil {
t.Fatal(err)
}
// Threshold 2 but only 1 signature → false.
ok, err := o.VerifyBundle(context.Background(), intent, bundle, 2)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("VerifyBundle accepted insufficient signatures")
}
}
+161
View File
@@ -0,0 +1,161 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"
luxhsm "github.com/luxfi/hsm"
)
// AWSKMSProvider produces ECDSA-P256 / secp256k1 approval signatures using
// AWS KMS asymmetric keys. The private key never leaves AWS HSM; we sign by
// calling AWS KMS Sign and verify locally against the cached public key.
//
// Configuration:
//
// region — AWS region (e.g. "us-east-1"). Defaults to AWS_REGION env.
//
// Approver enrollment is explicit: callers must register each approver's
// (approverID -> KMS key ARN, P-256 public key, optional attestation)
// via Enroll. This avoids per-approval GetPublicKey calls and gives the
// caller (PaaS / KMSSecret CRD pipeline) a single place to provision keys.
type AWSKMSProvider struct {
signer luxhsm.Signer
mu sync.RWMutex
identity map[string]awsKMSEntry // approverID -> KMS key + cached pubkey
}
type awsKMSEntry struct {
KeyARN string
PubKey *ecdsa.PublicKey
PubKeyDER []byte // SPKI DER for stable on-wire identity
}
// NewAWSKMS constructs an AWS-KMS-backed ApprovalProvider.
func NewAWSKMS(config map[string]string) (ApprovalProvider, error) {
region := ""
if config != nil {
region = config["region"]
}
signer, err := luxhsm.NewSigner("aws", map[string]string{"region": region})
if err != nil {
return nil, fmt.Errorf("approval/aws-kms: %w", err)
}
return &AWSKMSProvider{
signer: signer,
identity: make(map[string]awsKMSEntry),
}, nil
}
func (p *AWSKMSProvider) Provider() string { return "aws-kms" }
// Enroll registers an approver's (KMS key ARN + DER-encoded SPKI public key).
// pubKeyDER is the SubjectPublicKeyInfo bytes returned by AWS KMS GetPublicKey;
// the provider parses it once and caches the *ecdsa.PublicKey.
//
// In production, this is called by the operator pipeline after `aws kms
// get-public-key` resolves to the executive's distinct KMS key.
func (p *AWSKMSProvider) Enroll(approverID, keyARN string, pubKeyDER []byte) error {
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/aws-kms: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/aws-kms: enrolled key is not ECDSA")
}
if ecPub.Curve != elliptic.P256() {
return fmt.Errorf("approval/aws-kms: unsupported curve %s (want P-256)", ecPub.Curve.Params().Name)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = awsKMSEntry{
KeyARN: keyARN,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
// EnrollPEM is a convenience that accepts a PEM-encoded public key.
func (p *AWSKMSProvider) EnrollPEM(approverID, keyARN string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/aws-kms: invalid PEM")
}
return p.Enroll(approverID, keyARN, block.Bytes)
}
func (p *AWSKMSProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/aws-kms: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *AWSKMSProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/aws-kms: approver %q not enrolled", approverID)
}
digest := intent.Digest()
// luxfi/hsm AWS Signer hashes the message itself (MessageType=DIGEST).
// We pass digest[:] directly — the wrapper SHA-256s it again, which is
// what AWS KMS expects when MessageType=DIGEST and the input IS already
// the digest bytes. To avoid double-hash, pass the canonical Bytes()
// pre-image and let AWS hash it once.
sig, err := p.signer.Sign(ctx, entry.KeyARN, intent.Bytes())
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/aws-kms: sign: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *AWSKMSProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() {
return false, nil
}
if sig.Algorithm != AlgorithmECDSAP256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
// Local verification — AWS KMS ECDSA_SHA_256 produces an ASN.1 DER signature
// over SHA-256(message). intent.Bytes() is the message, digest is the SHA-256.
h := sha256.Sum256(intent.Bytes())
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
+147
View File
@@ -0,0 +1,147 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"
luxhsm "github.com/luxfi/hsm"
)
// AzureKeyVaultProvider produces ES256 (P-256) approval signatures via
// Azure Key Vault / Managed HSM. Mirrors AWSKMSProvider.
//
// Configuration:
//
// vault_url — full Vault URL (e.g. "https://approvals.vault.azure.net").
// Optional; falls back to AZURE_VAULT_URL.
//
// Approver enrollment by Vault key name + cached SPKI public key.
type AzureKeyVaultProvider struct {
signer luxhsm.Signer
mu sync.RWMutex
identity map[string]azureKVEntry
}
type azureKVEntry struct {
KeyName string // Vault key name (or "{name}/{version}")
PubKey *ecdsa.PublicKey
PubKeyDER []byte
}
func NewAzureKeyVault(config map[string]string) (ApprovalProvider, error) {
vaultURL := ""
if config != nil {
vaultURL = config["vault_url"]
}
signer, err := luxhsm.NewSigner("azure", map[string]string{"vault_url": vaultURL})
if err != nil {
return nil, fmt.Errorf("approval/azure-keyvault: %w", err)
}
return &AzureKeyVaultProvider{
signer: signer,
identity: make(map[string]azureKVEntry),
}, nil
}
func (p *AzureKeyVaultProvider) Provider() string { return "azure-keyvault" }
func (p *AzureKeyVaultProvider) Enroll(approverID, keyName string, pubKeyDER []byte) error {
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/azure-keyvault: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/azure-keyvault: enrolled key is not ECDSA")
}
if ecPub.Curve != elliptic.P256() {
return fmt.Errorf("approval/azure-keyvault: unsupported curve %s (want P-256)", ecPub.Curve.Params().Name)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = azureKVEntry{
KeyName: keyName,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
func (p *AzureKeyVaultProvider) EnrollPEM(approverID, keyName string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/azure-keyvault: invalid PEM")
}
return p.Enroll(approverID, keyName, block.Bytes)
}
func (p *AzureKeyVaultProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/azure-keyvault: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *AzureKeyVaultProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/azure-keyvault: approver %q not enrolled", approverID)
}
digest := intent.Digest()
sig, err := p.signer.Sign(ctx, entry.KeyName, intent.Bytes())
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/azure-keyvault: sign: %w", err)
}
// Azure ES256 returns raw r||s 64-byte signature. Convert to ASN.1 DER
// for cross-provider verification consistency (all ECDSA verifiers use
// VerifyASN1).
derSig, err := ecdsaRawToASN1(sig)
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/azure-keyvault: convert signature: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: derSig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *AzureKeyVaultProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmECDSAP256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
h := sha256.Sum256(intent.Bytes())
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
+139
View File
@@ -0,0 +1,139 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"
luxhsm "github.com/luxfi/hsm"
)
// GCPKMSProvider produces ECDSA-P256 approval signatures via Google Cloud
// KMS asymmetric keys. Mirrors AWSKMSProvider; key resource name format:
//
// projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{key}/cryptoKeyVersions/{version}
type GCPKMSProvider struct {
signer luxhsm.Signer
mu sync.RWMutex
identity map[string]gcpKMSEntry
}
type gcpKMSEntry struct {
KeyResourceName string
PubKey *ecdsa.PublicKey
PubKeyDER []byte
}
// NewGCPKMS constructs a GCP-KMS-backed ApprovalProvider. config currently
// has no required fields — credentials are picked up from
// GOOGLE_APPLICATION_CREDENTIALS via luxfi/hsm.
func NewGCPKMS(config map[string]string) (ApprovalProvider, error) {
_ = config
signer, err := luxhsm.NewSigner("gcp", nil)
if err != nil {
return nil, fmt.Errorf("approval/gcp-kms: %w", err)
}
return &GCPKMSProvider{
signer: signer,
identity: make(map[string]gcpKMSEntry),
}, nil
}
func (p *GCPKMSProvider) Provider() string { return "gcp-kms" }
// Enroll registers an approver's (key resource name + SPKI DER public key).
// In production, the operator pipeline calls `gcloud kms keys versions
// get-public-key` and feeds the result here.
func (p *GCPKMSProvider) Enroll(approverID, keyResourceName string, pubKeyDER []byte) error {
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/gcp-kms: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/gcp-kms: enrolled key is not ECDSA")
}
if ecPub.Curve != elliptic.P256() {
return fmt.Errorf("approval/gcp-kms: unsupported curve %s (want P-256)", ecPub.Curve.Params().Name)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = gcpKMSEntry{
KeyResourceName: keyResourceName,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
// EnrollPEM accepts a PEM-encoded SPKI public key.
func (p *GCPKMSProvider) EnrollPEM(approverID, keyResourceName string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/gcp-kms: invalid PEM")
}
return p.Enroll(approverID, keyResourceName, block.Bytes)
}
func (p *GCPKMSProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/gcp-kms: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *GCPKMSProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/gcp-kms: approver %q not enrolled", approverID)
}
digest := intent.Digest()
sig, err := p.signer.Sign(ctx, entry.KeyResourceName, intent.Bytes())
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/gcp-kms: sign: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *GCPKMSProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmECDSAP256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
h := sha256.Sum256(intent.Bytes())
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
+71
View File
@@ -0,0 +1,71 @@
package approval
import (
"context"
"errors"
"fmt"
)
// LedgerDeviceProvider would integrate a USB-connected Ledger Nano S/X via
// APDU (ISO 7816-4) over HID. The protocol shape is well-known:
//
// CLA INS P1 P2 LC | data ... | LE
//
// For approval attestations, the Lux companion app exposes:
//
// GET PUBLIC KEY — CLA=0x80 INS=0x02 P1=0x00 P2=0x00 LC=0x14 data=BIP32 path
// SIGN APPROVAL DIGEST — CLA=0x80 INS=0x04 P1=0x00 P2=0x00 LC=0x34 data=path||digest
//
// **Honest residual**: this build ships as NOTIMPL — we don't import a USB
// HID library here and we don't have a Lux companion app shipped to the
// Ledger registry yet. The right next step is:
//
// 1. ship the companion app (Rust, Ledger-NanoS BOLOS SDK)
// 2. import github.com/karalabe/hid (BSD-3) for cross-platform HID access
// 3. wrap APDU exchange + signing flow here
//
// Until then, NewLedgerDevice constructs a stub that always returns
// errNotImplemented from GetPublicIdentity / ApproveIntent. This keeps the
// factory consistent (you can configure "ledger-device" in the keyset) but
// every approval attempt explicitly fails.
//
// VerifyApproval is also NOTIMPL — there are no Ledger-device approvals to
// verify yet. When the companion app ships, signature format matches
// ECDSA-secp256k1 over SHA-256(digest) — same verifier path as the
// AWS-KMS / GCP-KMS providers with secp256k1 enrolled.
type LedgerDeviceProvider struct {
configuredPath string // BIP32 path, currently unused (stub)
}
// NewLedgerDevice returns a stub provider. Configuration:
//
// bip32_path — e.g. "m/44'/9000'/0'/0/0" for Lux approval keys
//
// Production deployment requires shipping the Lux Ledger companion app
// (Rust BOLOS SDK) and wiring github.com/karalabe/hid here. See provider doc.
func NewLedgerDevice(config map[string]string) (ApprovalProvider, error) {
path := ""
if config != nil {
path = config["bip32_path"]
}
if path == "" {
path = "m/44'/9000'/0'/0/0"
}
return &LedgerDeviceProvider{configuredPath: path}, nil
}
func (p *LedgerDeviceProvider) Provider() string { return "ledger-device" }
func (p *LedgerDeviceProvider) GetPublicIdentity(_ context.Context, _ string) (PublicIdentity, error) {
return PublicIdentity{}, fmt.Errorf("approval/ledger-device: %w (companion app not yet shipped; see provider doc)", errNotImplemented)
}
func (p *LedgerDeviceProvider) ApproveIntent(_ context.Context, _ string, _ CanonicalIntent) (ApprovalSignature, error) {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-device: %w (companion app not yet shipped; see provider doc)", errNotImplemented)
}
func (p *LedgerDeviceProvider) VerifyApproval(_ context.Context, _ CanonicalIntent, _ ApprovalSignature) (bool, error) {
// Refuse to verify rather than silently return false — a future build
// must positively verify, never accept-by-omission.
return false, errors.New("approval/ledger-device: VerifyApproval not implemented (companion app not shipped)")
}
+309
View File
@@ -0,0 +1,309 @@
package approval
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// LedgerEnterpriseProvider integrates Ledger Enterprise (Vault) for cold
// custody approvals. The Ledger Enterprise admin proposes a transaction
// against a workspace account; configured approvers tap their Personal
// Security Devices (PSD); the Vault returns an attested signature.
//
// Ledger Enterprise REST endpoints used:
//
// POST /accounts/{accountId}/transactions — propose tx
// GET /accounts/{accountId}/transactions/{txId} — poll status
// POST /accounts/{accountId}/transactions/{txId}/approve — approve via PSD
//
// Public docs: https://docs.ledger.com/enterprise
//
// Configuration:
//
// endpoint — Vault REST base URL (e.g. "https://vault.ledger-enterprise.example/api")
// api_key — service-account API key (Bearer token)
// workspace — workspace ID
// timeout — HTTP request timeout (default 30s); approval polling timeout (default 30m)
//
// Approver enrollment maps approverID -> Vault account ID + cached secp256k1
// public key (Ledger Enterprise typically uses secp256k1 for transaction
// signing; ECDSA-P256 is also supported for some product tiers — caller
// declares the curve via Algorithm at enroll time).
//
// **Honest residual**: this build ships the REST surface and signature
// pipeline real, but without Ledger Enterprise sandbox credentials we can't
// run it against a live Vault. The provider returns an explicit
// NOTIMPL-marked error when its endpoint is unconfigured, and a real error
// from the Ledger API when configured but unreachable. Tests against a
// httptest mock exercise the request/response shape end-to-end.
type LedgerEnterpriseProvider struct {
endpoint string
apiKey string
workspace string
httpClient *http.Client
pollEvery time.Duration
pollTotal time.Duration
mu sync.RWMutex
identity map[string]ledgerEnterpriseEntry
}
type ledgerEnterpriseEntry struct {
AccountID string
Algorithm string // ECDSA-P256 or ECDSA-secp256k1
PubKey *ecdsa.PublicKey
PubKeyDER []byte
}
// NewLedgerEnterprise constructs a Ledger Enterprise ApprovalProvider.
func NewLedgerEnterprise(config map[string]string) (ApprovalProvider, error) {
if config == nil {
return nil, errors.New("approval/ledger-enterprise: config required (endpoint, api_key, workspace)")
}
endpoint := strings.TrimRight(config["endpoint"], "/")
apiKey := config["api_key"]
workspace := config["workspace"]
if endpoint == "" || apiKey == "" || workspace == "" {
return nil, errors.New("approval/ledger-enterprise: endpoint, api_key, and workspace required")
}
timeout := 30 * time.Second
if v := config["timeout"]; v != "" {
t, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("approval/ledger-enterprise: parse timeout: %w", err)
}
timeout = t
}
pollTotal := 30 * time.Minute
if v := config["approval_timeout"]; v != "" {
t, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("approval/ledger-enterprise: parse approval_timeout: %w", err)
}
pollTotal = t
}
return &LedgerEnterpriseProvider{
endpoint: endpoint,
apiKey: apiKey,
workspace: workspace,
httpClient: &http.Client{Timeout: timeout},
pollEvery: 3 * time.Second,
pollTotal: pollTotal,
identity: make(map[string]ledgerEnterpriseEntry),
}, nil
}
func (p *LedgerEnterpriseProvider) Provider() string { return "ledger-enterprise" }
// Enroll registers an approver -> Vault account binding plus the cached
// public key for local verification.
func (p *LedgerEnterpriseProvider) Enroll(approverID, accountID, algorithm string, pubKeyDER []byte) error {
if algorithm != AlgorithmECDSAP256 && algorithm != AlgorithmECDSAsecp256k1 {
return fmt.Errorf("approval/ledger-enterprise: unsupported algorithm %q", algorithm)
}
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/ledger-enterprise: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/ledger-enterprise: enrolled key is not ECDSA")
}
switch algorithm {
case AlgorithmECDSAP256:
if ecPub.Curve != elliptic.P256() {
return errors.New("approval/ledger-enterprise: ECDSA-P256 expects P-256 key")
}
case AlgorithmECDSAsecp256k1:
if ecPub.Curve.Params().Name != "secp256k1" {
return errors.New("approval/ledger-enterprise: ECDSA-secp256k1 expects secp256k1 key")
}
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = ledgerEnterpriseEntry{
AccountID: accountID,
Algorithm: algorithm,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
func (p *LedgerEnterpriseProvider) EnrollPEM(approverID, accountID, algorithm string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/ledger-enterprise: invalid PEM")
}
return p.Enroll(approverID, accountID, algorithm, block.Bytes)
}
func (p *LedgerEnterpriseProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/ledger-enterprise: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: entry.Algorithm,
}, nil
}
type leTransactionRequest struct {
Workspace string `json:"workspace"`
Type string `json:"type"` // "approval-attestation"
Digest string `json:"digest"` // base64 of intent digest
Algorithm string `json:"signing_algorithm"`
}
type leTransactionResponse struct {
TxID string `json:"transaction_id"`
Status string `json:"status"` // "pending", "approved", "rejected", "failed"
Sig string `json:"signature,omitempty"` // base64 DER signature when approved
Error string `json:"error,omitempty"`
}
func (p *LedgerEnterpriseProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: approver %q not enrolled", approverID)
}
digest := intent.Digest()
body, _ := json.Marshal(leTransactionRequest{
Workspace: p.workspace,
Type: "approval-attestation",
Digest: base64.StdEncoding.EncodeToString(digest[:]),
Algorithm: entry.Algorithm,
})
createURL := fmt.Sprintf("%s/accounts/%s/transactions", p.endpoint, entry.AccountID)
resp, err := p.doRequest(ctx, http.MethodPost, createURL, body)
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: create tx: %w", err)
}
if resp.TxID == "" {
return ApprovalSignature{}, errors.New("approval/ledger-enterprise: empty transaction_id in response")
}
// Trigger approval (Vault notifies the configured approvers' PSDs).
approveURL := fmt.Sprintf("%s/accounts/%s/transactions/%s/approve", p.endpoint, entry.AccountID, resp.TxID)
if _, err := p.doRequest(ctx, http.MethodPost, approveURL, nil); err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: trigger approve: %w", err)
}
// Poll for completion.
deadline := time.Now().Add(p.pollTotal)
pollURL := fmt.Sprintf("%s/accounts/%s/transactions/%s", p.endpoint, entry.AccountID, resp.TxID)
ticker := time.NewTicker(p.pollEvery)
defer ticker.Stop()
for {
status, err := p.doRequest(ctx, http.MethodGet, pollURL, nil)
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: poll: %w", err)
}
switch status.Status {
case "approved":
sigBytes, err := base64.StdEncoding.DecodeString(status.Sig)
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: decode signature: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sigBytes,
Timestamp: time.Now().UTC(),
Algorithm: entry.Algorithm,
}, nil
case "rejected":
return ApprovalSignature{}, errors.New("approval/ledger-enterprise: rejected by approver")
case "failed":
return ApprovalSignature{}, fmt.Errorf("approval/ledger-enterprise: failed: %s", status.Error)
}
select {
case <-ctx.Done():
return ApprovalSignature{}, ctx.Err()
case <-ticker.C:
}
if time.Now().After(deadline) {
return ApprovalSignature{}, errors.New("approval/ledger-enterprise: approval timeout")
}
}
}
func (p *LedgerEnterpriseProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
if entry.Algorithm != sig.Algorithm {
return false, nil
}
// Ledger Enterprise signs SHA-256(intent digest) per the
// approval-attestation transaction type. Wrap the digest so even if
// the digest itself is shorter than 32 bytes, we land on a stable
// 32-byte hash to verify.
h := sha256.Sum256(digest[:])
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
func (p *LedgerEnterpriseProvider) doRequest(ctx context.Context, method, url string, body []byte) (*leTransactionResponse, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("ledger-enterprise: %s %s -> %d: %s", method, url, resp.StatusCode, string(respBody))
}
var out leTransactionResponse
if len(respBody) > 0 {
if err := json.Unmarshal(respBody, &out); err != nil {
return nil, fmt.Errorf("ledger-enterprise: decode response: %w", err)
}
}
return &out, nil
}
+150
View File
@@ -0,0 +1,150 @@
package approval
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
)
// LocalDevProvider holds Ed25519 keypairs in process memory. Test only.
//
// Two safety gates:
//
// 1. NewProvider("local-dev", ...) refuses to start when MPC_ENV=production.
// 2. Even if the factory check were bypassed, this provider refuses to call
// ApproveIntent unless MPC_ENV=dev OR MPC_LOCAL_APPROVAL=true.
//
// Both gates are checked at call time as well as construction time — a
// process that flips MPC_ENV to "production" between construction and use
// MUST stop signing approvals.
type LocalDevProvider struct {
mu sync.RWMutex
keys map[string]ed25519.PrivateKey // approverID -> private key
}
// NewLocalDev constructs a LocalDevProvider. config is currently ignored
// (Ed25519 has no algorithm choice). Future config keys may carry seed
// material for deterministic test vectors.
func NewLocalDev(config map[string]string) (ApprovalProvider, error) {
_ = config
if err := localDevAllowed(); err != nil {
return nil, err
}
return &LocalDevProvider{
keys: make(map[string]ed25519.PrivateKey),
}, nil
}
func (p *LocalDevProvider) Provider() string { return "local-dev" }
// localDevAllowed returns nil iff this build/process is permitted to use
// software approval keys. Production deployments must always return non-nil.
func localDevAllowed() error {
env := strings.ToLower(os.Getenv("MPC_ENV"))
if env == "production" || env == "prod" {
return errors.New("approval/local-dev: forbidden in production (MPC_ENV=" + env + ")")
}
flag := strings.ToLower(os.Getenv("MPC_LOCAL_APPROVAL"))
if env == "dev" || env == "development" || env == "test" || env == "testing" || flag == "true" || flag == "1" {
return nil
}
// Default-deny: if MPC_ENV is unset, require explicit opt-in.
return errors.New("approval/local-dev: requires MPC_ENV=dev or MPC_LOCAL_APPROVAL=true")
}
// getOrCreateKey returns the Ed25519 key for approverID, generating a new
// one on first use. Tests may pre-seed via Enroll.
func (p *LocalDevProvider) getOrCreateKey(approverID string) (ed25519.PrivateKey, error) {
p.mu.RLock()
if k, ok := p.keys[approverID]; ok {
p.mu.RUnlock()
return k, nil
}
p.mu.RUnlock()
p.mu.Lock()
defer p.mu.Unlock()
if k, ok := p.keys[approverID]; ok {
return k, nil
}
_, sk, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("approval/local-dev: keygen failed for %q: %w", approverID, err)
}
p.keys[approverID] = sk
return sk, nil
}
// Enroll injects a pre-existing Ed25519 keypair for the given approverID.
// Tests use this to set up deterministic identities before approvals.
func (p *LocalDevProvider) Enroll(approverID string, sk ed25519.PrivateKey) {
p.mu.Lock()
defer p.mu.Unlock()
p.keys[approverID] = sk
}
func (p *LocalDevProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
if err := localDevAllowed(); err != nil {
return PublicIdentity{}, err
}
sk, err := p.getOrCreateKey(approverID)
if err != nil {
return PublicIdentity{}, err
}
pk := sk.Public().(ed25519.PublicKey)
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: []byte(pk),
Algorithm: AlgorithmEd25519,
}, nil
}
func (p *LocalDevProvider) ApproveIntent(_ context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
if err := localDevAllowed(); err != nil {
return ApprovalSignature{}, err
}
sk, err := p.getOrCreateKey(approverID)
if err != nil {
return ApprovalSignature{}, err
}
digest := intent.Digest()
sig := ed25519.Sign(sk, digest[:])
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmEd25519,
}, nil
}
func (p *LocalDevProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
// Cross-provider integrity: sig.Provider must claim local-dev, otherwise
// reject — keep verifiers honest about which keyset they're checking.
if sig.Provider != p.Provider() {
return false, nil
}
if sig.Algorithm != AlgorithmEd25519 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
sk, ok := p.keys[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
pk := sk.Public().(ed25519.PublicKey)
return ed25519.Verify(pk, digest[:], sig.Signature), nil
}
+129
View File
@@ -0,0 +1,129 @@
package approval
import (
"context"
"crypto"
"crypto/rand"
"errors"
"fmt"
"sync"
"time"
"github.com/luxfi/crypto/mldsa"
)
// MLDSAProvider produces ML-DSA-65 (FIPS 204, NIST level 3) post-quantum
// approval signatures. Use for long-lived attestations where you need
// resistance to future quantum attacks (e.g. board-level approvals
// archived for 10+ years).
//
// We hold private keys in process memory, mirroring luxfi/hsm's MLDSASigner.
// For production HSM-backed PQ approvals, use AWS KMS (which now supports
// ML-DSA) and prefer the "aws-kms" provider with an ML-DSA configured key —
// or wait for hardware ML-DSA support in Zymbit/YubiHSM.
//
// Configuration: none. Mode is fixed at ML-DSA-65.
type MLDSAProvider struct {
mu sync.RWMutex
identity map[string]mldsaEntry
}
type mldsaEntry struct {
PrivKey *mldsa.PrivateKey
PubKey *mldsa.PublicKey
PubBytes []byte
}
func NewMLDSA(config map[string]string) (ApprovalProvider, error) {
_ = config
return &MLDSAProvider{
identity: make(map[string]mldsaEntry),
}, nil
}
func (p *MLDSAProvider) Provider() string { return "mldsa" }
// Enroll either generates a new ML-DSA-65 keypair for approverID (if
// privBytes is nil) or restores a known key from raw bytes.
func (p *MLDSAProvider) Enroll(approverID string, privBytes []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
var sk *mldsa.PrivateKey
if len(privBytes) == 0 {
generated, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
return fmt.Errorf("approval/mldsa: keygen for %q: %w", approverID, err)
}
sk = generated
} else {
restored, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, privBytes)
if err != nil {
return fmt.Errorf("approval/mldsa: restore key for %q: %w", approverID, err)
}
sk = restored
}
p.identity[approverID] = mldsaEntry{
PrivKey: sk,
PubKey: sk.PublicKey,
PubBytes: sk.PublicKey.Bytes(),
}
return nil
}
func (p *MLDSAProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/mldsa: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubBytes,
Algorithm: AlgorithmMLDSA65,
}, nil
}
func (p *MLDSAProvider) ApproveIntent(_ context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/mldsa: approver %q not enrolled", approverID)
}
digest := intent.Digest()
sig, err := entry.PrivKey.Sign(rand.Reader, intent.Bytes(), crypto.Hash(0))
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/mldsa: sign: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmMLDSA65,
}, nil
}
func (p *MLDSAProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmMLDSA65 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
return entry.PubKey.VerifySignature(intent.Bytes(), sig.Signature), nil
}
// errMLDSAUnenrolled — kept for callers that want a clean sentinel for the
// "no such approver" case.
var errMLDSAUnenrolled = errors.New("approval/mldsa: approver not enrolled")
+316
View File
@@ -0,0 +1,316 @@
package approval
import (
"context"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
)
// SafeMultisigProvider treats a Gnosis Safe (now Safe) execTransaction as
// the approval. The Safe contract enforces N-of-M owner signatures on-chain;
// once `execTransaction` succeeds, that's a cryptographic proof of approval
// by the configured Safe owners.
//
// Layout of an approval bundle for this provider:
//
// [chainID (8B BE)] [safeAddress (20B)] [safeTxHash (32B)] [execTxHash (32B)]
//
// Verification: a service caller checks
//
// 1. safeTxHash == Safe.getTransactionHash(intent.Bytes() / EIP-712 typed data)
// 2. execTxHash is mined (≥ 1 confirmation) on the chain
// 3. execTxHash receipt logs include `ExecutionSuccess(safeTxHash)`
//
// This package only validates the bundle's *shape* — on-chain confirmation
// is the caller's responsibility (it requires an EVM RPC client we don't
// want as a dep here). Operators must wire a chain client at the API layer.
//
// Configuration:
//
// chain_id — decimal chain ID (e.g. "1" mainnet, "42161" arbitrum)
// safe_address — 0x-prefixed Safe address (20 bytes)
//
// Approver enrollment: register approverID -> Safe address. One Safe can
// represent any number of logical approvers (each with their own owner key)
// — the Safe quorum *is* the approval threshold, so a single Safe maps to
// approverID = "<org>-treasury-safe".
type SafeMultisigProvider struct {
chainID uint64
safeAddress [20]byte
mu sync.RWMutex
pending map[string]*pendingSafe
identity map[string]safeEntry
}
type safeEntry struct {
SafeAddress [20]byte
ChainID uint64
}
type pendingSafe struct {
intent CanonicalIntent
expiresAt time.Time
resultCh chan safeResult
}
type safeResult struct {
sig ApprovalSignature
err error
}
func NewSafeMultisig(config map[string]string) (ApprovalProvider, error) {
if config == nil {
return nil, errors.New("approval/safe: config required (chain_id, safe_address)")
}
chainIDRaw := config["chain_id"]
if chainIDRaw == "" {
return nil, errors.New("approval/safe: chain_id required")
}
var chainID uint64
if _, err := fmt.Sscanf(chainIDRaw, "%d", &chainID); err != nil || chainID == 0 {
return nil, fmt.Errorf("approval/safe: invalid chain_id %q", chainIDRaw)
}
addrHex := config["safe_address"]
addr, err := parseHex20(addrHex)
if err != nil {
return nil, fmt.Errorf("approval/safe: %w", err)
}
return &SafeMultisigProvider{
chainID: chainID,
safeAddress: addr,
pending: make(map[string]*pendingSafe),
identity: make(map[string]safeEntry),
}, nil
}
func (p *SafeMultisigProvider) Provider() string { return "safe-multisig" }
// Enroll registers an approver-to-Safe mapping. Each approverID is the
// logical name (e.g. "treasury-3of5"); the Safe's owner set defines who
// can actually sign. chainID and safeAddress override the provider-level
// config — useful for multi-chain deployments where one provider serves
// several Safes (e.g. one per chain).
func (p *SafeMultisigProvider) Enroll(approverID string, chainID uint64, safeAddressHex string) error {
addr, err := parseHex20(safeAddressHex)
if err != nil {
return fmt.Errorf("approval/safe: enroll: %w", err)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = safeEntry{
SafeAddress: addr,
ChainID: chainID,
}
return nil
}
func (p *SafeMultisigProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
// Default to the provider-level Safe if no per-approver mapping.
entry = safeEntry{SafeAddress: p.safeAddress, ChainID: p.chainID}
}
pub := make([]byte, 28)
binary.BigEndian.PutUint64(pub[:8], entry.ChainID)
copy(pub[8:], entry.SafeAddress[:])
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: pub,
Algorithm: AlgorithmSafeContract,
}, nil
}
// SubmitExecution is called by the API once the Safe transaction is mined.
// safeTxHash is the EIP-712 hash of the SafeTx struct; execTxHash is the
// L1 transaction hash carrying the execTransaction call.
//
// In production, the caller MUST verify on-chain that execTxHash succeeded
// and emitted ExecutionSuccess(safeTxHash) before invoking SubmitExecution.
// This package does NOT do that — it only assembles the bundle.
func (p *SafeMultisigProvider) SubmitExecution(_ context.Context, sessionID string, safeTxHash, execTxHash [32]byte) (ApprovalSignature, error) {
p.mu.Lock()
pending, ok := p.pending[sessionID]
if !ok {
p.mu.Unlock()
return ApprovalSignature{}, errors.New("approval/safe: no pending session")
}
if time.Now().After(pending.expiresAt) {
delete(p.pending, sessionID)
p.mu.Unlock()
return ApprovalSignature{}, errors.New("approval/safe: session expired")
}
delete(p.pending, sessionID)
p.mu.Unlock()
approverID, _, err := splitWebAuthnSession(sessionID)
if err != nil {
return ApprovalSignature{}, err
}
p.mu.RLock()
entry, ok := p.identity[approverID]
if !ok {
entry = safeEntry{SafeAddress: p.safeAddress, ChainID: p.chainID}
}
p.mu.RUnlock()
bundle := encodeSafeBundle(entry.ChainID, entry.SafeAddress, safeTxHash, execTxHash)
digest := pending.intent.Digest()
sig := ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: bundle,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmSafeContract,
}
select {
case pending.resultCh <- safeResult{sig: sig}:
default:
}
return sig, nil
}
// IssueChallenge starts a Safe approval session. The caller is responsible
// for proposing the corresponding execTransaction on-chain (typically via
// Safe Transaction Service or the Safe SDK) — this provider only tracks
// the resulting safeTxHash / execTxHash bundle.
func (p *SafeMultisigProvider) IssueChallenge(_ context.Context, approverID string, intent CanonicalIntent) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
digest := intent.Digest()
sessionID := approverID + "/" + hexEncode(digest[:])
p.pending[sessionID] = &pendingSafe{
intent: intent,
expiresAt: time.Now().Add(15 * time.Minute), // longer for on-chain ceremonies
resultCh: make(chan safeResult, 1),
}
return sessionID, nil
}
// ApproveIntent issues a session and blocks waiting for SubmitExecution.
// The caller (orchestrator) typically uses the explicit session API
// because Safe ceremonies span minutes-to-hours.
func (p *SafeMultisigProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
sessionID, err := p.IssueChallenge(ctx, approverID, intent)
if err != nil {
return ApprovalSignature{}, err
}
p.mu.Lock()
pending := p.pending[sessionID]
p.mu.Unlock()
if pending == nil {
return ApprovalSignature{}, errors.New("approval/safe: pending session lost")
}
select {
case <-ctx.Done():
p.mu.Lock()
delete(p.pending, sessionID)
p.mu.Unlock()
return ApprovalSignature{}, ctx.Err()
case res := <-pending.resultCh:
if res.err != nil {
return ApprovalSignature{}, res.err
}
return res.sig, nil
}
}
func (p *SafeMultisigProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmSafeContract {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
chainID, safeAddr, _, _, err := decodeSafeBundle(sig.Signature)
if err != nil {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
entry = safeEntry{SafeAddress: p.safeAddress, ChainID: p.chainID}
}
if chainID != entry.ChainID {
return false, nil
}
if safeAddr != entry.SafeAddress {
return false, nil
}
// Note: this verifier returns true once the bundle structurally matches
// the enrolled Safe. The caller MUST independently verify execTxHash on
// chain — see SubmitExecution doc. Not having an EVM client in this
// package is deliberate; that's a wiring concern at the API layer.
return true, nil
}
func encodeSafeBundle(chainID uint64, safeAddr [20]byte, safeTxHash, execTxHash [32]byte) []byte {
out := make([]byte, 8+20+32+32)
binary.BigEndian.PutUint64(out[:8], chainID)
copy(out[8:28], safeAddr[:])
copy(out[28:60], safeTxHash[:])
copy(out[60:92], execTxHash[:])
return out
}
func decodeSafeBundle(b []byte) (chainID uint64, safeAddr [20]byte, safeTxHash [32]byte, execTxHash [32]byte, err error) {
if len(b) != 92 {
err = fmt.Errorf("approval/safe: bundle wrong length: %d", len(b))
return
}
chainID = binary.BigEndian.Uint64(b[:8])
copy(safeAddr[:], b[8:28])
copy(safeTxHash[:], b[28:60])
copy(execTxHash[:], b[60:92])
return
}
func parseHex20(s string) ([20]byte, error) {
var out [20]byte
if len(s) == 42 && (s[:2] == "0x" || s[:2] == "0X") {
s = s[2:]
}
if len(s) != 40 {
return out, fmt.Errorf("hex address must be 20 bytes (40 hex chars), got %d", len(s))
}
for i := 0; i < 20; i++ {
hi, ok1 := fromHexNibble(s[2*i])
lo, ok2 := fromHexNibble(s[2*i+1])
if !ok1 || !ok2 {
return out, fmt.Errorf("invalid hex byte at offset %d", 2*i)
}
out[i] = hi<<4 | lo
}
return out, nil
}
func fromHexNibble(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'a' && c <= 'f':
return c - 'a' + 10, true
case c >= 'A' && c <= 'F':
return c - 'A' + 10, true
}
return 0, false
}
func hexEncode(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, c := range b {
out[i*2] = hexdigits[c>>4]
out[i*2+1] = hexdigits[c&0xf]
}
return string(out)
}
+318
View File
@@ -0,0 +1,318 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"sync"
"time"
mpcwebauthn "github.com/luxfi/mpc/pkg/webauthn"
)
// WebAuthnProvider implements ApprovalProvider for FIDO2 / WebAuthn
// authenticators (passkeys, YubiKeys, platform authenticators).
//
// WebAuthn is a *multi-step* ceremony: the server issues a challenge, the
// authenticator signs it, the client returns the assertion. The synchronous
// ApproveIntent contract requires that all three happen during the call.
//
// We bridge this with a small in-memory pending-challenge map: ApproveIntent
// issues a challenge, blocks on a channel, and unblocks when the API
// completes the ceremony via SubmitAssertion. Callers that don't want to
// block can use IssueChallenge / SubmitAssertion explicitly — the API's
// `/v1/approval/intent/{id}/cast` route does this.
//
// Configuration:
//
// rpid — Relying Party ID (e.g. "approvals.fund.com"). Required.
// origins — comma-separated allowed origins (e.g. "https://approvals.fund.com").
// In production, MUST be HTTPS.
// timeout — challenge TTL (default 5m). Override with "300s" / "5m".
//
// HTTPS enforcement: NewWebAuthn requires HTTPS origins unless MPC_ENV=dev.
type WebAuthnProvider struct {
rpID string
allowedOrigins map[string]bool
timeout time.Duration
mu sync.Mutex
identity map[string]webauthnEntry
pending map[string]*pendingWebAuthn
}
type webauthnEntry struct {
CredentialID string // base64url-encoded WebAuthn credential ID
PubKey *ecdsa.PublicKey
PubKeyUncompressed []byte // 65-byte 0x04||X||Y
}
type pendingWebAuthn struct {
intent CanonicalIntent
challenge []byte // 32 random bytes; binds to intent.Digest()
expiresAt time.Time
resultCh chan webauthnResult
}
type webauthnResult struct {
sig ApprovalSignature
err error
}
// NewWebAuthn constructs a WebAuthn ApprovalProvider.
func NewWebAuthn(config map[string]string) (ApprovalProvider, error) {
if config == nil {
return nil, errors.New("approval/webauthn: config required (rpid, origins)")
}
rpID := config["rpid"]
if rpID == "" {
return nil, errors.New("approval/webauthn: rpid required")
}
originsRaw := config["origins"]
if originsRaw == "" {
return nil, errors.New("approval/webauthn: origins required (comma-separated)")
}
allowed := map[string]bool{}
for _, o := range splitCommaTrim(originsRaw) {
if !isHTTPSOrDev(o) {
return nil, fmt.Errorf("approval/webauthn: origin %q must be HTTPS in production (set MPC_ENV=dev to permit http://localhost)", o)
}
allowed[o] = true
}
timeout := 5 * time.Minute
if tRaw := config["timeout"]; tRaw != "" {
t, err := time.ParseDuration(tRaw)
if err != nil {
return nil, fmt.Errorf("approval/webauthn: parse timeout: %w", err)
}
timeout = t
}
return &WebAuthnProvider{
rpID: rpID,
allowedOrigins: allowed,
timeout: timeout,
identity: make(map[string]webauthnEntry),
pending: make(map[string]*pendingWebAuthn),
}, nil
}
func (p *WebAuthnProvider) Provider() string { return "webauthn" }
// Enroll registers an approver's WebAuthn credential. publicKey is the raw
// 65-byte uncompressed P-256 point (0x04||X||Y) extracted from the
// attestation object during the WebAuthn create ceremony.
func (p *WebAuthnProvider) Enroll(approverID, credentialID string, publicKey []byte) error {
if len(publicKey) != 65 || publicKey[0] != 0x04 {
return errors.New("approval/webauthn: public key must be 65-byte uncompressed P-256")
}
pub, err := mpcwebauthn.ParseUncompressedP256(base64.StdEncoding.EncodeToString(publicKey))
if err != nil {
return fmt.Errorf("approval/webauthn: parse public key: %w", err)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = webauthnEntry{
CredentialID: credentialID,
PubKey: pub,
PubKeyUncompressed: publicKey,
}
return nil
}
func (p *WebAuthnProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.Lock()
defer p.mu.Unlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/webauthn: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyUncompressed,
Algorithm: AlgorithmWebAuthnES256,
}, nil
}
// IssueChallenge produces a WebAuthn challenge bound to the intent digest.
// The challenge is `intent.Digest()` directly — no extra randomness is
// required because the digest already commits to the full intent. The
// browser-side WebAuthn client will sign over `authData || SHA256(clientData)`
// where clientData.challenge == base64url(challenge).
//
// Returns a sessionID the API uses to track this pending approval.
func (p *WebAuthnProvider) IssueChallenge(_ context.Context, approverID string, intent CanonicalIntent) (sessionID string, challenge []byte, err error) {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.identity[approverID]; !ok {
return "", nil, fmt.Errorf("approval/webauthn: approver %q not enrolled", approverID)
}
digest := intent.Digest()
chal := make([]byte, 32)
copy(chal, digest[:])
sessionID = approverID + "/" + base64.RawURLEncoding.EncodeToString(digest[:])
p.pending[sessionID] = &pendingWebAuthn{
intent: intent,
challenge: chal,
expiresAt: time.Now().Add(p.timeout),
resultCh: make(chan webauthnResult, 1),
}
return sessionID, chal, nil
}
// SubmitAssertion is called by the API when the browser returns the
// WebAuthn assertion bundle. It verifies the assertion against the stored
// challenge + public key, and unblocks any waiting ApproveIntent caller.
func (p *WebAuthnProvider) SubmitAssertion(_ context.Context, sessionID string, clientDataJSONB64, authDataB64, signatureB64 string) (ApprovalSignature, error) {
p.mu.Lock()
pending, ok := p.pending[sessionID]
if !ok {
p.mu.Unlock()
return ApprovalSignature{}, errors.New("approval/webauthn: no pending challenge for session")
}
if time.Now().After(pending.expiresAt) {
delete(p.pending, sessionID)
p.mu.Unlock()
return ApprovalSignature{}, errors.New("approval/webauthn: challenge expired")
}
approverID, _, err := splitWebAuthnSession(sessionID)
if err != nil {
p.mu.Unlock()
return ApprovalSignature{}, err
}
entry, ok := p.identity[approverID]
if !ok {
p.mu.Unlock()
return ApprovalSignature{}, fmt.Errorf("approval/webauthn: approver %q not enrolled", approverID)
}
pubB64 := base64.StdEncoding.EncodeToString(entry.PubKeyUncompressed)
p.mu.Unlock()
out, err := mpcwebauthn.Verify(&mpcwebauthn.Opts{
Ceremony: mpcwebauthn.CeremonyGet,
ExpectedChallenge: pending.challenge,
AllowedOrigins: p.allowedOrigins,
RPID: p.rpID,
RequireUP: true,
RequireUV: true,
PublicKeyB64: pubB64,
ClientDataJSONB64: clientDataJSONB64,
AuthDataB64: authDataB64,
SignatureB64: signatureB64,
})
if err != nil {
// On verify failure, signal failure to any waiter and remove pending.
p.mu.Lock()
delete(p.pending, sessionID)
p.mu.Unlock()
select {
case pending.resultCh <- webauthnResult{err: err}:
default:
}
return ApprovalSignature{}, err
}
if !out.SignedOK {
p.mu.Lock()
delete(p.pending, sessionID)
p.mu.Unlock()
return ApprovalSignature{}, errors.New("approval/webauthn: assertion did not produce a verified signature")
}
// Build the canonical ApprovalSignature. The signature stored is the
// concatenation of (authData, clientDataJSON, sig) base64-encoded —
// future verifiers can re-run the WebAuthn pipeline. We also store
// the raw assertion sig so simple verifiers can compare against the
// stored public key directly.
digest := pending.intent.Digest()
sigBytes, err := base64.URLEncoding.DecodeString(signatureB64)
if err != nil {
if alt, alterr := base64.RawURLEncoding.DecodeString(signatureB64); alterr == nil {
sigBytes = alt
} else {
return ApprovalSignature{}, errors.New("approval/webauthn: invalid signature encoding")
}
}
clientData, _ := base64.URLEncoding.DecodeString(clientDataJSONB64)
if len(clientData) == 0 {
clientData, _ = base64.RawURLEncoding.DecodeString(clientDataJSONB64)
}
authData, _ := base64.URLEncoding.DecodeString(authDataB64)
if len(authData) == 0 {
authData, _ = base64.RawURLEncoding.DecodeString(authDataB64)
}
bundled := encodeWebAuthnBundle(authData, clientData, sigBytes)
approval := ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: bundled,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmWebAuthnES256,
}
p.mu.Lock()
delete(p.pending, sessionID)
p.mu.Unlock()
select {
case pending.resultCh <- webauthnResult{sig: approval}:
default:
}
return approval, nil
}
// ApproveIntent issues a challenge and blocks waiting for SubmitAssertion.
// API endpoints typically use IssueChallenge + the explicit ceremony
// instead — ApproveIntent is for orchestrator integration where the
// orchestrator manages the timeout via context.
func (p *WebAuthnProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
sessionID, _, err := p.IssueChallenge(ctx, approverID, intent)
if err != nil {
return ApprovalSignature{}, err
}
p.mu.Lock()
pending := p.pending[sessionID]
p.mu.Unlock()
if pending == nil {
return ApprovalSignature{}, errors.New("approval/webauthn: pending session lost")
}
select {
case <-ctx.Done():
p.mu.Lock()
delete(p.pending, sessionID)
p.mu.Unlock()
return ApprovalSignature{}, ctx.Err()
case res := <-pending.resultCh:
if res.err != nil {
return ApprovalSignature{}, res.err
}
return res.sig, nil
}
}
func (p *WebAuthnProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmWebAuthnES256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
authData, clientData, sigBytes, err := decodeWebAuthnBundle(sig.Signature)
if err != nil {
return false, nil
}
p.mu.Lock()
entry, ok := p.identity[sig.ApproverID]
p.mu.Unlock()
if !ok {
return false, nil
}
clientDataHash := sha256.Sum256(clientData)
signed := append(append([]byte{}, authData...), clientDataHash[:]...)
signedHash := sha256.Sum256(signed)
return ecdsa.VerifyASN1(entry.PubKey, signedHash[:], sigBytes), nil
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"
luxhsm "github.com/luxfi/hsm"
)
// YubiHSMProvider produces ECDSA-P256 approval signatures via a Yubico
// YubiHSM 2 device. The device is reached over the yubihsm-connector
// daemon (default http://127.0.0.1:12345) using the yubihsm-shell tool.
//
// Configuration:
//
// connector_url — yubihsm-connector address (default "http://127.0.0.1:12345").
// auth_key_id — YubiHSM authentication object ID (decimal).
// password — session password (KMS-supplied).
// algorithm — signing algorithm ("ecdsa-sha256", "ed25519").
//
// Approver enrollment maps approverID -> YubiHSM object ID + cached SPKI
// public key. Object IDs are provisioned out-of-band with `yubihsm-shell
// put-asymmetric-key` (or generate-asymmetric-key for on-device key gen).
type YubiHSMProvider struct {
signer luxhsm.Signer
mu sync.RWMutex
identity map[string]yubihsmEntry
}
type yubihsmEntry struct {
ObjectID string // YubiHSM object ID ("12", "0x000c").
PubKey *ecdsa.PublicKey
PubKeyDER []byte
}
// NewYubiHSM constructs a YubiHSMProvider. The underlying luxfi/hsm
// signer handles wire-protocol details (session auth, AES-CMAC, AES-CBC).
func NewYubiHSM(config map[string]string) (ApprovalProvider, error) {
signer, err := luxhsm.NewSigner("yubihsm", config)
if err != nil {
return nil, fmt.Errorf("approval/yubihsm: %w", err)
}
return &YubiHSMProvider{
signer: signer,
identity: make(map[string]yubihsmEntry),
}, nil
}
func (p *YubiHSMProvider) Provider() string { return "yubihsm" }
// Enroll registers approverID -> objectID with a pinned PKIX-encoded
// ECDSA P-256 public key. The public key MUST be pulled from the
// YubiHSM at enrollment time (yubihsm-shell get-public-key) so the
// device cannot impersonate a different signer later.
func (p *YubiHSMProvider) Enroll(approverID, objectID string, pubKeyDER []byte) error {
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/yubihsm: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/yubihsm: enrolled key is not ECDSA")
}
if ecPub.Curve != elliptic.P256() {
return fmt.Errorf("approval/yubihsm: unsupported curve %s (want P-256)", ecPub.Curve.Params().Name)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = yubihsmEntry{
ObjectID: objectID,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
// EnrollPEM accepts a PEM-encoded public key (typical output of
// `yubihsm-shell get-public-key --out-format=PEM`).
func (p *YubiHSMProvider) EnrollPEM(approverID, objectID string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/yubihsm: invalid PEM")
}
return p.Enroll(approverID, objectID, block.Bytes)
}
func (p *YubiHSMProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/yubihsm: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *YubiHSMProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/yubihsm: approver %q not enrolled", approverID)
}
digest := intent.Digest()
sig, err := p.signer.Sign(ctx, entry.ObjectID, intent.Bytes())
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/yubihsm: sign: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *YubiHSMProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmECDSAP256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
h := sha256.Sum256(intent.Bytes())
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
+142
View File
@@ -0,0 +1,142 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"
luxhsm "github.com/luxfi/hsm"
)
// ZymbitProvider produces ECDSA-P256 approval signatures via Zymbit SCM
// (HSM6 / ZYMKEY) over the local REST API. Useful for on-prem, air-gapped,
// or edge approval ceremonies where the executive is co-located with the
// Zymbit device.
//
// Configuration:
//
// api_addr — Zymbit REST API URL (default "http://localhost:6789").
//
// Approver enrollment maps approverID -> Zymbit slot ID + cached SPKI public
// key. Slots must be provisioned out-of-band on the Zymbit device.
type ZymbitProvider struct {
signer luxhsm.Signer
mu sync.RWMutex
identity map[string]zymbitEntry
}
type zymbitEntry struct {
SlotID string // Zymbit slot ("0", "1", ...)
PubKey *ecdsa.PublicKey
PubKeyDER []byte
}
func NewZymbit(config map[string]string) (ApprovalProvider, error) {
apiAddr := ""
if config != nil {
apiAddr = config["api_addr"]
}
signer, err := luxhsm.NewSigner("zymbit", map[string]string{"api_addr": apiAddr})
if err != nil {
return nil, fmt.Errorf("approval/zymbit: %w", err)
}
return &ZymbitProvider{
signer: signer,
identity: make(map[string]zymbitEntry),
}, nil
}
func (p *ZymbitProvider) Provider() string { return "zymbit" }
func (p *ZymbitProvider) Enroll(approverID, slotID string, pubKeyDER []byte) error {
pub, err := x509.ParsePKIXPublicKey(pubKeyDER)
if err != nil {
return fmt.Errorf("approval/zymbit: parse public key: %w", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return errors.New("approval/zymbit: enrolled key is not ECDSA")
}
if ecPub.Curve != elliptic.P256() {
return fmt.Errorf("approval/zymbit: unsupported curve %s (want P-256)", ecPub.Curve.Params().Name)
}
p.mu.Lock()
defer p.mu.Unlock()
p.identity[approverID] = zymbitEntry{
SlotID: slotID,
PubKey: ecPub,
PubKeyDER: pubKeyDER,
}
return nil
}
func (p *ZymbitProvider) EnrollPEM(approverID, slotID string, pemBytes []byte) error {
block, _ := pem.Decode(pemBytes)
if block == nil {
return errors.New("approval/zymbit: invalid PEM")
}
return p.Enroll(approverID, slotID, block.Bytes)
}
func (p *ZymbitProvider) GetPublicIdentity(_ context.Context, approverID string) (PublicIdentity, error) {
p.mu.RLock()
defer p.mu.RUnlock()
entry, ok := p.identity[approverID]
if !ok {
return PublicIdentity{}, fmt.Errorf("approval/zymbit: approver %q not enrolled", approverID)
}
return PublicIdentity{
ApproverID: approverID,
Provider: p.Provider(),
PublicKey: entry.PubKeyDER,
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *ZymbitProvider) ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error) {
p.mu.RLock()
entry, ok := p.identity[approverID]
p.mu.RUnlock()
if !ok {
return ApprovalSignature{}, fmt.Errorf("approval/zymbit: approver %q not enrolled", approverID)
}
digest := intent.Digest()
sig, err := p.signer.Sign(ctx, entry.SlotID, intent.Bytes())
if err != nil {
return ApprovalSignature{}, fmt.Errorf("approval/zymbit: sign: %w", err)
}
return ApprovalSignature{
ApproverID: approverID,
Provider: p.Provider(),
IntentDigest: digest,
Signature: sig,
Timestamp: time.Now().UTC(),
Algorithm: AlgorithmECDSAP256,
}, nil
}
func (p *ZymbitProvider) VerifyApproval(_ context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error) {
if sig.Provider != p.Provider() || sig.Algorithm != AlgorithmECDSAP256 {
return false, nil
}
digest := intent.Digest()
if digest != sig.IntentDigest {
return false, nil
}
p.mu.RLock()
entry, ok := p.identity[sig.ApproverID]
p.mu.RUnlock()
if !ok {
return false, nil
}
h := sha256.Sum256(intent.Bytes())
return ecdsa.VerifyASN1(entry.PubKey, h[:], sig.Signature), nil
}
+106
View File
@@ -0,0 +1,106 @@
package approval
import (
"context"
"testing"
)
func TestSafe_RoundTrip(t *testing.T) {
provider, err := NewSafeMultisig(map[string]string{
"chain_id": "1",
"safe_address": "0x1234567890abcdef1234567890abcdef12345678",
})
if err != nil {
t.Fatalf("NewSafeMultisig: %v", err)
}
sp := provider.(*SafeMultisigProvider)
if err := sp.Enroll("treasury-3of5", 1, "0x1234567890abcdef1234567890abcdef12345678"); err != nil {
t.Fatalf("Enroll: %v", err)
}
intent := newTestIntent("send 100 ETH to grant recipient")
sessionID, err := sp.IssueChallenge(context.Background(), "treasury-3of5", intent)
if err != nil {
t.Fatalf("IssueChallenge: %v", err)
}
// Simulate the on-chain execution producing safeTxHash + execTxHash.
var safeTxHash, execTxHash [32]byte
for i := range safeTxHash {
safeTxHash[i] = byte(i)
execTxHash[i] = byte(0xff - i)
}
sig, err := sp.SubmitExecution(context.Background(), sessionID, safeTxHash, execTxHash)
if err != nil {
t.Fatalf("SubmitExecution: %v", err)
}
if sig.Algorithm != AlgorithmSafeContract {
t.Fatalf("Algorithm = %q, want %q", sig.Algorithm, AlgorithmSafeContract)
}
if len(sig.Signature) != 92 {
t.Fatalf("Signature length = %d, want 92", len(sig.Signature))
}
ok, err := sp.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if !ok {
t.Fatal("VerifyApproval = false, want true")
}
// Tampered intent → verification fails (digest mismatch).
other := newTestIntent("different")
ok, _ = sp.VerifyApproval(context.Background(), other, sig)
if ok {
t.Fatal("VerifyApproval(tampered) = true, want false")
}
// Bundle with wrong chain → fail.
tamperedSig := sig
tamperedSig.Signature = make([]byte, 92)
copy(tamperedSig.Signature, sig.Signature)
tamperedSig.Signature[7] = 0xff // chainID byte
ok, _ = sp.VerifyApproval(context.Background(), intent, tamperedSig)
if ok {
t.Fatal("VerifyApproval(wrong chain) = true, want false")
}
}
func TestSafe_BadConfig(t *testing.T) {
if _, err := NewSafeMultisig(nil); err == nil {
t.Fatal("NewSafeMultisig(nil): want error")
}
if _, err := NewSafeMultisig(map[string]string{"chain_id": "1"}); err == nil {
t.Fatal("NewSafeMultisig without safe_address: want error")
}
if _, err := NewSafeMultisig(map[string]string{"safe_address": "0x12"}); err == nil {
t.Fatal("NewSafeMultisig with short address: want error")
}
}
func TestSafe_BundleEncoding(t *testing.T) {
addr := [20]byte{1, 2, 3, 4, 5}
var safeTxHash, execTxHash [32]byte
safeTxHash[0] = 0xab
execTxHash[31] = 0xcd
bundle := encodeSafeBundle(42, addr, safeTxHash, execTxHash)
chainID, gotAddr, gotSafe, gotExec, err := decodeSafeBundle(bundle)
if err != nil {
t.Fatalf("decodeSafeBundle: %v", err)
}
if chainID != 42 {
t.Fatalf("chainID = %d, want 42", chainID)
}
if gotAddr != addr {
t.Fatal("addr mismatch")
}
if gotSafe != safeTxHash {
t.Fatal("safeTxHash mismatch")
}
if gotExec != execTxHash {
t.Fatal("execTxHash mismatch")
}
}
+18
View File
@@ -0,0 +1,18 @@
package approval
import "crypto/sha256"
// testIntent is a tiny CanonicalIntent stand-in for the approval tests so
// they don't need pkg/intent (still being authored by a sibling task).
// Identical interface contract: Digest() returns 32 bytes, Bytes() returns
// the canonical pre-image.
type testIntent struct {
body []byte
}
func newTestIntent(s string) *testIntent {
return &testIntent{body: []byte(s)}
}
func (t *testIntent) Digest() [32]byte { return sha256.Sum256(t.body) }
func (t *testIntent) Bytes() []byte { return t.body }
+112
View File
@@ -0,0 +1,112 @@
// Package approval provides ApprovalProvider — a pluggable interface for
// out-of-band executive approval of CanonicalIntents.
//
// Approvals are *separate* from MPC signing. The MPC nodes sign the actual
// chain transaction; ApprovalProvider attests that an authorized human (or
// authorized programmatic key) approved the intent before signing began.
//
// Why separate? An MPC quorum that both *authorizes* and *signs* is one
// compromise away from total custody loss. Out-of-band approvers (Ledger
// Enterprise, hardware-backed WebAuthn, KMS-held keys, Safe multisig) raise
// the bar to "compromise the MPC quorum AND the executive's distinct device".
//
// One way to wire approval:
//
// intent := intent.New(...)
// bundle, err := orchestrator.CollectApprovals(ctx, intent, approvers, threshold)
// // intent.SetApprovals(bundle); MPC signing only proceeds when
// // bundle has >= threshold valid signatures from required approvers.
package approval
import (
"context"
"time"
)
// CanonicalIntent is the contract surface this package needs from
// pkg/intent.CanonicalIntent. Anything that produces a stable digest and
// canonical bytes can be approved. We do not import pkg/intent here —
// that would create a coupling we don't need.
//
// pkg/intent.CanonicalIntent's concrete type satisfies this interface via
// method-set match; callers pass it directly.
type CanonicalIntent interface {
// Digest returns the canonical 32-byte hash committed to by approvers.
Digest() [32]byte
// Bytes returns the canonical serialized intent for re-derivation /
// audit. Approvers MUST verify Digest() == hash(Bytes()) before signing.
Bytes() []byte
}
// ApprovalProvider is the pluggable interface every approval backend
// implements. Implementations include cloud KMS, hardware HSMs (Ledger /
// YubiKey / Zymbit), WebAuthn passkeys, EVM Safes, and post-quantum signers.
//
// The provider does *not* manage MPC key shares. It manages an *approval*
// keypair owned by a specific approver (human executive or programmatic
// signer). Compromising this keypair compromises authorization decisions
// only — never the underlying MPC keys.
type ApprovalProvider interface {
// Provider returns the canonical name (e.g. "ledger-enterprise",
// "webauthn", "aws-kms"). Used in audit logs and the Approval registry.
Provider() string
// GetPublicIdentity returns the approver's PublicIdentity — the public
// material a verifier needs to validate approvals. ApproverID is the
// stable identifier (email/DID/owner-arn).
GetPublicIdentity(ctx context.Context, approverID string) (PublicIdentity, error)
// ApproveIntent produces an ApprovalSignature for the given intent.
// For interactive providers (Ledger device, WebAuthn) this BLOCKS until
// the user taps the device, or the context cancels.
ApproveIntent(ctx context.Context, approverID string, intent CanonicalIntent) (ApprovalSignature, error)
// VerifyApproval checks that sig was produced by approverID via this
// provider over the given intent. Returns false on signature mismatch,
// true on cryptographic match. Errors only on unrecoverable failures
// (network, malformed key) — a wrong signature is (false, nil).
VerifyApproval(ctx context.Context, intent CanonicalIntent, sig ApprovalSignature) (bool, error)
}
// PublicIdentity is the public-facing approver record stored in the keyset
// registry. ApproverID is the stable handle ("[email protected]", DID, KMS ARN);
// PublicKey + Algorithm let any verifier independently validate approvals.
//
// Attestation, when present, carries the vendor attestation chain (e.g.
// Ledger Live attestation, FIDO MDS metadata) — empty by default.
type PublicIdentity struct {
ApproverID string `json:"approver_id"`
Provider string `json:"provider"`
PublicKey []byte `json:"public_key"`
Algorithm string `json:"algorithm"`
Attestation []byte `json:"attestation,omitempty"`
}
// ApprovalSignature is the artifact attached to a CanonicalIntent. It binds
// (approverID, provider, intent_digest) under the approver's keypair so a
// later verifier can prove "this exact human approved this exact intent".
//
// Algorithm carries the signature scheme used so multi-algorithm bundles
// (mixed ECDSA / Ed25519 / ML-DSA) can be verified without external lookup.
type ApprovalSignature struct {
ApproverID string `json:"approver_id"`
Provider string `json:"provider"`
IntentDigest [32]byte `json:"intent_digest"`
Signature []byte `json:"signature"`
Timestamp time.Time `json:"timestamp"`
Algorithm string `json:"algorithm"`
}
// Algorithm constants for ApprovalSignature.Algorithm and PublicIdentity.Algorithm.
//
// Use the named constants — never raw strings — so a typo at the call site
// becomes a compile error.
const (
AlgorithmECDSAP256 = "ECDSA-P256" // generic ECDSA secp256r1, SHA-256
AlgorithmECDSAsecp256k1 = "ECDSA-secp256k1" // Ethereum / Bitcoin signatures
AlgorithmEd25519 = "Ed25519"
AlgorithmMLDSA65 = "ML-DSA-65" // FIPS 204, NIST level 3
AlgorithmSafeContract = "Safe-execTransaction" // on-chain Safe approval
AlgorithmWebAuthnES256 = "WebAuthn-ES256" // CTAP2 ES256 (P-256)
)
+167
View File
@@ -0,0 +1,167 @@
package approval
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"os"
"testing"
)
// fakeAuthenticator simulates the WebAuthn assertion ceremony locally so
// the test exercises the full Verify path without real hardware.
type fakeAuthenticator struct {
priv *ecdsa.PrivateKey
rpID string
}
func newFakeAuthenticator(t *testing.T, rpID string) *fakeAuthenticator {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeAuthenticator{priv: priv, rpID: rpID}
}
func (a *fakeAuthenticator) uncompressedPub() []byte {
out := make([]byte, 65)
out[0] = 0x04
xBytes := a.priv.PublicKey.X.Bytes()
yBytes := a.priv.PublicKey.Y.Bytes()
copy(out[1+(32-len(xBytes)):33], xBytes)
copy(out[33+(32-len(yBytes)):65], yBytes)
return out
}
// produceAssertion creates a clientDataJSON, authenticatorData, and
// signature consistent with the spec for a get ceremony.
func (a *fakeAuthenticator) produceAssertion(t *testing.T, challenge []byte, origin string) (string, string, string) {
t.Helper()
cd := map[string]string{
"type": "webauthn.get",
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
"origin": origin,
}
cdJSON, _ := json.Marshal(cd)
rpHash := sha256.Sum256([]byte(a.rpID))
authData := make([]byte, 37)
copy(authData[:32], rpHash[:])
authData[32] = 0x01 | 0x04 // UP + UV
// counter 0
signed := append(append([]byte{}, authData...), sha256ed(cdJSON)...)
signedHash := sha256.Sum256(signed)
r, s, err := ecdsa.Sign(rand.Reader, a.priv, signedHash[:])
if err != nil {
t.Fatal(err)
}
der, err := encodeECDSASignature(r, s)
if err != nil {
t.Fatal(err)
}
return base64.URLEncoding.EncodeToString(cdJSON),
base64.URLEncoding.EncodeToString(authData),
base64.URLEncoding.EncodeToString(der)
}
func sha256ed(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func encodeECDSASignature(r, s interface{}) ([]byte, error) {
type sig struct{ R, S interface{} }
// Use the package-internal helper via raw r||s -> ASN.1 path.
// That helper takes 64-byte raw form, but our r, s are *big.Int — so
// we hand-pack into 64 bytes then convert.
rBytes := r.(interface{ Bytes() []byte }).Bytes()
sBytes := s.(interface{ Bytes() []byte }).Bytes()
raw := make([]byte, 64)
copy(raw[32-len(rBytes):32], rBytes)
copy(raw[64-len(sBytes):], sBytes)
return ecdsaRawToASN1(raw)
}
func TestWebAuthn_RoundTrip(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
t.Cleanup(func() { os.Setenv("MPC_ENV", prevEnv) })
os.Setenv("MPC_ENV", "test") // permit http://localhost origin in tests
rpID := "lux.network"
origin := "http://localhost:3000"
auth := newFakeAuthenticator(t, rpID)
provider, err := NewWebAuthn(map[string]string{
"rpid": rpID,
"origins": origin,
"timeout": "30s",
})
if err != nil {
t.Fatalf("NewWebAuthn: %v", err)
}
wa := provider.(*WebAuthnProvider)
if err := wa.Enroll("[email protected]", "cred-1", auth.uncompressedPub()); err != nil {
t.Fatalf("Enroll: %v", err)
}
intent := newTestIntent("approve withdrawal")
sessionID, challenge, err := wa.IssueChallenge(context.Background(), "[email protected]", intent)
if err != nil {
t.Fatalf("IssueChallenge: %v", err)
}
cdB64, adB64, sigB64 := auth.produceAssertion(t, challenge, origin)
sig, err := wa.SubmitAssertion(context.Background(), sessionID, cdB64, adB64, sigB64)
if err != nil {
t.Fatalf("SubmitAssertion: %v", err)
}
if sig.Algorithm != AlgorithmWebAuthnES256 {
t.Fatalf("Algorithm = %q, want %q", sig.Algorithm, AlgorithmWebAuthnES256)
}
ok, err := wa.VerifyApproval(context.Background(), intent, sig)
if err != nil {
t.Fatalf("VerifyApproval: %v", err)
}
if !ok {
t.Fatal("VerifyApproval = false, want true")
}
// Tampered intent → verification fails.
tampered := newTestIntent("steal everything")
ok, _ = wa.VerifyApproval(context.Background(), tampered, sig)
if ok {
t.Fatal("VerifyApproval(tampered) = true, want false")
}
}
func TestWebAuthn_RejectsHTTPInProduction(t *testing.T) {
prevEnv := os.Getenv("MPC_ENV")
t.Cleanup(func() { os.Setenv("MPC_ENV", prevEnv) })
os.Setenv("MPC_ENV", "production")
if _, err := NewWebAuthn(map[string]string{
"rpid": "fund.com",
"origins": "http://approvals.fund.com", // no HTTPS
}); err == nil {
t.Fatal("NewWebAuthn with http origin in production: want error")
}
}
func TestWebAuthn_BundleRoundTrip(t *testing.T) {
authData := []byte{1, 2, 3, 4, 5, 6, 7, 8}
clientData := []byte(`{"type":"webauthn.get"}`)
sig := []byte{0xaa, 0xbb, 0xcc}
bundle := encodeWebAuthnBundle(authData, clientData, sig)
a2, c2, s2, err := decodeWebAuthnBundle(bundle)
if err != nil {
t.Fatalf("decodeWebAuthnBundle: %v", err)
}
if string(a2) != string(authData) || string(c2) != string(clientData) || string(s2) != string(sig) {
t.Fatal("bundle round-trip mismatch")
}
}
+180
View File
@@ -0,0 +1,180 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
)
func mustEvent(t *testing.T, kind EventKind, payload any) *Event {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return &Event{
NodeID: "node0",
Kind: kind,
OrgID: "org-test",
Payload: raw,
}
}
func TestEvent_SealComputesHashAndChain(t *testing.T) {
a := mustEvent(t, KindKeygen, map[string]string{"wallet": "w1"})
if _, err := a.Seal(0, ""); err != nil {
t.Fatalf("seal a: %v", err)
}
if a.Hash == "" {
t.Fatal("a.Hash empty")
}
b := mustEvent(t, KindSign, map[string]string{"wallet": "w1", "tx": "deadbeef"})
if _, err := b.Seal(1, a.Hash); err != nil {
t.Fatalf("seal b: %v", err)
}
if b.PrevHash != a.Hash {
t.Fatalf("b.PrevHash=%q want %q", b.PrevHash, a.Hash)
}
if idx, err := VerifyChain([]*Event{a, b}); err != nil || idx != -1 {
t.Fatalf("verify clean chain: idx=%d err=%v", idx, err)
}
// Tamper the payload of b. Hash recomputation must fail.
b.Payload = json.RawMessage(`{"wallet":"w1","tx":"00"}`)
if idx, err := VerifyChain([]*Event{a, b}); err == nil || idx != 1 {
t.Fatalf("verify tampered chain: idx=%d err=%v (want idx=1, err!=nil)", idx, err)
}
}
func TestEvent_DoubleSealRejected(t *testing.T) {
ev := mustEvent(t, KindKeygen, nil)
if _, err := ev.Seal(0, ""); err != nil {
t.Fatalf("seal: %v", err)
}
if _, err := ev.ComputeHash(); err == nil {
t.Fatal("expected ComputeHash to reject pre-sealed event")
}
}
func TestWORMDispatcher_AppendAndReplay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
d, err := NewWORMDispatcher(path)
if err != nil {
t.Fatalf("new: %v", err)
}
for i := 0; i < 5; i++ {
ev := mustEvent(t, KindSign, map[string]int{"i": i})
if _, err := d.Append(context.Background(), ev); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
seq, head, _ := d.VerifyHead(context.Background())
if seq != 4 {
t.Fatalf("seq=%d want 4", seq)
}
if err := d.Close(); err != nil {
t.Fatalf("close: %v", err)
}
// Reopen — replay must recover the same head.
d2, err := NewWORMDispatcher(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer d2.Close()
seq2, head2, _ := d2.VerifyHead(context.Background())
if seq2 != seq || head2 != head {
t.Fatalf("replay mismatch: got (seq=%d head=%q) want (seq=%d head=%q)", seq2, head2, seq, head)
}
ev := mustEvent(t, KindBackup, nil)
out, err := d2.Append(context.Background(), ev)
if err != nil {
t.Fatalf("post-replay append: %v", err)
}
if out.Seq != seq+1 {
t.Fatalf("post-replay seq=%d want %d", out.Seq, seq+1)
}
}
func TestMChainDispatcher_BatchAndAnchor(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["batchHash"] == "" {
t.Errorf("anchor body missing batchHash")
}
w.WriteHeader(200)
}))
defer srv.Close()
d, err := NewMChainDispatcher(srv.URL, "test-key", 3)
if err != nil {
t.Fatalf("new: %v", err)
}
defer d.Close()
for i := 0; i < 7; i++ {
ev := mustEvent(t, KindSign, map[string]int{"i": i})
if _, err := d.Append(context.Background(), ev); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
if err := d.Flush(context.Background()); err != nil {
t.Fatalf("flush: %v", err)
}
got := atomic.LoadInt32(&hits)
// 7 events, batchSize=3 → 2 size-triggered flushes (after #3 and #6)
// + 1 explicit Flush of the trailing event = 3 anchor calls.
if got != 3 {
t.Fatalf("anchor hits=%d want 3", got)
}
}
func TestNewDispatcher_Composite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
d, err := NewDispatcher(context.Background(), Config{
Store: "composite",
Composite: []string{StoreLocalWORM, StoreMChain},
WORMPath: path,
MChainURL: srv.URL,
MChainBatch: 1,
})
if err != nil {
t.Fatalf("new composite: %v", err)
}
defer d.Close()
for i := 0; i < 3; i++ {
ev := mustEvent(t, KindKeygen, map[string]int{"i": i})
if _, err := d.Append(context.Background(), ev); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
}
func TestNewDispatcher_CloudStubsAreNotImplemented(t *testing.T) {
for _, store := range []string{StoreS3GlacierVault, StoreAzureImmutable, StoreGCSObjectLock} {
_, err := NewDispatcher(context.Background(), Config{Store: store})
if err == nil {
t.Errorf("%s: expected NotImplemented error", store)
}
}
}
+166
View File
@@ -0,0 +1,166 @@
package audit
import (
"context"
"fmt"
"strings"
"sync"
)
// Dispatcher is the only surface mpcd uses to record audit events.
//
// Append must be safe for concurrent callers and must be linearizable:
// the Seq returned for a given event is the position assigned in the
// chain stored at that target. Implementations MUST refuse to assign a
// non-monotonic Seq.
//
// VerifyHead returns the Hash of the latest event (or "" for an empty
// chain) along with its Seq. Callers compare across replicas to detect
// fork or tampering.
//
// Close flushes any pending writes and releases handles.
type Dispatcher interface {
Append(ctx context.Context, ev *Event) (*Event, error)
VerifyHead(ctx context.Context) (seq uint64, hash string, err error)
Close() error
}
// Mode names accepted by NewDispatcher. The string literals are the
// values passed via the --audit-store flag and so are part of the
// operator-facing surface; do not rename without coordinating with
// the runbook.
const (
StoreLocalWORM = "local-worm"
StoreMChain = "mchain"
StoreS3GlacierVault = "s3-glacier-vault"
StoreAzureImmutable = "azure-immutable"
StoreGCSObjectLock = "gcs-object-lock"
)
// Config is the union of every backend's configuration. Only fields
// relevant to the selected store need to be populated; NewDispatcher
// fails loudly on missing values for the chosen target.
type Config struct {
Store string
// LocalWORM
WORMPath string
// MChain (RPC anchor: nodes POST batch-roots to this URL)
MChainURL string
MChainAPIKey string
MChainBatch int // events per anchor batch (>=1)
// Composite — comma-separated list of stores; Append fails if any leg fails.
// Used for redundancy (e.g. "local-worm,mchain"). Read via the first leg.
Composite []string
}
// NewDispatcher constructs the dispatcher named by cfg.Store. Composite
// mode is selected by passing Store="composite" with cfg.Composite
// listing the legs in priority order.
//
// Cloud-backed dispatchers (S3 Glacier Vault, Azure Immutable, GCS Object
// Lock) currently return ErrNotImplemented. They are wired into the flag
// surface so operators can plan for them, but the SDK paths are gated
// behind separate build tags so the default binary remains small. Use
// the local-worm + mchain composite for production today; flip to a
// cloud target only after the corresponding terraform module has been
// applied AND the relevant build tag enabled.
func NewDispatcher(ctx context.Context, cfg Config) (Dispatcher, error) {
switch cfg.Store {
case "", StoreLocalWORM:
if cfg.WORMPath == "" {
return nil, fmt.Errorf("audit: local-worm requires WORMPath")
}
return NewWORMDispatcher(cfg.WORMPath)
case StoreMChain:
if cfg.MChainURL == "" {
return nil, fmt.Errorf("audit: mchain requires MChainURL")
}
return NewMChainDispatcher(cfg.MChainURL, cfg.MChainAPIKey, cfg.MChainBatch)
case StoreS3GlacierVault, StoreAzureImmutable, StoreGCSObjectLock:
return nil, fmt.Errorf("audit: %s dispatcher requires the cloud-audit build tag (set --audit-store=local-worm or composite for now)", cfg.Store)
case "composite":
legs := make([]Dispatcher, 0, len(cfg.Composite))
for _, name := range cfg.Composite {
name = strings.TrimSpace(name)
if name == "" {
continue
}
leg, err := NewDispatcher(ctx, Config{
Store: name,
WORMPath: cfg.WORMPath,
MChainURL: cfg.MChainURL,
MChainAPIKey: cfg.MChainAPIKey,
MChainBatch: cfg.MChainBatch,
})
if err != nil {
for _, l := range legs {
_ = l.Close()
}
return nil, fmt.Errorf("audit: composite leg %q: %w", name, err)
}
legs = append(legs, leg)
}
if len(legs) == 0 {
return nil, fmt.Errorf("audit: composite requires at least one leg")
}
return &compositeDispatcher{legs: legs}, nil
default:
return nil, fmt.Errorf("audit: unknown store %q", cfg.Store)
}
}
// compositeDispatcher fans Append out to every leg and fails if any leg
// fails. Read operations consult the first leg only — operators verify
// cross-leg consistency out-of-band by comparing VerifyHead values.
type compositeDispatcher struct {
legs []Dispatcher
mu sync.Mutex
}
func (c *compositeDispatcher) Append(ctx context.Context, ev *Event) (*Event, error) {
c.mu.Lock()
defer c.mu.Unlock()
var sealed *Event
for i, leg := range c.legs {
// Each leg seals its own copy. They will all assign different Seq
// values (each leg keeps its own chain), but the payloads are
// identical. Cross-leg consistency is checked out-of-band by
// comparing event payloads at the same Seq.
copyEv := &Event{
Timestamp: ev.Timestamp,
NodeID: ev.NodeID,
Kind: ev.Kind,
OrgID: ev.OrgID,
WalletID: ev.WalletID,
Payload: ev.Payload,
}
out, err := leg.Append(ctx, copyEv)
if err != nil {
return nil, fmt.Errorf("audit: composite leg %d: %w", i, err)
}
if i == 0 {
sealed = out
}
}
return sealed, nil
}
func (c *compositeDispatcher) VerifyHead(ctx context.Context) (uint64, string, error) {
if len(c.legs) == 0 {
return 0, "", nil
}
return c.legs[0].VerifyHead(ctx)
}
func (c *compositeDispatcher) Close() error {
var firstErr error
for _, leg := range c.legs {
if err := leg.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+134
View File
@@ -0,0 +1,134 @@
// Package audit provides an append-only audit log with a Merkle hash chain.
//
// Every Event carries: a sequence number, the SHA-256 of the previous event,
// and the SHA-256 of the canonical bytes of (PrevHash || SerializedPayload).
// Tampering with any historical event invalidates every subsequent hash, so
// verification reduces to recomputing the chain.
//
// The Dispatcher interface lets the same chain be persisted to different
// targets — the local WORM file, the Lux M-Chain anchor RPC, S3 Glacier
// Vault Lock, Azure Immutable Blob Storage, or GCS Object Lock. Mode
// selection happens once at startup via NewDispatcher; the rest of the
// daemon only sees the interface.
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// EventKind enumerates the audit event types emitted by mpcd.
//
// Keep this list closed: adding a kind is a wire-protocol change and must
// be coordinated with verifiers. New kinds go at the end of the constant
// block to preserve any external numeric ordering.
type EventKind string
const (
KindKeygen EventKind = "keygen"
KindSign EventKind = "sign"
KindReshare EventKind = "reshare"
KindBackup EventKind = "backup"
KindBootstrap EventKind = "bootstrap"
KindPeerJoin EventKind = "peer_join"
KindPeerLeave EventKind = "peer_leave"
KindPolicyApply EventKind = "policy_apply"
)
// Event is one entry in the audit log.
//
// The Hash field is the SHA-256 of canonical(PrevHash || Seq || NodeID ||
// Kind || OrgID || WalletID || Timestamp || Payload). It is computed by
// Seal and never set by callers; ComputeHash will reject an Event with a
// pre-populated Hash to prevent forgery.
type Event struct {
Seq uint64 `json:"seq"`
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
Timestamp time.Time `json:"ts"`
NodeID string `json:"node_id"`
Kind EventKind `json:"kind"`
OrgID string `json:"org_id,omitempty"`
WalletID string `json:"wallet_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// canonicalBytes returns the bytes that go into the SHA-256 for Hash.
// Order is fixed: prev || seq(8 BE) || node || kind || org || wallet ||
// unix-nano(8 BE) || payload. We intentionally do NOT use json.Marshal
// here because Go's map iteration ordering would defeat reproducibility.
func (e *Event) canonicalBytes() []byte {
prev, _ := hex.DecodeString(e.PrevHash)
buf := make([]byte, 0, 256+len(e.Payload))
buf = append(buf, prev...)
buf = appendUint64BE(buf, e.Seq)
buf = append(buf, []byte(e.NodeID)...)
buf = append(buf, byte(0)) // separator — NodeID may be empty
buf = append(buf, []byte(e.Kind)...)
buf = append(buf, byte(0))
buf = append(buf, []byte(e.OrgID)...)
buf = append(buf, byte(0))
buf = append(buf, []byte(e.WalletID)...)
buf = append(buf, byte(0))
buf = appendUint64BE(buf, uint64(e.Timestamp.UnixNano()))
buf = append(buf, e.Payload...)
return buf
}
// ComputeHash returns the canonical SHA-256 hex of e. e.Hash must be empty.
func (e *Event) ComputeHash() (string, error) {
if e.Hash != "" {
return "", fmt.Errorf("audit: event already sealed (Hash=%q)", e.Hash)
}
sum := sha256.Sum256(e.canonicalBytes())
return hex.EncodeToString(sum[:]), nil
}
// Seal sets Seq, PrevHash, Timestamp (if zero) and Hash on e.
// Mutates e and returns it.
func (e *Event) Seal(seq uint64, prevHash string) (*Event, error) {
e.Seq = seq
e.PrevHash = prevHash
if e.Timestamp.IsZero() {
e.Timestamp = time.Now().UTC()
}
h, err := e.ComputeHash()
if err != nil {
return nil, err
}
e.Hash = h
return e, nil
}
// VerifyChain walks events in order and confirms each Hash chains to its
// PrevHash. Returns the index of the first inconsistent event, or -1 if
// the whole slice is intact.
func VerifyChain(events []*Event) (int, error) {
prev := ""
for i, ev := range events {
if ev.PrevHash != prev {
return i, fmt.Errorf("audit: event %d prev=%q expected %q", i, ev.PrevHash, prev)
}
want := ev.Hash
ev.Hash = "" // recompute requires empty
got, err := ev.ComputeHash()
ev.Hash = want
if err != nil {
return i, err
}
if got != want {
return i, fmt.Errorf("audit: event %d hash=%q recomputed %q", i, want, got)
}
prev = want
}
return -1, nil
}
func appendUint64BE(b []byte, v uint64) []byte {
return append(b,
byte(v>>56), byte(v>>48), byte(v>>40), byte(v>>32),
byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}
+159
View File
@@ -0,0 +1,159 @@
package audit
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// MChainDispatcher anchors batches of audit events to a Lux M-Chain
// audit-anchor RPC endpoint.
//
// Wire protocol: when batchSize events have accumulated, the dispatcher
// POSTs JSON {batchHash, fromSeq, toSeq, prevAnchor, events:[...]} to
// MChainURL. The server is expected to record (batchHash, fromSeq, toSeq)
// on chain and return 200. The dispatcher does NOT itself sign or
// transact — that responsibility belongs to whatever validator set is
// behind the anchor RPC.
//
// On HTTP failure, Append returns the error and does not advance the
// chain. Callers can retry or fail open depending on the operational
// posture (composite mode pairs this with WORM so a transient anchor
// outage does not lose audit data).
type MChainDispatcher struct {
url string
apiKey string
batchSize int
mu sync.Mutex
seq uint64
head string
pend []*Event
anchor string // last successful batchHash, used as prevAnchor for the next POST
httpClient *http.Client
}
// NewMChainDispatcher constructs an anchor dispatcher. batchSize <=0
// defaults to 32. The HTTP client uses a 10s timeout, which is short
// enough that a stuck anchor won't pin a signing thread.
func NewMChainDispatcher(url, apiKey string, batchSize int) (*MChainDispatcher, error) {
if url == "" {
return nil, fmt.Errorf("audit/mchain: url is required")
}
if batchSize <= 0 {
batchSize = 32
}
return &MChainDispatcher{
url: url,
apiKey: apiKey,
batchSize: batchSize,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
func (d *MChainDispatcher) Append(ctx context.Context, ev *Event) (*Event, error) {
d.mu.Lock()
defer d.mu.Unlock()
sealed, err := ev.Seal(d.seq, d.head)
if err != nil {
return nil, fmt.Errorf("audit/mchain: seal: %w", err)
}
d.seq = sealed.Seq + 1
d.head = sealed.Hash
d.pend = append(d.pend, sealed)
if len(d.pend) >= d.batchSize {
if err := d.flushLocked(ctx); err != nil {
// Flush failure: leave events in pending so a later Append
// (or a manual flush) retries. Return the error so the caller
// can decide whether to fail the request.
return sealed, err
}
}
return sealed, nil
}
// Flush forces an out-of-band anchor of any pending events. Useful for
// graceful shutdown.
func (d *MChainDispatcher) Flush(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
return d.flushLocked(ctx)
}
func (d *MChainDispatcher) flushLocked(ctx context.Context) error {
if len(d.pend) == 0 {
return nil
}
from := d.pend[0].Seq
to := d.pend[len(d.pend)-1].Seq
// batchHash = SHA-256(prevAnchor || hash_of_each_event_in_order).
h := sha256.New()
if d.anchor != "" {
if prev, err := hex.DecodeString(d.anchor); err == nil {
h.Write(prev)
}
}
for _, ev := range d.pend {
if b, err := hex.DecodeString(ev.Hash); err == nil {
h.Write(b)
}
}
batchHash := hex.EncodeToString(h.Sum(nil))
body, err := json.Marshal(map[string]any{
"batchHash": batchHash,
"fromSeq": from,
"toSeq": to,
"prevAnchor": d.anchor,
"events": d.pend,
})
if err != nil {
return fmt.Errorf("audit/mchain: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("audit/mchain: request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if d.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+d.apiKey)
}
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("audit/mchain: post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("audit/mchain: anchor rejected (status=%d)", resp.StatusCode)
}
d.anchor = batchHash
d.pend = d.pend[:0]
return nil
}
func (d *MChainDispatcher) VerifyHead(_ context.Context) (uint64, string, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.seq == 0 {
return 0, "", nil
}
return d.seq - 1, d.head, nil
}
func (d *MChainDispatcher) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return d.Flush(ctx)
}
+152
View File
@@ -0,0 +1,152 @@
package audit
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// WORMDispatcher writes the audit chain to an append-only file.
//
// "WORM" here means the file is opened O_APPEND so writes can only add
// to the tail. Operators are expected to mount the directory on
// hardware-WORM media (immutable S3 prefix, write-once tape, or a
// filesystem with chattr +a) for the integrity guarantee — this struct
// supplies the chain itself but does not enforce hardware immutability.
//
// File format: one JSON object per line (NDJSON). Each line is a sealed
// Event. Recovery on startup re-reads the tail to recover the head Seq
// and Hash; corruption is detected by VerifyChain.
type WORMDispatcher struct {
path string
mu sync.Mutex
f *os.File
w *bufio.Writer
seq uint64
head string
}
// NewWORMDispatcher opens path (creating parent directories as needed)
// and replays it to recover the chain head.
func NewWORMDispatcher(path string) (*WORMDispatcher, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("audit/worm: mkdir: %w", err)
}
d := &WORMDispatcher{path: path}
if err := d.replay(); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
return nil, fmt.Errorf("audit/worm: open: %w", err)
}
d.f = f
d.w = bufio.NewWriterSize(f, 64<<10)
return d, nil
}
// replay walks the existing file (if any) and recovers seq + head. It
// also verifies the chain — a corrupt log refuses to open.
func (d *WORMDispatcher) replay() error {
f, err := os.Open(d.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("audit/worm: replay open: %w", err)
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64<<10), 16<<20) // up to 16 MiB per line
prev := ""
var seq uint64
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var ev Event
if err := json.Unmarshal(line, &ev); err != nil {
return fmt.Errorf("audit/worm: replay parse seq=%d: %w", seq, err)
}
if ev.PrevHash != prev {
return fmt.Errorf("audit/worm: replay chain break at seq=%d (prev=%q expected=%q)", ev.Seq, ev.PrevHash, prev)
}
want := ev.Hash
ev.Hash = ""
got, err := ev.ComputeHash()
if err != nil {
return fmt.Errorf("audit/worm: replay hash seq=%d: %w", ev.Seq, err)
}
if got != want {
return fmt.Errorf("audit/worm: replay tampered at seq=%d (stored=%q recomputed=%q)", ev.Seq, want, got)
}
prev = want
seq = ev.Seq + 1
}
if err := sc.Err(); err != nil {
return fmt.Errorf("audit/worm: replay scan: %w", err)
}
d.seq = seq
d.head = prev
return nil
}
func (d *WORMDispatcher) Append(_ context.Context, ev *Event) (*Event, error) {
d.mu.Lock()
defer d.mu.Unlock()
sealed, err := ev.Seal(d.seq, d.head)
if err != nil {
return nil, fmt.Errorf("audit/worm: seal: %w", err)
}
line, err := json.Marshal(sealed)
if err != nil {
return nil, fmt.Errorf("audit/worm: marshal: %w", err)
}
if _, err := d.w.Write(line); err != nil {
return nil, fmt.Errorf("audit/worm: write: %w", err)
}
if err := d.w.WriteByte('\n'); err != nil {
return nil, fmt.Errorf("audit/worm: write newline: %w", err)
}
if err := d.w.Flush(); err != nil {
return nil, fmt.Errorf("audit/worm: flush: %w", err)
}
if err := d.f.Sync(); err != nil {
return nil, fmt.Errorf("audit/worm: fsync: %w", err)
}
d.seq = sealed.Seq + 1
d.head = sealed.Hash
return sealed, nil
}
func (d *WORMDispatcher) VerifyHead(_ context.Context) (uint64, string, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.seq == 0 {
return 0, "", nil
}
return d.seq - 1, d.head, nil
}
func (d *WORMDispatcher) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.w != nil {
_ = d.w.Flush()
}
if d.f != nil {
err := d.f.Close()
d.f = nil
return err
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
package identity
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
// BootstrapMode controls where a freshly-generated node identity gets
// announced.
//
// In ModePrimary the bootstrap routine POSTs the node's public key to
// the configured registrar URL — typically the Lux M-Chain audit /
// validator-manager API — so the larger network learns of this node.
// In ModePrivate no remote announcement is made; the cluster is fully
// self-contained and the only thing that exists is the local identity
// file. Same code path, same on-disk format — just two terminations.
type BootstrapMode string
const (
ModePrimary BootstrapMode = "primary"
ModePrivate BootstrapMode = "private"
)
// Config drives Bootstrap. Only RegistrarURL is consulted in primary
// mode; in private mode it is ignored.
type Config struct {
NodeID string
KeysDir string
Mode BootstrapMode
RegistrarURL string
RegistrarAuth string
// HTTP client override — set in tests; zero-value uses a 10s default.
HTTPClient *http.Client
}
// Identity is the result of Bootstrap.
type Identity struct {
NodeID string `json:"node_id"`
Mode BootstrapMode `json:"mode"`
PublicKey ed25519.PublicKey `json:"-"`
PrivateKey ed25519.PrivateKey `json:"-"`
}
// onDiskIdentity is the JSON representation persisted under
// {KeysDir}/{NodeID}_identity.json. It matches the schema written by
// cmd/mpcd/main.go's loadOrGenerateIdentity so existing nodes keep
// working without migration.
type onDiskIdentity struct {
NodeID string `json:"node_id"`
Mode string `json:"mode,omitempty"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
// Bootstrap loads or generates this node's Ed25519 identity, persists it
// under cfg.KeysDir, and (in primary mode) announces the public key to
// the registrar. The function is idempotent: a second invocation with
// the same KeysDir + NodeID returns the existing identity without
// reannouncing.
//
// Errors fall into three classes:
// - bad config (missing NodeID/KeysDir, unknown Mode) — return immediately
// - I/O failure — surfaced unwrapped
// - registrar failure in primary mode — surfaced; caller decides whether
// to fall back to private behaviour or fail closed
func Bootstrap(ctx context.Context, cfg Config) (*Identity, error) {
if cfg.NodeID == "" {
return nil, fmt.Errorf("identity/bootstrap: NodeID is required")
}
if cfg.KeysDir == "" {
return nil, fmt.Errorf("identity/bootstrap: KeysDir is required")
}
switch cfg.Mode {
case ModePrimary, ModePrivate:
case "":
cfg.Mode = ModePrivate
default:
return nil, fmt.Errorf("identity/bootstrap: unknown mode %q", cfg.Mode)
}
if err := os.MkdirAll(cfg.KeysDir, 0o700); err != nil {
return nil, fmt.Errorf("identity/bootstrap: mkdir keys: %w", err)
}
path := filepath.Join(cfg.KeysDir, cfg.NodeID+"_identity.json")
id, err := loadIdentity(path)
if err != nil {
return nil, err
}
if id == nil {
id, err = generateIdentity(cfg.NodeID, cfg.Mode, path)
if err != nil {
return nil, err
}
} else {
// Refresh the mode field if the on-disk record predates this code.
// Identity bytes never change.
id.Mode = cfg.Mode
}
if cfg.Mode == ModePrimary {
if err := announce(ctx, cfg, id); err != nil {
return id, fmt.Errorf("identity/bootstrap: primary announce: %w", err)
}
}
return id, nil
}
func loadIdentity(path string) (*Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("identity/bootstrap: read: %w", err)
}
var od onDiskIdentity
if err := json.Unmarshal(data, &od); err != nil {
return nil, fmt.Errorf("identity/bootstrap: parse: %w", err)
}
priv, err := hex.DecodeString(od.PrivateKey)
if err != nil || len(priv) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("identity/bootstrap: corrupt private key in %s", path)
}
pub, err := hex.DecodeString(od.PublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("identity/bootstrap: corrupt public key in %s", path)
}
return &Identity{
NodeID: od.NodeID,
Mode: BootstrapMode(od.Mode),
PublicKey: ed25519.PublicKey(pub),
PrivateKey: ed25519.PrivateKey(priv),
}, nil
}
func generateIdentity(nodeID string, mode BootstrapMode, path string) (*Identity, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("identity/bootstrap: keygen: %w", err)
}
od := onDiskIdentity{
NodeID: nodeID,
Mode: string(mode),
PublicKey: hex.EncodeToString(pub),
PrivateKey: hex.EncodeToString(priv),
}
body, err := json.MarshalIndent(od, "", " ")
if err != nil {
return nil, fmt.Errorf("identity/bootstrap: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return nil, fmt.Errorf("identity/bootstrap: write: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return nil, fmt.Errorf("identity/bootstrap: rename: %w", err)
}
return &Identity{
NodeID: nodeID,
Mode: mode,
PublicKey: pub,
PrivateKey: priv,
}, nil
}
// announce posts {nodeID, pubKey, ts} to the registrar URL. The
// registrar is responsible for whatever validator-set bookkeeping the
// primary network does — this client makes one HTTP call and verifies
// the 2xx response.
func announce(ctx context.Context, cfg Config, id *Identity) error {
if cfg.RegistrarURL == "" {
// No registrar configured — primary mode degrades to private.
// The mode label is kept on disk for operator clarity but no
// network call is made. This is the documented behaviour for
// air-gapped staging clusters.
return nil
}
body, err := json.Marshal(map[string]any{
"node_id": id.NodeID,
"public_key": hex.EncodeToString(id.PublicKey),
"ts": time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.RegistrarURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.RegistrarAuth != "" {
req.Header.Set("Authorization", "Bearer "+cfg.RegistrarAuth)
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("registrar status=%d", resp.StatusCode)
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package identity
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
)
func TestBootstrap_PrivateMode_NoRegistrarCalled(t *testing.T) {
dir := t.TempDir()
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(200)
}))
defer srv.Close()
id, err := Bootstrap(context.Background(), Config{
NodeID: "node0",
KeysDir: dir,
Mode: ModePrivate,
RegistrarURL: srv.URL, // present but must NOT be hit
})
if err != nil {
t.Fatalf("bootstrap: %v", err)
}
if got := atomic.LoadInt32(&hits); got != 0 {
t.Fatalf("registrar hits=%d want 0 in private mode", got)
}
if id.Mode != ModePrivate {
t.Fatalf("mode=%q want %q", id.Mode, ModePrivate)
}
if len(id.PublicKey) != 32 {
t.Fatalf("pubkey len=%d want 32", len(id.PublicKey))
}
}
func TestBootstrap_PrimaryMode_RegistrarHitOnce(t *testing.T) {
dir := t.TempDir()
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(200)
}))
defer srv.Close()
cfg := Config{
NodeID: "node0",
KeysDir: dir,
Mode: ModePrimary,
RegistrarURL: srv.URL,
}
if _, err := Bootstrap(context.Background(), cfg); err != nil {
t.Fatalf("bootstrap: %v", err)
}
// Reinvoke — second call announces too (registrar is responsible for
// idempotency). What the second call MUST NOT do is regenerate keys.
id2, err := Bootstrap(context.Background(), cfg)
if err != nil {
t.Fatalf("bootstrap 2: %v", err)
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("registrar hits=%d want 2", got)
}
if id2.Mode != ModePrimary {
t.Fatalf("mode=%q want %q", id2.Mode, ModePrimary)
}
id3, err := loadIdentity(filepath.Join(dir, "node0_identity.json"))
if err != nil || id3 == nil {
t.Fatalf("load: %v", err)
}
for i := range id2.PublicKey {
if id2.PublicKey[i] != id3.PublicKey[i] {
t.Fatalf("pubkey changed across reload")
}
}
}
func TestBootstrap_PrimaryMode_NoRegistrarURLIsNoop(t *testing.T) {
dir := t.TempDir()
id, err := Bootstrap(context.Background(), Config{
NodeID: "node0",
KeysDir: dir,
Mode: ModePrimary,
})
if err != nil {
t.Fatalf("bootstrap with empty URL must succeed (air-gapped staging): %v", err)
}
if id.Mode != ModePrimary {
t.Fatalf("mode=%q want %q", id.Mode, ModePrimary)
}
}
func TestBootstrap_PrimaryMode_RegistrarFailureSurfaces(t *testing.T) {
dir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(503)
}))
defer srv.Close()
_, err := Bootstrap(context.Background(), Config{
NodeID: "node0",
KeysDir: dir,
Mode: ModePrimary,
RegistrarURL: srv.URL,
})
if err == nil {
t.Fatal("expected error from 503 registrar")
}
}