mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
+37
-11
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user