mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
+137
-52
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(®istrarHits, 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(®istrarHits); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user