feat(mpcd): expose ZAP server on :9970 for KMS handshake

Adds threshold-signing transport so KMS can use mpcd as MPC backend.
Reuses ML-KEM-768 hybrid handshake from luxfi/kms/pkg/zap (#55) by
vendoring the same construction into mpc/pkg/zap — wire constants
(opcodes 0x00F0/0x00F1, HKDF info "kms/zap/v1/session", direction
tags) stay in lockstep with KMS.

New surface:
- pkg/zap: vendored handshake + session (forward-only convergent)
- pkg/api/zap_kms_server.go: KMS-facing ZAP node (opcodes
  0x0001/0x0010/0x0011/0x0012/0x0020) wired onto MPCBackend
- cmd/mpcd: --kms-zap-listen flag (default :9970), enabled out of the
  box; default-disable by passing empty string

Existing pkg/api/zap_server.go (dashboard event bus, opcodes 0x0060+)
is unchanged — different consumer, different port.

Closes the secrets-only mode gap on mainnet (#70).
This commit is contained in:
2026-05-01 01:35:13 -07:00
parent a80c75b0c6
commit 3cf1004d92
9 changed files with 1507 additions and 19 deletions
+37 -11
View File
@@ -50,7 +50,7 @@ import (
)
const (
Version = "0.3.3"
Version = "0.4.0"
DefaultBackupPeriodSeconds = 300 // (5 minutes)
)
@@ -110,6 +110,12 @@ func main() {
Name: "jwt-secret",
Usage: "JWT signing secret for dashboard auth",
},
&cli.StringFlag{
Name: "kms-zap-listen",
Usage: "KMS-facing ZAP server listen address (luxfi/kms dials this for threshold ops). Empty disables.",
Sources: cli.EnvVars("MPC_KMS_ZAP_LISTEN"),
Value: ":9970",
},
// HSM / password provider flags
&cli.StringFlag{
Name: "hsm-provider",
@@ -698,6 +704,36 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
}
}
// MPCBackend wraps the consensus transport so both the Dashboard API
// (HTTP, port 8081) and the KMS ZAP server (port 9970) can drive
// keygen/sign/reshare against the same node. Hoisted out of the
// dashboard branch so the KMS ZAP server can attach without depending
// on a JWT secret being set.
mpcBackend := &ConsensusMPCBackend{
pubSub: pubSub,
peerRegistry: peerRegistry,
factory: factory,
keyInfoStore: factory.KeyInfoStore(),
identity: consensusIdentity,
nodeID: nodeID,
threshold: threshold,
}
// Start KMS-facing ZAP server. KMS dials this for threshold-signing
// requests after running the ML-KEM-768 hybrid handshake. Empty
// --kms-zap-listen disables it (e.g., for legacy compose stacks that
// only need the HTTP dashboard).
kmsZapAddr := c.String("kms-zap-listen")
if kmsZapAddr != "" {
kmsZap, kmsZapErr := mpcapi.StartKMSZAP(mpcBackend, fmt.Sprintf("mpcd-kms-zap-%s", nodeID), kmsZapAddr)
if kmsZapErr != nil {
logger.Error("Failed to start KMS ZAP server", kmsZapErr, "addr", kmsZapAddr)
} else {
defer kmsZap.Stop()
logger.Info("KMS ZAP server ready", "addr", kmsZapAddr, "nodeID", nodeID)
}
}
// Start Dashboard API server (ZapDB-backed, no external dependencies)
apiListenAddr := c.String("api-listen")
jwtSecret := c.String("jwt-secret")
@@ -712,16 +748,6 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
} else {
defer database.Close()
mpcBackend := &ConsensusMPCBackend{
pubSub: pubSub,
peerRegistry: peerRegistry,
factory: factory,
keyInfoStore: factory.KeyInfoStore(),
identity: consensusIdentity,
nodeID: nodeID,
threshold: threshold,
}
apiServer := mpcapi.NewServer(database, mpcBackend, jwtSecret)
apiServer.StartScheduler(ctx)
+2 -2
View File
@@ -15,6 +15,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
github.com/cloudflare/circl v1.6.3
github.com/cronokirby/saferith v0.33.0
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
@@ -38,7 +39,7 @@ require (
github.com/luxfi/metric v1.5.1
github.com/luxfi/threshold v1.6.4
github.com/luxfi/zap v0.2.0
github.com/minio/minio-go/v7 v7.0.99
github.com/minio/minio-go/v7 v7.0.100
github.com/mitchellh/mapstructure v1.5.0
github.com/mr-tron/base58 v1.2.0
github.com/nats-io/nats.go v1.50.0
@@ -89,7 +90,6 @@ require (
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/crypto/rand v1.0.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
+4 -6
View File
@@ -618,6 +618,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -901,8 +903,6 @@ github.com/luxfi/fhe v1.7.7 h1:RP26i3CxvNy2y/3LKiJPgd9uR5MrlG/MvQnv5rkQFhk=
github.com/luxfi/fhe v1.7.7/go.mod h1:FHfGNgKL5ZLcnKVKFVNLDJOBbQyQm5DGpVrMnY8ASJo=
github.com/luxfi/geth v1.16.79 h1:MtP8ZUuSVZDjmZDa1kTJCl0PpG8+wGbQft652GO6a3A=
github.com/luxfi/geth v1.16.79/go.mod h1:6xNi4sHoh0v1kBf0TOjmFhBg5+4umbSqrwiFZsDanZ0=
github.com/luxfi/hsm v1.1.2 h1:XvzFp0q7IueMyb5/exve/TB2fJpGhClYDRvSBdB0A9Y=
github.com/luxfi/hsm v1.1.2/go.mod h1:+tB7UEipw6h4cGNRFhvQsviM95fnseyMt894bqiRrbU=
github.com/luxfi/hsm v1.1.3 h1:ke36qmz5zqapBrnN3ij7gU9fFPaPBrnzcdrKX4lV/rk=
github.com/luxfi/hsm v1.1.3/go.mod h1:2DwOpBG1yzo9zHbsw2t0p/CbGjbyHYXjPx7uxEIp8wo=
github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI=
@@ -947,8 +947,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -964,8 +962,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+132
View File
@@ -1,8 +1,124 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0=
capnproto.org/go/capnp/v3 v3.1.0-alpha.2/go.mod h1:2vT5D2dtG8sJGEoEKU17e+j7shdaYp1Myl8X03B3hmc=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go/accessapproval v1.9.0/go.mod h1:GLlfSKe9gJN28w2CVfR9J8eWZgFruNutEU+GAx1iXHo=
cloud.google.com/go/accesscontextmanager v1.10.0/go.mod h1:F6ajq/ZMZ0bYpOr3LRtBQrhI0l1i80Esg7b128YNVas=
cloud.google.com/go/aiplatform v1.122.0/go.mod h1:Z0QhICmtYNxf0fbl/axHrVXCGcfdYuedH5NK+GwVu1I=
cloud.google.com/go/analytics v0.31.0/go.mod h1:3nTQB3QiqS9Z07tbj+8Q2Yf9HQnSP256tkpwFilY7qs=
cloud.google.com/go/apigateway v1.8.0/go.mod h1:G/+yRp7QCx2JDnw5awoCqpOOxeFS1EU2YU1kIip/0BE=
cloud.google.com/go/apigeeconnect v1.8.0/go.mod h1:1SwzPtTk3T2nmEmYQB3vxO6kdSE22HGPOvoWIzyXjmg=
cloud.google.com/go/apigeeregistry v0.11.0/go.mod h1:UR0cACaSYm2nocg3R6mKNBY83atd9pz1HqLADp40lF8=
cloud.google.com/go/appengine v1.10.0/go.mod h1:IStRRjdbvxAesIPg8vWSCfz4SgEroFHyNZh2oYwoyOQ=
cloud.google.com/go/area120 v0.11.0/go.mod h1:/Q8MRFwW+enSQrxsmrKZtEh+E+LuVh7Ytg3tCkzq2lw=
cloud.google.com/go/artifactregistry v1.21.0/go.mod h1:IhMxwuMpzlVJjpTodjz1eZXMxvMxXS9/vgG5hbnYUz0=
cloud.google.com/go/asset v1.23.0/go.mod h1:LTL+e6J4kc493wnt5uSlC3Dss+S6b9QgK5cBxjTghqw=
cloud.google.com/go/assuredworkloads v1.14.0/go.mod h1:eSvUapoU9yH71+XZ4WNOXQAXqKj1X1xbPPudXGxDCbA=
cloud.google.com/go/automl v1.16.0/go.mod h1:01pqJU9T8xQ6JW4zcP9zgtnKLDugRrtJrO5Mcl5S0rg=
cloud.google.com/go/baremetalsolution v1.5.0/go.mod h1:e4+LWVqGObMTL1R6HVqWNA+Km32H/qvf573xB47T+bM=
cloud.google.com/go/batch v1.15.0/go.mod h1:/3beOUtaLJChD59xNgCgXxEeXrtv5EDLMzUVYr9aiqo=
cloud.google.com/go/beyondcorp v1.3.0/go.mod h1:8yO8ZZOF8ZNfRHcQ1nB6PjZx+9f9ey38uKcQZcM4VSA=
cloud.google.com/go/bigquery v1.75.0/go.mod h1:zNCHWok+hfTgKCwNqT+V7GH/YmFFgZqjzljKCZBJTWc=
cloud.google.com/go/bigtable v1.45.0/go.mod h1:Ztklmotutn5zkAYzsn2w8ye8wvy+azwyGwYmujW5JHg=
cloud.google.com/go/billing v1.22.0/go.mod h1:nQVTVqWyw8YK0QScVV1pXr9KrvjvM7cabIQr6tLJMys=
cloud.google.com/go/binaryauthorization v1.11.0/go.mod h1:2GBJK67zdEkeXGpnHYDXa21cngrYL99jR+Z688gom5c=
cloud.google.com/go/certificatemanager v1.10.0/go.mod h1:dfJSJnWK2wgw2BTQ5oaAalombcajsviqsA6liniLL/U=
cloud.google.com/go/channel v1.22.0/go.mod h1:Eodq1pGB6UnpVB622T0NGf2qvW9aJYGqaojcW8IS6UU=
cloud.google.com/go/cloudbuild v1.26.0/go.mod h1:gKgjpgxUSqlX4TLVPoCYYAskEtHdbNpnTMEL2vUMT1s=
cloud.google.com/go/clouddms v1.9.0/go.mod h1:O0P8OX8QBK0UFlDJx+Xd32aHi3qltjel4q67YyZPJIg=
cloud.google.com/go/cloudtasks v1.14.0/go.mod h1:mFzsLKuM4gzzmlbu1363510Fjm5ZJR+8mH1C2w5roJo=
cloud.google.com/go/compute v1.58.0/go.mod h1:5/1KiFDIdFPcx/Fw6pFQpcQaBGc6MGtgSfczeLFBj7o=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/contactcenterinsights v1.18.0/go.mod h1:uvgwG1ey82pNIxZ6r4gfu0bZZ+EiVdUqxKa7b/Ilw+A=
cloud.google.com/go/container v1.47.0/go.mod h1:ySZKwTx9fTEqo1YiWN09SrM96a/dgpxXsi1tEzWGQd0=
cloud.google.com/go/containeranalysis v0.15.0/go.mod h1:CgPFsUrNm5vZDlxW4DBdeYTW9KVtZVJLzHOtERyuEVc=
cloud.google.com/go/datacatalog v1.27.0/go.mod h1:YTI11pFlC5HCj4CphEf+qWCy/z9udd7o0HVN6c2Povg=
cloud.google.com/go/dataflow v0.12.0/go.mod h1:jcNeMiDW4pVYYKLi4jFkJmPwtq2A8OIF0CCqoh9K6yI=
cloud.google.com/go/dataform v0.15.0/go.mod h1:buNB3+hgOk+RhmONRw13Mk7uqANhRYGJOUVWDx63PtM=
cloud.google.com/go/datafusion v1.9.0/go.mod h1:sJzQ3KEt3pVmQzlO1RDnujdf5gB2agNzlKt0svOEj40=
cloud.google.com/go/datalabeling v0.10.0/go.mod h1:2JlC6SFzPXTAjK8XSbdeRJYLjk6QV4+AyCxJZlc4Ju4=
cloud.google.com/go/dataplex v1.30.0/go.mod h1:GgV6b+1viq2nMtr+AUzKNUbaR+tKGxdhVaMN8TPPu0w=
cloud.google.com/go/dataproc/v2 v2.17.0/go.mod h1:lUY58QBxs6IIScAo9ZZKSOxx3imkHBxz6dow9f4fSRM=
cloud.google.com/go/dataqna v0.10.0/go.mod h1:TJ6nt/jAilxNYzzbPMa4CsxO+l/9glfiNYb0Oarbvho=
cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8=
cloud.google.com/go/datastream v1.16.0/go.mod h1:9gPqghndibXcAXA7Y+PdbkoyTkGT95UbuoXk1vNdt1g=
cloud.google.com/go/deploy v1.28.0/go.mod h1:1x7W4Ql3uvz/1ljPTxES33/D4ss8ro/Q5pyhOzXaXvA=
cloud.google.com/go/dialogflow v1.78.0/go.mod h1:H+SCvWiah4SNalfbG9P4wGYaxlyemLa8OFD/ljKSlRA=
cloud.google.com/go/dlp v1.30.0/go.mod h1:HVcPCYTniHItHfk8LqepllS6fGE0hmYCtGHvQE+wSpU=
cloud.google.com/go/documentai v1.44.0/go.mod h1:ePqAOw0PvlezPoysTeP7TImdSzx52lErU3k1f4LrxWM=
cloud.google.com/go/domains v0.11.0/go.mod h1:jvi1QTJYy1w14K+3nBYjYJPaTHi1Zuv0y8wQkFqV8AE=
cloud.google.com/go/edgecontainer v1.5.0/go.mod h1:5iCEsaMo8lw6wwJzt33fyfxKqpm/r1iMoXyFzAG9kHQ=
cloud.google.com/go/errorreporting v0.5.0/go.mod h1:l7UT6tcjSmR37Pn8TjSrKYtoiPyOMAmWuCceowHMY+w=
cloud.google.com/go/essentialcontacts v1.8.0/go.mod h1:/nlPxHm9vyDSZyWHB1CQYkRV44VEp8VjPlqoeGweApE=
cloud.google.com/go/eventarc v1.19.0/go.mod h1:xO4c0cMGNC47wZPJVe7gpABvOLJF+Wt6ze2n6KM70ak=
cloud.google.com/go/filestore v1.11.0/go.mod h1:SOM0F8N/VIQaI8/KLbc1evVqX8S9KQmGdLYuj9HaQns=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/functions v1.20.0/go.mod h1:TW6jT0+yQsnI9ICkhJfz7HDFzdwtTaTOlKi1c9wQTqA=
cloud.google.com/go/gkebackup v1.9.0/go.mod h1:Rzxd7vr7h5Tsr9VHnexiK5LtFKGc+4LHRN9KI1osGXU=
cloud.google.com/go/gkeconnect v0.13.0/go.mod h1:C06HGlhVhE6Og1QA9hQRO4wLyxItldAnq16kIQpG/ws=
cloud.google.com/go/gkehub v0.17.0/go.mod h1:PAznKTCcvzLy2cJVb+tulfwaQUABlhYYRw5r3hMsgAI=
cloud.google.com/go/gkemulticloud v1.7.0/go.mod h1:Cei8ayrpfxRDivJLCiQbFvmieBTldPf9sAd5vYdVvWI=
cloud.google.com/go/gsuiteaddons v1.8.0/go.mod h1:iObYcSP07RXsRaflrKn/o6hQ+Ygzem3Nd9PovowChAg=
cloud.google.com/go/iap v1.13.0/go.mod h1:/EMWDLYdDGelUxucj9drtP8SyY1L1RfzbqeUnWCoaNA=
cloud.google.com/go/ids v1.6.0/go.mod h1:mSTbYgnZUQM3triYR96y+m07C4i7bAkMEZYbwDyTSTg=
cloud.google.com/go/iot v1.9.0/go.mod h1:Yj14ToMHIR2+IIdoWY2ziYNKbFnrlV/YahmcFhxWQlQ=
cloud.google.com/go/language v1.15.0/go.mod h1:4Q+axu8ZalG3KQKuoDisLKMoTxaM51fLOwLNOR8R4I4=
cloud.google.com/go/lifesciences v0.11.0/go.mod h1:LIgB118ExLb5A4MwPuMB9U1i5S6vIa6D4aVz6iXhScQ=
cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM=
cloud.google.com/go/managedidentities v1.8.0/go.mod h1:/YZXzgEYRzmYa/wLWr+5/2hXhg6wTJolpSCCoDDQ6II=
cloud.google.com/go/maps v1.31.0/go.mod h1:mWhSlc7r3FdTfh+9grRnmAy24yoHzQHSd9qPGeWFFe8=
cloud.google.com/go/mediatranslation v0.10.0/go.mod h1:zNtvcd8C5YEX7nW9dX0mOa4upqmzu8DGpuzgvvXb7mE=
cloud.google.com/go/memcache v1.12.0/go.mod h1:LfD72uuBN2xkj307WIFn4am3x4nsfKoADA7sL5Ww2KU=
cloud.google.com/go/metastore v1.15.0/go.mod h1:iKjrQqKUFc5yw0VVQjh/rHU3ASW4GAQpSXqhMPn2cXQ=
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
cloud.google.com/go/networkconnectivity v1.22.0/go.mod h1:E7lmnd1M04GM6ZjZPtWqYUWsJX40uHYlYpvqKJ5weqU=
cloud.google.com/go/networkmanagement v1.24.0/go.mod h1:rbVzje/eG7/w+iWIdbNMT25dU/89rA91X2iqI4cRniY=
cloud.google.com/go/networksecurity v0.12.0/go.mod h1:/xkjQdYYGHZGlpE+kF43RMJFx2Ak5FcUXEYXkBzuWHE=
cloud.google.com/go/notebooks v1.13.0/go.mod h1:ODeLG8Kzaxsh/jaQVGRzPj/5qTyrxetHfp4qBp4IytU=
cloud.google.com/go/optimization v1.8.0/go.mod h1:JjunBI7Gg+rI1kOu5Qhtk8xetbLKeAsRcxpOelFL1HQ=
cloud.google.com/go/orchestration v1.12.0/go.mod h1:z5NJIg6gM6mDcvnjNeUXhQYr08cBCeuQ3l3OCedGZ1A=
cloud.google.com/go/orgpolicy v1.16.0/go.mod h1:l88jhhDM/KYN0FJF87m5Gub1qcOFkN94efA2uhy+U0s=
cloud.google.com/go/osconfig v1.17.0/go.mod h1:iveYW1fYygItJeJwbQ/fkzYAzLEbXOQnr7+eBmH9MJ0=
cloud.google.com/go/oslogin v1.15.0/go.mod h1:hZPTJtRwb0f2SNvqOVqb2QoF6z1C1s6yvSx23jUnnFk=
cloud.google.com/go/phishingprotection v0.10.0/go.mod h1:6SxFjbwfY0UPbX8px45qFuejcqo6xaLliYBqnSNUYQs=
cloud.google.com/go/policytroubleshooter v1.12.0/go.mod h1:9YkZq/rC3wMBmBW3bhPux8haEss0t0C8e1xxs1kawws=
cloud.google.com/go/privatecatalog v0.11.0/go.mod h1:2Y+RuSNZWsM+tfHVI0/Lx0AbRW5jsliIr771Z/vvR/0=
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
cloud.google.com/go/recaptchaenterprise/v2 v2.22.0/go.mod h1:3jHDVYA4b/hlrISmTYjk4cXZWZLk4PenhH+NyrlYhRQ=
cloud.google.com/go/recommendationengine v0.10.0/go.mod h1:548mK1fxhG12vg4kHrLfWKX9BC7fsq2udo1YsED4L2s=
cloud.google.com/go/recommender v1.14.0/go.mod h1:21EuILd1jr+9X/aHcf7it+nWvB6VH+/ALz2vPqm4Dog=
cloud.google.com/go/redis v1.19.0/go.mod h1:vdL4FIUmY5DFGORAb/iBrpJWoWxygEpT9Ls8wK+8bsk=
cloud.google.com/go/resourcemanager v1.11.0/go.mod h1:jBdKCDtskipgmN1BC9wJnBzlGG7HpVIQ60cT9LEMgeQ=
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
cloud.google.com/go/retail v1.27.0/go.mod h1:aDziApdysNZudAlkRvbPv4pdPwR/hc3sPsYWCpeJOnc=
cloud.google.com/go/run v1.17.0/go.mod h1:24NIu/ueXZFL3OyKJfkhp1j3VJDAEukuclpBSiL9pa0=
cloud.google.com/go/scheduler v1.12.0/go.mod h1:A11HxINAugN6GqEYx36OVcc78dL6UHTT+1YXPZj2KBI=
cloud.google.com/go/security v1.20.0/go.mod h1:a/4VUEX2QdSolTbtqlNoCGGyBHc198XEvvBtaM14SKM=
cloud.google.com/go/securitycenter v1.40.0/go.mod h1:zxPwtKVAu6kBRQDYWFi6MdDmT72mI/N4CdTs77ktCjQ=
cloud.google.com/go/servicedirectory v1.13.0/go.mod h1:W1BVLsabUKHRkU/pVf1xY7v4JRIfZ7ly6+GoJyoCOeQ=
cloud.google.com/go/shell v1.9.0/go.mod h1:baiOHQUhZxzUL3DhLDdE0F9puPPmubxtTRfsgL96WpQ=
cloud.google.com/go/spanner v1.89.0/go.mod h1:okNuxnp1wdPaVoM5M28Al2irKZLkHhZ2Z+DW6/ZJWGw=
cloud.google.com/go/speech v1.31.0/go.mod h1:8U80+TvXb17d7L/5jDLC3BGGKTrVqR7jFX0SvTkXs30=
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
cloud.google.com/go/storagetransfer v1.14.0/go.mod h1:AXV3Zq+8CDDYa5VlFB4xCnve/LnuHRnCKh2ZP8bVFm4=
cloud.google.com/go/talent v1.9.0/go.mod h1:NW4daUY2mJAFrQ5C5+LrR+RoOyHgjQXKXDhhMxvpe9w=
cloud.google.com/go/texttospeech v1.17.0/go.mod h1:qXUktcF2pHd2Gh9GcY2NFEtZPnIeC+kKVPC3wes7LSk=
cloud.google.com/go/tpu v1.9.0/go.mod h1:MeJ0QXume/iLq+DEk9LcD62l6HynG5KZdW38pOkF7RU=
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
cloud.google.com/go/translate v1.13.0/go.mod h1:gNfroOEKGH2eAu1hm24Ex8E4e2bTiUDkWZWsfuMVGj0=
cloud.google.com/go/video v1.28.0/go.mod h1:ZjAJB+69e6A8fTr9ut+agY8gyWQP4+L+DLgSLO14RqE=
cloud.google.com/go/videointelligence v1.13.0/go.mod h1:jZ25omw+UDsubcHHyzKFR/h1lb+6NrqCoFTaN2rAtD0=
cloud.google.com/go/vision/v2 v2.10.0/go.mod h1:zyF6oZkvg4S0yCPAUcCVLKs+x2xqTBU0w1CI9+dlTA4=
cloud.google.com/go/vmmigration v1.11.0/go.mod h1:C8eGJ9LJE6KSInDCLQY349NK2KpebPIaahR/fin/xJQ=
cloud.google.com/go/vmwareengine v1.4.0/go.mod h1:fuz8B0EIb8CwioqmBSRCTZ4MQDT94Q0OiF4pPE/57Uc=
cloud.google.com/go/vpcaccess v1.9.0/go.mod h1:AYFkpistDjSc9FooHo3ezICbRsDoIyyoAbeXD1rDFOE=
cloud.google.com/go/webrisk v1.12.0/go.mod h1:oLNOZ1L/abccgx/OHi6us3YKHTsxYZKYd4eWv+hyvJQ=
cloud.google.com/go/websecurityscanner v1.8.0/go.mod h1:2FS2WQ+RaL6O3/HaGBl9JHkSbhKLRulmRNEHRS15Plw=
cloud.google.com/go/workflows v1.15.0/go.mod h1:2UOYVoNLYsroRbG3Ok6eBliFPThf5ronyqlD64NFLx8=
codeberg.org/go-fonts/dejavu v0.4.0/go.mod h1:abni088lmhQJvso2Lsb7azCKzwkfcnttl6tL1UTWKzg=
codeberg.org/go-fonts/latin-modern v0.4.0/go.mod h1:BF68mZznJ9QHn+hic9ks2DaFl4sR5YhfM6xTYaP9vNw=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
@@ -32,6 +148,8 @@ github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwS
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
@@ -59,6 +177,7 @@ github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@@ -213,6 +332,7 @@ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6O
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
@@ -249,10 +369,13 @@ github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
@@ -276,6 +399,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl
github.com/gtank/merlin v0.1.1-0.20191105220539-8318aed1a79f/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s=
github.com/gtank/ristretto255 v0.1.2/go.mod h1:Ph5OpO6c7xKUGROZfWVLiJf9icMDwUeIvY4OmlYW69o=
github.com/guptarohit/asciigraph v0.5.5/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
github.com/hanzoai/kms/sdk/go v1.0.0/go.mod h1:kPPvEuE3K+fcOY+S6hiNO8nffxDNChvC1Uc4fR5Y+xk=
github.com/hanzoai/tygoja v0.0.2/go.mod h1:o+poSPLJ5S3SMtdz7gJHaL/6P6tjGBZcfQMl0SUIBr4=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/go-bexpr v0.1.15/go.mod h1:HGKbAByHn2aJWUV47gL7+IjLK79iU3EZIbOwCXJZLoE=
@@ -311,6 +436,7 @@ github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/hydrogen18/memlistener v1.0.0/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -484,6 +610,7 @@ github.com/luxfi/zapdb v1.0.0 h1:u8TF4g/bWew5mQwiR/Mcu+QIFYTT6BP5Ty8oBaDgalA=
github.com/luxfi/zapdb v1.0.0/go.mod h1:tT2WF2iSabQHrAYe9l7LHscaIBfN2YtCbdgOrCyCI2A=
github.com/luxfi/zapdb/v4 v4.9.1/go.mod h1:gYJjLcFCxmUATZ6Zg7iZHwAbgBMxGVP1g8UOWomK70Y=
github.com/luxfi/zmq/v4 v4.2.2/go.mod h1:omy8ymZAhx60lx/nPsWT6uOV11uNFVnjtls+Hi5KqaU=
github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
@@ -527,6 +654,7 @@ github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.8.2/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
@@ -647,6 +775,7 @@ github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/status-im/keycard-go v0.3.3/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -689,6 +818,7 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
@@ -877,6 +1007,7 @@ google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaL
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
@@ -884,6 +1015,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401001100-f93e5f3e9f0f/go.
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+426
View File
@@ -0,0 +1,426 @@
// Package api — KMS-facing ZAP server.
//
// This file implements a luxfi/zap node that exposes MPC threshold operations
// to the KMS control plane. KMS dials this node, runs the ML-KEM-768 hybrid
// handshake (kms/pkg/zap), then issues opcode-prefixed JSON requests for
// keygen / sign / reshare / status / wallet lookup. The wire contract is the
// one defined by kms/pkg/mpc/zap_client.go — opcodes 0x0001-0x0031.
//
// The existing pkg/api zap server (zap_server.go, opcodes 0x0060+) is for
// MPC's own dashboard event bus and uses a different wire format. The two
// servers serve different consumers and run on different ports.
//
// Wire format per request: opcode(2 LE) || JSON payload.
// Response: opcode(2 LE) || JSON body. Errors set top-level "error" string.
package api
import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"github.com/luxfi/mpc/pkg/logger"
"github.com/luxfi/zap"
kmszap "github.com/luxfi/mpc/pkg/zap"
)
// KMS-facing opcode constants. These are the contract with
// luxfi/kms/pkg/mpc/zap_client.go and MUST stay in lockstep with that file.
const (
OpKMSStatus uint16 = 0x0001
OpKMSKeygen uint16 = 0x0010
OpKMSSign uint16 = 0x0011
OpKMSReshare uint16 = 0x0012
OpKMSWallet uint16 = 0x0020
)
// KMSZapServer wires MPCBackend onto a luxfi/zap.Node and frames responses in
// KMS's opcode-prefixed JSON dialect.
type KMSZapServer struct {
backend MPCBackend
// sessions holds per-peer hybrid handshake state. Once a peer runs the
// ML-KEM-768 handshake (opcode 0x00F0), every subsequent request is
// AEAD-sealed under the derived session key. A peer with no entry can
// still talk in the clear — forward-only fallback so older KMS builds
// that pre-date the handshake (#55) keep working until they upgrade.
sessions map[string]*kmszap.Session
sessionsMu sync.RWMutex
// localCaps controls what the server advertises in ServerHello. ML-KEM
// is on by default; we never opt out unilaterally.
localCaps uint16
node *zap.Node
}
// StartKMSZAP creates a luxfi/zap.Node bound to listenAddr and registers KMS
// opcode handlers. Returns the server (so callers can stop it on shutdown).
//
// nodeID is the ZAP advertised identity — KMS uses it as the auditable
// principal for sign/keygen requests. Every MPC StatefulSet pod gets a unique
// ID (mpc-kms-zap-<pod>) so KMS audit rows can pin a request to a node.
func StartKMSZAP(backend MPCBackend, nodeID, listenAddr string) (*KMSZapServer, error) {
port, err := parsePort(listenAddr)
if err != nil {
return nil, fmt.Errorf("zap: parse listen %q: %w", listenAddr, err)
}
node := zap.NewNode(zap.NodeConfig{
NodeID: nodeID,
ServiceType: "_mpc-kms._tcp",
Port: port,
NoDiscovery: true, // direct dial only — no mDNS leakage from prod pods
})
s := &KMSZapServer{
backend: backend,
sessions: make(map[string]*kmszap.Session),
localCaps: kmszap.CapMLKEM768,
node: node,
}
// Application-layer hybrid handshake. Distinct opcode so a client can
// negotiate before sending any threshold-op payload.
node.Handle(kmszap.OpClientHello, s.handleHandshake)
// KMS opcodes — the public surface.
node.Handle(OpKMSStatus, s.handle(s.handleStatus))
node.Handle(OpKMSKeygen, s.handle(s.handleKeygen))
node.Handle(OpKMSSign, s.handle(s.handleSign))
node.Handle(OpKMSReshare, s.handle(s.handleReshare))
node.Handle(OpKMSWallet, s.handle(s.handleWallet))
// Universal type=0 mux: clients that don't set the per-message type
// field (zap.Builder default) reach handlers via the opcode embedded in
// the body bytes. Mirrors kms/pkg/zapserver Register().
node.Handle(0, s.muxByOpcode)
if err := node.Start(); err != nil {
return nil, fmt.Errorf("zap: start node: %w", err)
}
logger.Info("KMS ZAP server started", "addr", listenAddr, "nodeID", nodeID, "caps", "X25519+ML-KEM-768")
return s, nil
}
// Stop tears down the underlying ZAP node. Idempotent.
func (s *KMSZapServer) Stop() {
if s.node != nil {
s.node.Stop()
}
}
// handlerFn is the shape of an op handler before opcode framing.
type handlerFn func(ctx context.Context, from string, payload []byte) ([]byte, error)
// handle wraps an opcode handler with payload extraction, optional session
// AEAD, and opcode-prefixed JSON response framing. The opcode comes from
// the first 2 LE bytes of the body — the same prefix the client wrote.
func (s *KMSZapServer) handle(op handlerFn) zap.Handler {
return func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
raw := extractPayload(msg)
if len(raw) < 2 {
return s.respond(from, 0, errBody("empty payload")), nil
}
opcode := binary.LittleEndian.Uint16(raw[:2])
payload := raw[2:]
// Session-sealed payloads are AEAD-opened before dispatch.
if sess := s.session(from); sess != nil {
pt, err := sess.Open(kmszap.DirClientToServer, payload)
if err != nil {
logger.Warn("kms-zap session open failed", "from", from, "op", opcode, "err", err)
return s.respond(from, opcode, errBody("session decrypt failed")), nil
}
payload = pt
}
body, err := op(ctx, from, payload)
if err != nil {
logger.Warn("kms-zap handler error", "from", from, "op", opcode, "err", err)
return s.respond(from, opcode, errBody(err.Error())), nil
}
return s.respond(from, opcode, body), nil
}
}
// muxByOpcode dispatches type-0 messages by reading the opcode from the body.
// Clients that don't set the ZAP message type field land here.
func (s *KMSZapServer) muxByOpcode(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
raw := extractPayload(msg)
if len(raw) < 2 {
return s.respond(from, 0, errBody("empty payload")), nil
}
op := binary.LittleEndian.Uint16(raw[:2])
if op == kmszap.OpClientHello {
return s.respondHandshake(from, raw[2:]), nil
}
dispatch := map[uint16]handlerFn{
OpKMSStatus: s.handleStatus,
OpKMSKeygen: s.handleKeygen,
OpKMSSign: s.handleSign,
OpKMSReshare: s.handleReshare,
OpKMSWallet: s.handleWallet,
}
h, ok := dispatch[op]
if !ok {
return s.respond(from, op, errBody(fmt.Sprintf("unknown opcode 0x%04x", op))), nil
}
payload := raw[2:]
if sess := s.session(from); sess != nil {
pt, err := sess.Open(kmszap.DirClientToServer, payload)
if err != nil {
logger.Warn("kms-zap mux session open failed", "from", from, "op", op, "err", err)
return s.respond(from, op, errBody("session decrypt failed")), nil
}
payload = pt
}
body, err := h(ctx, from, payload)
if err != nil {
logger.Warn("kms-zap mux handler error", "from", from, "op", op, "err", err)
return s.respond(from, op, errBody(err.Error())), nil
}
return s.respond(from, op, body), nil
}
// extractPayload pulls the application bytes out of a ZAP message,
// handling both the structured-Object form and the raw-bytes-after-header
// form used by zap.Builder.WriteBytes.
func extractPayload(msg *zap.Message) []byte {
if raw := msg.Root().Bytes(0); raw != nil {
return raw
}
b := msg.Bytes()
if len(b) > zap.HeaderSize {
return b[zap.HeaderSize:]
}
return nil
}
// respond frames opcode(2 LE) || body and seals under the peer's session
// key when one exists. Output matches what kms/pkg/mpc/zap_client.go expects.
func (s *KMSZapServer) respond(peerID string, opcode uint16, body []byte) *zap.Message {
if sess := s.session(peerID); sess != nil {
sealed, err := sess.Seal(kmszap.DirServerToClient, body)
if err != nil {
logger.Error("kms-zap seal failed", err, "from", peerID)
body = errBody("session seal failed")
} else {
body = sealed
}
}
out := make([]byte, 2+len(body))
binary.LittleEndian.PutUint16(out[0:2], opcode)
copy(out[2:], body)
b := zap.NewBuilder(len(out) + 64)
b.WriteBytes(out)
m, err := zap.Parse(b.Finish())
if err != nil {
// Builder output is always parseable — invariant violation.
logger.Error("kms-zap respond build failed", err)
return nil
}
return m
}
func errBody(msg string) []byte {
b, _ := json.Marshal(map[string]string{"error": msg})
return b
}
// session returns the active hybrid session for a peer, or nil when the
// peer hasn't run OpClientHello.
func (s *KMSZapServer) session(peerID string) *kmszap.Session {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
return s.sessions[peerID]
}
// setSession stores a freshly negotiated session, replacing any prior entry.
func (s *KMSZapServer) setSession(peerID string, sess *kmszap.Session) {
s.sessionsMu.Lock()
s.sessions[peerID] = sess
s.sessionsMu.Unlock()
}
// handleHandshake is the per-opcode handler for OpClientHello.
func (s *KMSZapServer) handleHandshake(_ context.Context, from string, msg *zap.Message) (*zap.Message, error) {
raw := extractPayload(msg)
if len(raw) < 2 {
return s.respond(from, kmszap.OpServerHello, errBody("empty handshake payload")), nil
}
// Strip 2-byte opcode prefix; client mux puts opcode in body.
return s.respondHandshake(from, raw[2:]), nil
}
// respondHandshake runs the server side of the hybrid PQ handshake, installs
// the session, and returns ServerHello as the response payload (opcode 0x00F1).
//
// ServerHello rides un-AEAD'd because the key only exists after this exchange
// completes. Subsequent opcodes get sealed automatically.
func (s *KMSZapServer) respondHandshake(from string, helloBytes []byte) *zap.Message {
replyWire, result, err := kmszap.ServerRespond(s.localCaps, helloBytes)
if err != nil {
logger.Warn("kms-zap handshake failed", "from", from, "err", err)
return s.respond(from, kmszap.OpServerHello, errBody(err.Error()))
}
sess, err := kmszap.NewSession(result.SessionKey, result.Hybrid)
if err != nil {
logger.Error("kms-zap session init failed", err, "from", from)
return s.respond(from, kmszap.OpServerHello, errBody(err.Error()))
}
s.setSession(from, sess)
if !result.Hybrid {
logger.Warn("kms-zap handshake classical-only — peer cleared ML-KEM-768",
"from", from, "peerCaps", result.PeerCaps)
} else {
logger.Info("kms-zap handshake hybrid", "from", from, "alg", "X25519+ML-KEM-768")
}
// Frame the server hello: opcode(0x00F1) || helloBytes (no AEAD).
out := make([]byte, 2+len(replyWire))
binary.LittleEndian.PutUint16(out[0:2], kmszap.OpServerHello)
copy(out[2:], replyWire)
b := zap.NewBuilder(len(out) + 64)
b.WriteBytes(out)
m, err := zap.Parse(b.Finish())
if err != nil {
logger.Error("kms-zap handshake reply build failed", err)
return nil
}
return m
}
// ---- KMS opcode handlers ----
// kmsZapStatusResponse mirrors kms/pkg/mpc/types.go ClusterStatus on the wire.
type kmsZapStatusResponse struct {
NodeID string `json:"node_id"`
Mode string `json:"mode"`
ExpectedPeers int `json:"expected_peers"`
ConnectedPeers int `json:"connected_peers"`
Ready bool `json:"ready"`
Threshold int `json:"threshold"`
Version string `json:"version"`
}
func (s *KMSZapServer) handleStatus(_ context.Context, _ string, _ []byte) ([]byte, error) {
st := s.backend.GetClusterStatus()
resp := kmsZapStatusResponse{
NodeID: st.NodeID,
Mode: st.Mode,
ExpectedPeers: st.ExpectedPeers,
ConnectedPeers: st.ConnectedPeers,
Ready: st.Ready,
Threshold: st.Threshold,
Version: st.Version,
}
return json.Marshal(resp)
}
// kmsZapKeygenRequest matches kms/pkg/mpc/zap_client.go Keygen() payload shape.
type kmsZapKeygenRequest struct {
VaultID string `json:"vault_id"`
Request struct {
WalletID string `json:"wallet_id"`
KeyType string `json:"key_type,omitempty"`
} `json:"request"`
}
func (s *KMSZapServer) handleKeygen(_ context.Context, from string, payload []byte) ([]byte, error) {
var req kmsZapKeygenRequest
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("decode keygen: %w", err)
}
if req.VaultID == "" {
return nil, fmt.Errorf("vault_id required")
}
logger.Info("kms-zap keygen", "from", from, "vault", req.VaultID, "wallet", req.Request.WalletID)
result, err := s.backend.TriggerKeygen(req.VaultID, req.Request.WalletID)
if err != nil {
return nil, err
}
return json.Marshal(result)
}
// kmsZapSignRequest matches kms/pkg/mpc/zap_client.go Sign() payload shape.
type kmsZapSignRequest struct {
VaultID string `json:"vault_id"`
WalletID string `json:"wallet_id"`
Payload []byte `json:"payload"`
}
func (s *KMSZapServer) handleSign(_ context.Context, from string, payload []byte) ([]byte, error) {
var req kmsZapSignRequest
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("decode sign: %w", err)
}
if req.VaultID == "" || req.WalletID == "" {
return nil, fmt.Errorf("vault_id and wallet_id required")
}
if len(req.Payload) == 0 {
return nil, fmt.Errorf("payload required")
}
logger.Info("kms-zap sign", "from", from, "vault", req.VaultID, "wallet", req.WalletID, "payloadHex", hex.EncodeToString(req.Payload[:min(8, len(req.Payload))]))
result, err := s.backend.TriggerSign(req.VaultID, req.WalletID, req.Payload)
if err != nil {
return nil, err
}
return json.Marshal(result)
}
// kmsZapReshareRequest matches kms/pkg/mpc/zap_client.go Reshare() payload shape.
type kmsZapReshareRequest struct {
WalletID string `json:"wallet_id"`
Request struct {
VaultID string `json:"vault_id"`
NewThreshold int `json:"new_threshold"`
NewParticipants []string `json:"new_participants"`
} `json:"request"`
}
func (s *KMSZapServer) handleReshare(_ context.Context, from string, payload []byte) ([]byte, error) {
var req kmsZapReshareRequest
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("decode reshare: %w", err)
}
if req.WalletID == "" || req.Request.VaultID == "" {
return nil, fmt.Errorf("wallet_id and request.vault_id required")
}
logger.Info("kms-zap reshare", "from", from, "wallet", req.WalletID, "newT", req.Request.NewThreshold)
if err := s.backend.TriggerReshare(req.Request.VaultID, req.WalletID, req.Request.NewThreshold, req.Request.NewParticipants); err != nil {
return nil, err
}
return json.Marshal(map[string]bool{"ok": true})
}
// handleWallet is a metadata read — mirrors GET /v1/wallets/{id}. We don't
// have a generic wallet-by-id lookup on MPCBackend; return cluster status as
// a placeholder so KMS Status() works and reshape later when MPCBackend grows
// a Wallet() method. Until then a Wallet() call returns a 503-shaped error
// the KMS client passes through.
func (s *KMSZapServer) handleWallet(_ context.Context, _ string, _ []byte) ([]byte, error) {
return nil, fmt.Errorf("wallet lookup not yet exposed on MPCBackend; use /v1/wallets HTTP")
}
// parsePort extracts the numeric port from a host:port listen string.
func parsePort(listenAddr string) (int, error) {
for i := len(listenAddr) - 1; i >= 0; i-- {
if listenAddr[i] == ':' {
var p int
if _, err := fmt.Sscanf(listenAddr[i+1:], "%d", &p); err != nil {
return 0, err
}
return p, nil
}
}
return 0, fmt.Errorf("no port in %q", listenAddr)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+300
View File
@@ -0,0 +1,300 @@
// In-process KMS ZAP server round-trip test. Spins up a real luxfi/zap.Node
// listener, dials it as a synthetic client, exercises the handshake +
// status / sign / keygen opcodes, and asserts wire framing.
package api
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"sync"
"testing"
"time"
kmszap "github.com/luxfi/mpc/pkg/zap"
"github.com/luxfi/zap"
)
// stubBackend implements MPCBackend with deterministic results so tests can
// assert byte-level outputs without standing up the full consensus stack.
type stubBackend struct {
keygenCalls int
signCalls int
mu sync.Mutex
}
func (s *stubBackend) TriggerKeygen(orgID, walletID string) (*KeygenResult, error) {
s.mu.Lock()
s.keygenCalls++
s.mu.Unlock()
return &KeygenResult{
WalletID: walletID,
ECDSAPubKey: "0202020202",
EDDSAPubKey: "0303030303",
EthAddress: "0x0000000000000000000000000000000000000001",
}, nil
}
func (s *stubBackend) TriggerSign(orgID, walletID string, payload []byte) (*SignResult, error) {
s.mu.Lock()
s.signCalls++
s.mu.Unlock()
return &SignResult{R: "aa", S: "bb", Signature: "ccdd"}, nil
}
func (s *stubBackend) TriggerReshare(orgID, walletID string, newT int, newP []string) error {
return nil
}
func (s *stubBackend) ExportKeyShare(orgID, walletID string) ([]byte, error) { return nil, nil }
func (s *stubBackend) GetClusterStatus() *ClusterStatus {
return &ClusterStatus{
NodeID: "test-node",
Mode: "consensus",
ExpectedPeers: 3,
ConnectedPeers: 3,
Ready: true,
Threshold: 2,
Version: "test",
}
}
// pickPort grabs a free TCP port from the kernel and returns it as a string.
func pickPort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("pickPort: %v", err)
}
addr := l.Addr().(*net.TCPAddr)
l.Close()
return fmt.Sprintf("127.0.0.1:%d", addr.Port)
}
// startServer spins a KMSZapServer on a free port and returns the address +
// a cleanup function.
func startServer(t *testing.T) (*KMSZapServer, string, func()) {
t.Helper()
addr := pickPort(t)
srv, err := StartKMSZAP(&stubBackend{}, "test-server", addr)
if err != nil {
t.Fatalf("StartKMSZAP: %v", err)
}
// Give the listener a moment to come up before clients dial.
time.Sleep(50 * time.Millisecond)
return srv, addr, func() { srv.Stop() }
}
// dialClient creates a luxfi/zap.Node, dials the server, and returns the
// peer ID once a connection is up.
func dialClient(t *testing.T, addr string) (*zap.Node, string) {
t.Helper()
client := zap.NewNode(zap.NodeConfig{
NodeID: "test-client",
ServiceType: "_mpc-kms._tcp",
NoDiscovery: true,
})
if err := client.ConnectDirect(addr); err != nil {
t.Fatalf("ConnectDirect %s: %v", addr, err)
}
peers := client.Peers()
if len(peers) == 0 {
t.Fatal("no peers after ConnectDirect")
}
return client, peers[0]
}
// callOp builds opcode(2 LE) || jsonPayload, sends it to the server, and
// returns the response opcode + body.
func callOp(t *testing.T, client *zap.Node, peerID string, op uint16, payload any) (uint16, []byte) {
t.Helper()
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := make([]byte, 2+len(data))
binary.LittleEndian.PutUint16(body[0:2], op)
copy(body[2:], data)
b := zap.NewBuilder(len(body) + 64)
b.WriteBytes(body)
msg, err := zap.Parse(b.Finish())
if err != nil {
t.Fatalf("parse req: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.Call(ctx, peerID, msg)
if err != nil {
t.Fatalf("Call op=0x%04x: %v", op, err)
}
raw := resp.Bytes()
if len(raw) < zap.HeaderSize+2 {
t.Fatalf("response too short: %d bytes", len(raw))
}
respBody := raw[zap.HeaderSize:]
respOp := binary.LittleEndian.Uint16(respBody[0:2])
return respOp, respBody[2:]
}
// TestKMSZAP_StatusRoundTrip exercises the smallest-possible RPC: status, no
// payload, no auth. Asserts the server frames the response correctly and the
// stub backend's data flows back.
func TestKMSZAP_StatusRoundTrip(t *testing.T) {
_, addr, stop := startServer(t)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
respOp, body := callOp(t, client, peer, OpKMSStatus, nil)
if respOp != OpKMSStatus {
t.Fatalf("response opcode: want 0x%04x got 0x%04x", OpKMSStatus, respOp)
}
var st kmsZapStatusResponse
if err := json.Unmarshal(body, &st); err != nil {
t.Fatalf("unmarshal status: %v body=%s", err, string(body))
}
if st.NodeID != "test-node" {
t.Errorf("NodeID: want test-node got %q", st.NodeID)
}
if st.Threshold != 2 {
t.Errorf("Threshold: want 2 got %d", st.Threshold)
}
if !st.Ready {
t.Errorf("Ready: want true got false")
}
}
// TestKMSZAP_SignRoundTrip drives a sign call through the wire and asserts
// the backend was actually invoked.
func TestKMSZAP_SignRoundTrip(t *testing.T) {
srv, addr, stop := startServer(t)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
req := kmsZapSignRequest{
VaultID: "vault-1",
WalletID: "wallet-1",
Payload: []byte("hello-mpc"),
}
respOp, body := callOp(t, client, peer, OpKMSSign, req)
if respOp != OpKMSSign {
t.Fatalf("response opcode: want 0x%04x got 0x%04x", OpKMSSign, respOp)
}
var sr SignResult
if err := json.Unmarshal(body, &sr); err != nil {
t.Fatalf("unmarshal sign: %v body=%s", err, string(body))
}
if sr.R != "aa" || sr.S != "bb" {
t.Errorf("sign: got R=%q S=%q want aa/bb", sr.R, sr.S)
}
stub := srv.backend.(*stubBackend)
stub.mu.Lock()
calls := stub.signCalls
stub.mu.Unlock()
if calls != 1 {
t.Errorf("backend sign calls: want 1 got %d", calls)
}
}
// TestKMSZAP_KeygenRoundTrip drives keygen + asserts the wallet ID pass-thru.
func TestKMSZAP_KeygenRoundTrip(t *testing.T) {
_, addr, stop := startServer(t)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
req := kmsZapKeygenRequest{VaultID: "vault-1"}
req.Request.WalletID = "wallet-2"
respOp, body := callOp(t, client, peer, OpKMSKeygen, req)
if respOp != OpKMSKeygen {
t.Fatalf("response opcode: want 0x%04x got 0x%04x", OpKMSKeygen, respOp)
}
var kr KeygenResult
if err := json.Unmarshal(body, &kr); err != nil {
t.Fatalf("unmarshal keygen: %v body=%s", err, string(body))
}
if kr.WalletID != "wallet-2" {
t.Errorf("WalletID: want wallet-2 got %q", kr.WalletID)
}
if kr.ECDSAPubKey == "" {
t.Errorf("ECDSAPubKey empty")
}
}
// TestKMSZAP_HybridHandshake runs the full ML-KEM-768 hybrid handshake and
// confirms a session is installed for the peer afterwards.
func TestKMSZAP_HybridHandshake(t *testing.T) {
srv, addr, stop := startServer(t)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
cliState, helloWire, err := kmszap.NewClient(kmszap.CapMLKEM768)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
// Frame: opcode || helloWire
body := make([]byte, 2+len(helloWire))
binary.LittleEndian.PutUint16(body[0:2], kmszap.OpClientHello)
copy(body[2:], helloWire)
b := zap.NewBuilder(len(body) + 64)
b.WriteBytes(body)
msg, err := zap.Parse(b.Finish())
if err != nil {
t.Fatalf("parse hello: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.Call(ctx, peer, msg)
if err != nil {
t.Fatalf("handshake call: %v", err)
}
raw := resp.Bytes()
respBody := raw[zap.HeaderSize:]
respOp := binary.LittleEndian.Uint16(respBody[0:2])
if respOp != kmszap.OpServerHello {
t.Fatalf("handshake opcode: want 0x%04x got 0x%04x", kmszap.OpServerHello, respOp)
}
result, err := cliState.ClientFinish(respBody[2:])
if err != nil {
t.Fatalf("ClientFinish: %v", err)
}
if !result.Hybrid {
t.Errorf("expected hybrid (ML-KEM-768) negotiation")
}
if len(result.SessionKey) != 32 {
t.Errorf("session key length: want 32 got %d", len(result.SessionKey))
}
// Server-side: peer should have an installed session keyed by client peer ID.
// The client peer ID as seen by the server is the test-client node ID.
srv.sessionsMu.RLock()
count := len(srv.sessions)
srv.sessionsMu.RUnlock()
if count == 0 {
t.Errorf("expected server to have installed a session after handshake")
}
}
// TestKMSZAP_UnknownOpcode confirms the server returns a structured error for
// unknown opcodes rather than dropping the connection.
func TestKMSZAP_UnknownOpcode(t *testing.T) {
_, addr, stop := startServer(t)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
respOp, body := callOp(t, client, peer, 0x9999, nil)
if respOp != 0x9999 {
t.Errorf("unknown opcode echo: want 0x9999 got 0x%04x", respOp)
}
var e map[string]string
if err := json.Unmarshal(body, &e); err != nil {
t.Fatalf("unmarshal err: %v body=%s", err, string(body))
}
if e["error"] == "" {
t.Errorf("expected error string in body, got %v", e)
}
}
+402
View File
@@ -0,0 +1,402 @@
// Package zap implements the application-layer hybrid post-quantum
// handshake for the MPC ZAP wire protocol. The construction is identical
// to luxfi/kms/pkg/zap (#55) so a KMS client and an MPC server can
// negotiate a session without cross-importing. Wire constants MUST stay
// in lockstep with KMS — do not edit opcodes or HKDF info without a
// coordinated rev across both repos.
//
// LP-022 specifies that ZAP transport runs over TLS 1.3 with Go 1.26's
// X25519MLKEM768 native hybrid curve. That covers the ingress path. For
// pod-to-pod traffic that does NOT route through ingress (in-cluster
// ZAP-to-ZAP), the connection is plaintext or classical-TLS only — no
// post-quantum key agreement at the wire layer.
//
// This package adds an application-layer hybrid handshake on top of
// every ZAP connection, irrespective of TLS configuration:
//
// 1. Classical X25519 ECDH (RFC 7748).
// 2. ML-KEM-768 KEM encapsulation (FIPS 203 / NIST PQC Level 3).
// 3. Combined shared secret = HKDF-SHA256(X25519_shared || MLKEM_shared).
// 4. Derived AES-256-GCM session key for the rest of the connection.
//
// Capability negotiation: a 2-byte cap bitmap rides in every hello frame.
// Bit 0 = ML-KEM-768 supported. If a peer clears bit 0, both sides fall
// back to classical-only and log a warning. The fallback path exists for
// inter-version compatibility — a forward-only stack would refuse, but
// at the wire level refusing breaks rolling upgrades.
//
// Wire format (little-endian where multi-byte):
//
// ClientHello (frame opcode 0x00F0):
// +0 uint16 caps // bit 0 = ML-KEM-768 supported
// +2 [32]byte x25519_pk // ephemeral X25519 public key
// +34 [1184]byte mlkem_pk // ML-KEM-768 public key (omit if !cap0)
//
// ServerHello (frame opcode 0x00F1):
// +0 uint16 caps // bit 0 echoed (set iff both sides support)
// +2 [32]byte x25519_pk // ephemeral X25519 public key
// +34 [1088]byte mlkem_ct // ML-KEM-768 ciphertext (omit if !cap0)
//
// The combined secret feeds HKDF-SHA256 with info "kms/zap/v1/session"
// and a transcript hash salt (SHA-256 of the concatenated hello frames).
// Output is a 32-byte AES-256-GCM session key.
//
// Backwards-compat: a peer that doesn't speak this opcode set will
// return a ZAP error or unknown-handler reply — the client treats that
// as "no PQ handshake available" and proceeds in classical-only mode
// (X25519-only, with a one-line warning). A KMS instance running this
// package always advertises bit 0 set.
package zap
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
"io"
mlkem "github.com/cloudflare/circl/kem/mlkem/mlkem768"
"golang.org/x/crypto/hkdf"
)
// HandshakeOpcodes are reserved at the high end of the KMS opcode space.
// See pkg/zapserver/server.go (OpSecret* = 0x0040..0x0043). 0x00F0/0x00F1
// avoid the secret opcode block and any future extensions there.
const (
OpClientHello uint16 = 0x00F0
OpServerHello uint16 = 0x00F1
)
// Capability bits. A peer ANDs its caps with the remote caps and uses
// the intersection to decide which key-agreement primitives to run.
const (
CapMLKEM768 uint16 = 1 << 0
)
// Sizes per FIPS 203 (ML-KEM-768) and RFC 7748 (X25519).
const (
X25519PubSize = 32
X25519PrivSize = 32
MLKEMPubSize = mlkem.PublicKeySize // 1184
MLKEMCtSize = mlkem.CiphertextSize // 1088
MLKEMSharedSize = mlkem.SharedKeySize // 32
SessionKeySize = 32 // AES-256-GCM
)
// Errors.
var (
ErrTruncated = errors.New("zap/handshake: frame truncated")
ErrCapMismatch = errors.New("zap/handshake: peer rejected ML-KEM-768")
ErrInvalidPubKey = errors.New("zap/handshake: invalid public key")
)
// HandshakeResult is the output of a completed handshake. SessionKey is
// 32 bytes suitable for AES-256-GCM. Hybrid is true iff ML-KEM-768 ran.
type HandshakeResult struct {
SessionKey []byte // 32 bytes
Hybrid bool // true = X25519+ML-KEM-768; false = X25519 only
PeerCaps uint16 // capability bitmap the peer advertised
}
// ClientHelloFrame is the parsed first message a client sends.
type ClientHelloFrame struct {
Caps uint16
X25519Pub [X25519PubSize]byte
MLKEMPub []byte // len == MLKEMPubSize iff caps&CapMLKEM768
}
// ServerHelloFrame is the parsed reply.
type ServerHelloFrame struct {
Caps uint16
X25519Pub [X25519PubSize]byte
MLKEMCt []byte // len == MLKEMCtSize iff caps&CapMLKEM768
}
// ClientState holds the ephemeral private keys a client needs to keep
// between sending hello and processing the server reply.
type ClientState struct {
caps uint16
x25519Priv *ecdh.PrivateKey
mlkemPriv *mlkem.PrivateKey
helloBytes []byte // serialized client hello, used in transcript hash
}
// ServerState holds keys derived during a server-side encapsulation; the
// caller produces a ServerHelloFrame and a SessionKey atomically via
// ServerRespond.
type ServerState struct {
helloBytes []byte // serialized server hello
}
// NewClient generates ephemeral key material and serializes a
// ClientHello. localCaps controls what the client offers; bit 0 set
// requests ML-KEM-768.
func NewClient(localCaps uint16) (*ClientState, []byte, error) {
xPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen X25519: %w", err)
}
st := &ClientState{caps: localCaps, x25519Priv: xPriv}
frame := &ClientHelloFrame{Caps: localCaps}
copy(frame.X25519Pub[:], xPriv.PublicKey().Bytes())
if localCaps&CapMLKEM768 != 0 {
mPub, mPriv, err := mlkem.GenerateKeyPair(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen ML-KEM: %w", err)
}
st.mlkemPriv = mPriv
buf := make([]byte, MLKEMPubSize)
mPub.Pack(buf)
frame.MLKEMPub = buf
}
wire, err := MarshalClientHello(frame)
if err != nil {
return nil, nil, err
}
st.helloBytes = wire
return st, wire, nil
}
// ServerRespond consumes a serialized ClientHello, performs X25519 ECDH
// and (if both peers offer ML-KEM-768) ML-KEM encapsulation, and returns
// (ServerHello bytes, session key, hybrid bool, error).
//
// localCaps is what the server supports. The reply caps are the AND of
// local and remote caps.
func ServerRespond(localCaps uint16, clientHelloWire []byte) (replyWire []byte, result *HandshakeResult, err error) {
client, err := UnmarshalClientHello(clientHelloWire)
if err != nil {
return nil, nil, err
}
negotiated := localCaps & client.Caps
hybrid := negotiated&CapMLKEM768 != 0
// Classical X25519 ECDH.
clientX, err := ecdh.X25519().NewPublicKey(client.X25519Pub[:])
if err != nil {
return nil, nil, fmt.Errorf("%w: X25519: %v", ErrInvalidPubKey, err)
}
serverXPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen X25519: %w", err)
}
xShared, err := serverXPriv.ECDH(clientX)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: X25519 ECDH: %w", err)
}
reply := &ServerHelloFrame{Caps: negotiated}
copy(reply.X25519Pub[:], serverXPriv.PublicKey().Bytes())
var mlkemShared []byte
if hybrid {
if len(client.MLKEMPub) != MLKEMPubSize {
return nil, nil, fmt.Errorf("%w: ML-KEM pubkey size %d", ErrInvalidPubKey, len(client.MLKEMPub))
}
var pk mlkem.PublicKey
if err := pk.Unpack(client.MLKEMPub); err != nil {
return nil, nil, fmt.Errorf("%w: ML-KEM unpack: %v", ErrInvalidPubKey, err)
}
ct := make([]byte, MLKEMCtSize)
ss := make([]byte, MLKEMSharedSize)
seed := make([]byte, mlkem.EncapsulationSeedSize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
return nil, nil, fmt.Errorf("zap/handshake: rand seed: %w", err)
}
pk.EncapsulateTo(ct, ss, seed)
reply.MLKEMCt = ct
mlkemShared = ss
}
replyWire, err = MarshalServerHello(reply)
if err != nil {
return nil, nil, err
}
sessionKey := combineAndDerive(xShared, mlkemShared, clientHelloWire, replyWire)
return replyWire, &HandshakeResult{
SessionKey: sessionKey,
Hybrid: hybrid,
PeerCaps: client.Caps,
}, nil
}
// ClientFinish consumes a ServerHello and finalizes the session key.
func (c *ClientState) ClientFinish(serverHelloWire []byte) (*HandshakeResult, error) {
server, err := UnmarshalServerHello(serverHelloWire)
if err != nil {
return nil, err
}
hybrid := server.Caps&CapMLKEM768 != 0 && c.caps&CapMLKEM768 != 0
if (c.caps&CapMLKEM768 != 0) && !hybrid {
// Peer cleared the bit; we requested it. This is the documented
// fallback path — caller logs the warning, we just continue.
}
serverX, err := ecdh.X25519().NewPublicKey(server.X25519Pub[:])
if err != nil {
return nil, fmt.Errorf("%w: X25519: %v", ErrInvalidPubKey, err)
}
xShared, err := c.x25519Priv.ECDH(serverX)
if err != nil {
return nil, fmt.Errorf("zap/handshake: X25519 ECDH: %w", err)
}
var mlkemShared []byte
if hybrid {
if c.mlkemPriv == nil {
return nil, errors.New("zap/handshake: ML-KEM priv missing on client")
}
if len(server.MLKEMCt) != MLKEMCtSize {
return nil, fmt.Errorf("%w: ML-KEM ct size %d", ErrInvalidPubKey, len(server.MLKEMCt))
}
ss := make([]byte, MLKEMSharedSize)
c.mlkemPriv.DecapsulateTo(ss, server.MLKEMCt)
mlkemShared = ss
}
sessionKey := combineAndDerive(xShared, mlkemShared, c.helloBytes, serverHelloWire)
return &HandshakeResult{
SessionKey: sessionKey,
Hybrid: hybrid,
PeerCaps: server.Caps,
}, nil
}
// combineAndDerive runs the NIST hybrid construction:
//
// IKM = X25519_shared || ML_KEM_shared
// salt = SHA-256(client_hello || server_hello)
// info = "kms/zap/v1/session"
// session = HKDF-SHA256(IKM, salt, info, 32)
//
// When hybrid is false (mlkemShared == nil), IKM = X25519_shared alone.
func combineAndDerive(xShared, mlkemShared, clientHello, serverHello []byte) []byte {
ikm := make([]byte, 0, len(xShared)+len(mlkemShared))
ikm = append(ikm, xShared...)
if mlkemShared != nil {
ikm = append(ikm, mlkemShared...)
}
var salt [sha256.Size]byte
h := sha256.New()
h.Write(clientHello)
h.Write(serverHello)
h.Sum(salt[:0])
r := hkdf.New(newSHA256, ikm, salt[:], []byte("kms/zap/v1/session"))
out := make([]byte, SessionKeySize)
if _, err := io.ReadFull(r, out); err != nil {
// HKDF over a fixed-size SHA-256 with 32-byte output is
// infallible. Treat as an unrecoverable invariant violation.
panic(fmt.Sprintf("zap/handshake: hkdf: %v", err))
}
return out
}
func newSHA256() hash.Hash { return sha256.New() }
// SessionAEAD wraps a 32-byte session key in an AES-256-GCM AEAD ready
// for sealing or opening frame payloads. Nonce management is the
// caller's responsibility — typical usage is a per-frame counter
// concatenated with a per-direction salt.
func SessionAEAD(sessionKey []byte) (cipher.AEAD, error) {
if len(sessionKey) != SessionKeySize {
return nil, fmt.Errorf("zap/handshake: session key must be %d bytes", SessionKeySize)
}
block, err := aes.NewCipher(sessionKey)
if err != nil {
return nil, fmt.Errorf("zap/handshake: aes.NewCipher: %w", err)
}
return cipher.NewGCM(block)
}
// ---- wire (de)serialization ----
// MarshalClientHello serializes a ClientHelloFrame to its on-the-wire
// byte representation. See the ClientHello layout in the package doc.
func MarshalClientHello(f *ClientHelloFrame) ([]byte, error) {
size := 2 + X25519PubSize
if f.Caps&CapMLKEM768 != 0 {
if len(f.MLKEMPub) != MLKEMPubSize {
return nil, fmt.Errorf("zap/handshake: ML-KEM pub must be %d bytes, got %d", MLKEMPubSize, len(f.MLKEMPub))
}
size += MLKEMPubSize
}
out := make([]byte, size)
binary.LittleEndian.PutUint16(out[0:2], f.Caps)
copy(out[2:2+X25519PubSize], f.X25519Pub[:])
if f.Caps&CapMLKEM768 != 0 {
copy(out[2+X25519PubSize:], f.MLKEMPub)
}
return out, nil
}
// UnmarshalClientHello parses a ClientHello wire frame.
func UnmarshalClientHello(b []byte) (*ClientHelloFrame, error) {
if len(b) < 2+X25519PubSize {
return nil, fmt.Errorf("%w: client hello %d bytes", ErrTruncated, len(b))
}
f := &ClientHelloFrame{
Caps: binary.LittleEndian.Uint16(b[0:2]),
}
copy(f.X25519Pub[:], b[2:2+X25519PubSize])
if f.Caps&CapMLKEM768 != 0 {
off := 2 + X25519PubSize
if len(b) < off+MLKEMPubSize {
return nil, fmt.Errorf("%w: ML-KEM pub", ErrTruncated)
}
f.MLKEMPub = make([]byte, MLKEMPubSize)
copy(f.MLKEMPub, b[off:off+MLKEMPubSize])
}
return f, nil
}
// MarshalServerHello serializes a ServerHelloFrame.
func MarshalServerHello(f *ServerHelloFrame) ([]byte, error) {
size := 2 + X25519PubSize
if f.Caps&CapMLKEM768 != 0 {
if len(f.MLKEMCt) != MLKEMCtSize {
return nil, fmt.Errorf("zap/handshake: ML-KEM ct must be %d bytes, got %d", MLKEMCtSize, len(f.MLKEMCt))
}
size += MLKEMCtSize
}
out := make([]byte, size)
binary.LittleEndian.PutUint16(out[0:2], f.Caps)
copy(out[2:2+X25519PubSize], f.X25519Pub[:])
if f.Caps&CapMLKEM768 != 0 {
copy(out[2+X25519PubSize:], f.MLKEMCt)
}
return out, nil
}
// UnmarshalServerHello parses a ServerHello wire frame.
func UnmarshalServerHello(b []byte) (*ServerHelloFrame, error) {
if len(b) < 2+X25519PubSize {
return nil, fmt.Errorf("%w: server hello %d bytes", ErrTruncated, len(b))
}
f := &ServerHelloFrame{
Caps: binary.LittleEndian.Uint16(b[0:2]),
}
copy(f.X25519Pub[:], b[2:2+X25519PubSize])
if f.Caps&CapMLKEM768 != 0 {
off := 2 + X25519PubSize
if len(b) < off+MLKEMCtSize {
return nil, fmt.Errorf("%w: ML-KEM ct", ErrTruncated)
}
f.MLKEMCt = make([]byte, MLKEMCtSize)
copy(f.MLKEMCt, b[off:off+MLKEMCtSize])
}
return f, nil
}
+92
View File
@@ -0,0 +1,92 @@
// Smoke test for the vendored ML-KEM-768 hybrid handshake. The audited
// reference suite lives in luxfi/kms/pkg/zap. This file pins three
// invariants that MUST hold for KMS↔MPC interop:
//
// 1. NewClient → ServerRespond → ClientFinish round-trips with hybrid=true
// when both sides advertise CapMLKEM768.
// 2. Both sides derive the same 32-byte session key.
// 3. Sealing under that key on one side opens on the other.
//
// If any of these break, KMS clients can no longer talk to MPC servers
// and the secrets-only mode regression returns.
package zap
import (
"bytes"
"testing"
)
func TestHandshakeHybridRoundTrip(t *testing.T) {
cli, helloWire, err := NewClient(CapMLKEM768)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
replyWire, srvResult, err := ServerRespond(CapMLKEM768, helloWire)
if err != nil {
t.Fatalf("ServerRespond: %v", err)
}
cliResult, err := cli.ClientFinish(replyWire)
if err != nil {
t.Fatalf("ClientFinish: %v", err)
}
if !srvResult.Hybrid || !cliResult.Hybrid {
t.Fatalf("expected hybrid both sides; srv=%v cli=%v", srvResult.Hybrid, cliResult.Hybrid)
}
if !bytes.Equal(srvResult.SessionKey, cliResult.SessionKey) {
t.Fatalf("session key mismatch")
}
if len(srvResult.SessionKey) != SessionKeySize {
t.Fatalf("session key size: want %d got %d", SessionKeySize, len(srvResult.SessionKey))
}
}
func TestSessionSealOpen(t *testing.T) {
cli, helloWire, err := NewClient(CapMLKEM768)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
replyWire, srv, err := ServerRespond(CapMLKEM768, helloWire)
if err != nil {
t.Fatalf("ServerRespond: %v", err)
}
cliRes, err := cli.ClientFinish(replyWire)
if err != nil {
t.Fatalf("ClientFinish: %v", err)
}
cliSess, err := NewSession(cliRes.SessionKey, cliRes.Hybrid)
if err != nil {
t.Fatalf("NewSession cli: %v", err)
}
srvSess, err := NewSession(srv.SessionKey, srv.Hybrid)
if err != nil {
t.Fatalf("NewSession srv: %v", err)
}
// C → S
plaintext := []byte("threshold-sign-payload")
sealed, err := cliSess.Seal(DirClientToServer, plaintext)
if err != nil {
t.Fatalf("Seal: %v", err)
}
pt, err := srvSess.Open(DirClientToServer, sealed)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !bytes.Equal(pt, plaintext) {
t.Fatalf("plaintext mismatch")
}
// S → C uses the opposite direction tag.
srvReply := []byte("ok")
sealedReply, err := srvSess.Seal(DirServerToClient, srvReply)
if err != nil {
t.Fatalf("Seal reply: %v", err)
}
openedReply, err := cliSess.Open(DirServerToClient, sealedReply)
if err != nil {
t.Fatalf("Open reply: %v", err)
}
if !bytes.Equal(openedReply, srvReply) {
t.Fatalf("reply mismatch")
}
}
+112
View File
@@ -0,0 +1,112 @@
// Session-layer framing helpers built on top of the handshake module.
//
// A completed handshake yields a 32-byte AES-256-GCM key. Every secret-
// carrying payload is sealed with that key before it crosses the wire and
// opened on the receiver. The ZAP frame envelope (opcode, message header)
// stays in clear — only the payload bytes the application supplies are
// confidential.
//
// Nonces are 12 bytes (GCM standard): 4-byte direction tag || 8-byte
// big-endian monotonic counter. The direction tag prevents a sealed frame
// the server emits from being re-injected on the client→server channel
// and vice versa. The counter is enforced never to wrap inside a single
// session; the underlying TCP stream from luxfi/zap delivers frames
// strictly in-order so peer counters stay in lockstep without any
// sequence number on the wire.
//
// Wire layout for a sealed payload:
//
// +0 ciphertext || GCM tag
//
// A replayed frame opens with a nonce mismatch (the receiver counter has
// advanced) and the AEAD rejects it. A tampered frame fails the GCM tag
// the same way. The handshake transcript hash binds the session key to
// both hello frames, so a captured ClientHello cannot resume into a
// previously observed session.
package zap
import (
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
"sync/atomic"
)
// Direction tags mix into the nonce so a frame the server seals is
// undecryptable on the server's own opener and vice versa.
const (
DirClientToServer uint32 = 0x43325300 // "C2S\x00"
DirServerToClient uint32 = 0x53324300 // "S2C\x00"
)
// Session is the per-connection AEAD state. SessionKey is the 32-byte
// HKDF output from the handshake; Hybrid records whether ML-KEM-768 ran
// for operator-visible logging.
type Session struct {
aead cipher.AEAD
hybrid bool
sendCtr atomic.Uint64
recvCtr atomic.Uint64
}
// NewSession wraps a 32-byte handshake-derived key in a Session ready to
// seal and open frames.
func NewSession(sessionKey []byte, hybrid bool) (*Session, error) {
a, err := SessionAEAD(sessionKey)
if err != nil {
return nil, err
}
return &Session{aead: a, hybrid: hybrid}, nil
}
// Hybrid reports whether the session was negotiated with ML-KEM-768
// active. Operators log this once per session at INFO.
func (s *Session) Hybrid() bool { return s.hybrid }
// Seal encrypts plaintext payload bytes for the given direction. The
// returned slice is the GCM ciphertext+tag (no length prefix).
//
// The send counter advances on every call; callers must use the same
// Session instance for every frame they send. Concurrent Seal is safe.
func (s *Session) Seal(direction uint32, plaintext []byte) ([]byte, error) {
ctr := s.sendCtr.Add(1)
if ctr == 0 {
return nil, errors.New("zap/session: send counter overflow")
}
nonce := makeNonce(direction, ctr)
return s.aead.Seal(nil, nonce, plaintext, nil), nil
}
// Open verifies and decrypts a sealed frame. The receive counter
// advances on every call; an out-of-order or replayed frame fails GCM
// and the counter is rolled back so the channel stays usable.
func (s *Session) Open(direction uint32, sealed []byte) ([]byte, error) {
if len(sealed) < s.aead.Overhead() {
return nil, errors.New("zap/session: sealed frame too short")
}
ctr := s.recvCtr.Add(1)
if ctr == 0 {
return nil, errors.New("zap/session: recv counter overflow")
}
nonce := makeNonce(direction, ctr)
pt, err := s.aead.Open(nil, nonce, sealed, nil)
if err != nil {
// Roll the counter back so a benign retry can succeed; a true
// attacker still cannot forge under the AEAD key.
s.recvCtr.Add(^uint64(0))
return nil, fmt.Errorf("zap/session: aead open: %w", err)
}
return pt, nil
}
// makeNonce builds a 12-byte GCM nonce: 4 bytes of direction tag || 8
// bytes of big-endian counter. Big-endian on the counter makes the nonce
// monotonically increase as a byte string, which is the documented GCM
// safe pattern for monotonic counters.
func makeNonce(direction uint32, counter uint64) []byte {
out := make([]byte, 12)
binary.BigEndian.PutUint32(out[0:4], direction)
binary.BigEndian.PutUint64(out[4:12], counter)
return out
}