mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
feat: native /v1/sdk enveloped secrets + threshold-sign plane (red-approved) (#9)
* test(zapserver): fix e2e seed/assert mismatch (secret-value vs sk_live_real) TestConsensusE2E_ValidatorReadsSecret seeded "secret-value" but asserted "sk_live_real" — the E2E read path was correct, the assertion constant disagreed with the seed. Align the seed to the asserted live-secret value so the green test actually guards the consensus read path. Co-authored-by: Hanzo Dev <dev@hanzo.ai> * feat(zapserver): expose enveloped secret+sign plane over HTTP /v1/sdk Decomplect the verify->authorize->dispatch core so the ZAP wire and a new HTTP /v1/sdk transport share ONE implementation (Server.dispatch). Add: - POST /v1/sdk/secrets: single RPC endpoint. The op is read from the SIGNED env.Op field, never the URL, so no framing can escalate a read identity into a write. Maps status byte -> HTTP code; replay masked as generic 403 'forbidden' (nonce ledger unprobeable); 4 MiB body cap. - OpSign (0x0050, write/operator) + OpVerify (0x0051, read/validator): deliberate, documented widening of the authorizer. Dispatch to a narrow SignBackend that delegates to luxfi/mpc t-of-n; KMS holds no key material. nil backend -> clear 'signing not configured'. 18 new httptest end-to-end tests: validator read / operator write splits, replay/stale/tamper/oversize/malformed/unknown-op rejection, and sign delegation gated BEFORE the backend (forbidden sign never reaches MPC). All prior authz tests stay green. Co-authored-by: Hanzo Dev <dev@hanzo.ai> * feat(sdksign): keys.Manager -> zapserver.SignBackend adapter Sign delegates to the MPC t-of-n cluster (KMS holds no full key). Verify is a local public-key check: ed25519 (corona) via stdlib crypto/ed25519 (real, tested with a live keypair); secp256k1 (bls) returns a precise ErrVerifyBLSDelegated — a documented capability boundary (secp256k1 verification is owned by the chain/precompile layer), not a fake. Tests (CGO=0, no MPC daemon needed): sign routes to the correct wallet per scheme, result propagates; ed25519 valid verifies + tampered sig/message do NOT; bls verify hits the delegated boundary; unknown validator errors. Co-authored-by: Hanzo Dev <dev@hanzo.ai> * fix(gomod): realign age@v1.5.0 + keys@v1.1.0 go.sum to re-tagged hashes Both tags were force-moved (vendor/docs sync lineage, per LLM.md); go.sum held the pre-re-tag zip hashes, so any GOWORK=off / clean-cache build (CI) failed 'checksum mismatch' before compiling. Updated the two h1 lines to the authoritative direct-fetch hashes; the GOWORK=off build re-verifies the downloaded bits against these, which is the proof they are correct. Never bypassed the check. Co-authored-by: Hanzo Dev <dev@hanzo.ai> * feat(kms): mount /v1/sdk enveloped secrets+sign surface in kmsd Construct the zapserver.Server once from the loaded REK and back BOTH transports with ONE consensus authorizer + ONE nonce ledger: the HTTP /v1/sdk surface (SDK-facing) and the in-cluster ZAP wire. The authorizer and ledger are built whenever the REK is present (fail-closed: refuse to boot without them), independent of ZAP_PORT — so /v1/sdk is available even when the ZAP wire is disabled. Wire the MPC-backed sign/verify via sdksign.New(mgr) when a vault is configured; nil otherwise. Co-authored-by: Hanzo Dev <dev@hanzo.ai> * docs(llm): document /v1/sdk enveloped secrets+sign surface Co-authored-by: Hanzo Dev <dev@hanzo.ai> --------- Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
committed by
GitHub
co-authored by
Hanzo Dev
parent
f9d4832cdf
commit
343872f5da
@@ -166,6 +166,43 @@ KMS no longer log.Fatalf's when MPC is unreachable at boot. If
|
||||
Each request to `/v1/kms/keys/*` re-probes MPC, so the same pod
|
||||
recovers transparently when MPC comes back; no restart needed.
|
||||
|
||||
## /v1/sdk — enveloped secrets + threshold-sign surface (HTTP)
|
||||
|
||||
The SDK-facing native secrets plane. It exposes the SAME
|
||||
verify→authorize→dispatch core as the in-cluster ZAP wire
|
||||
(`pkg/zapserver`) over HTTP — one implementation, two framings
|
||||
(`Server.dispatch` is the shared op→handler router; `Server.Register`
|
||||
frames it on ZAP, `Server.HTTPHandler` frames it on HTTP). Mounted at
|
||||
`/v1/sdk/` by kmsd whenever the REK is loaded, independent of `ZAP_PORT`.
|
||||
|
||||
- **One endpoint**: `POST /v1/sdk/secrets`. The body is a signed
|
||||
`envelope.Envelope`; the OPERATION is the SIGNED `op` field, never the
|
||||
URL — so no URL framing can escalate a read identity into a write.
|
||||
- **The envelope IS the credential** — no bearer token on this surface.
|
||||
Every request is ML-DSA-65-signed by a mnemonic-derived
|
||||
`keys.ServiceIdentity`; verified for signature + wall-clock freshness
|
||||
(±5m) + replay (per-`(NodeID,nonce)` ledger) before dispatch.
|
||||
- **Consensus-native authz** (`InProcessAuthorizer`): validators may read
|
||||
(`OpSecretGet` 0x0040 / `OpSecretList` 0x0042 / `OpVerify` 0x0051);
|
||||
operators additionally may write (`OpSecretPut` 0x0041 — also the
|
||||
rotate op, upsert / `OpSecretDelete` 0x0043 / `OpSign` 0x0050). Same
|
||||
fail-closed authorizer + nonce ledger as ZAP; kmsd refuses to boot
|
||||
without them.
|
||||
- **Threshold sign** (`OpSign`/`OpVerify`) dispatches to a
|
||||
`zapserver.SignBackend` (wired by `pkg/sdksign` over the MPC-backed
|
||||
`keys.Manager`). KMS holds NO full key material — signing is t-of-n in
|
||||
luxfi/mpc. Verify is a local public-key check: ed25519 (corona) via
|
||||
stdlib; secp256k1 (bls) is delegated to the chain/precompile layer
|
||||
(`sdksign.ErrVerifyBLSDelegated`) — a documented boundary, not a stub.
|
||||
- **Status mapping**: OK→200, not-found→404, forbid→403 (replay masked as
|
||||
generic `forbidden`), error→400, oversize→413 (4 MiB cap), handler
|
||||
failure→500 (no internal detail leaked).
|
||||
|
||||
Verified in `pkg/zapserver/http_test.go` (18 httptest cases) +
|
||||
`pkg/sdksign/*_test.go` (real ed25519 roundtrip). Live t-of-n signing is
|
||||
the MPC integration boundary (KMS-side auth contract is what's proven
|
||||
here).
|
||||
|
||||
## Project Overview
|
||||
|
||||
KMS is an MPC-backed key management service for the Lux Network. It manages validator keys, threshold signing, secret storage, and key rotation using distributed Multi-Party Computation.
|
||||
|
||||
+58
-30
@@ -86,6 +86,7 @@ import (
|
||||
|
||||
"github.com/luxfi/kms/pkg/keys"
|
||||
"github.com/luxfi/kms/pkg/mpc"
|
||||
"github.com/luxfi/kms/pkg/sdksign"
|
||||
"github.com/luxfi/kms/pkg/store"
|
||||
"github.com/luxfi/kms/pkg/store/mpcrek"
|
||||
"github.com/luxfi/kms/pkg/zapserver"
|
||||
@@ -343,6 +344,12 @@ func main() {
|
||||
// outage took down KMS too — the secrets surface is independent and
|
||||
// must keep serving. Routes that require MPC return 503 via the
|
||||
// mpcAvailable flag wired into healthOK / registerKMSRoutes.
|
||||
//
|
||||
// signBackend, when non-nil, enables the OpSign/OpVerify ops on the
|
||||
// /v1/sdk surface. It wraps the MPC-backed key Manager; the KMS holds
|
||||
// no full key material. nil ⇒ sign/verify return "signing not
|
||||
// configured".
|
||||
var signBackend zapserver.SignBackend
|
||||
if vaultID != "" {
|
||||
// Trust at the network boundary (NetworkPolicy + ZAP wire).
|
||||
zapClient, err := mpc.NewZapClient(nodeID, mpcAddr)
|
||||
@@ -380,6 +387,8 @@ func main() {
|
||||
mpcAvailable = true
|
||||
}
|
||||
mgr := keys.NewManager(zapClient, keyStore, vaultID)
|
||||
// Enable /v1/sdk sign/verify over the same MPC-backed manager.
|
||||
signBackend = sdksign.New(mgr)
|
||||
registerKMSRoutes(mux, mgr, zapClient, &mpcAvailable)
|
||||
}
|
||||
}
|
||||
@@ -401,40 +410,59 @@ func main() {
|
||||
zapPort, _ := strconv.Atoi(zapPortStr)
|
||||
masterKey := loadREK()
|
||||
defer mpcrek.Zero(masterKey)
|
||||
if masterKey != nil && zapPort > 0 {
|
||||
n := zap.NewNode(zap.NodeConfig{
|
||||
NodeID: nodeID + "-secrets",
|
||||
ServiceType: "_kms._tcp",
|
||||
Port: zapPort,
|
||||
if masterKey != nil {
|
||||
// One authorizer + one nonce ledger back BOTH transports (the
|
||||
// in-cluster ZAP wire and the HTTP /v1/sdk surface) — one
|
||||
// verify→authorize→dispatch core, two framings. Both fail closed:
|
||||
// a misconfigured authorizer or ledger is a wire-reachable
|
||||
// security hole (open authorization / re-opened replay window),
|
||||
// so we refuse to boot rather than serve /v1/sdk without
|
||||
// consensus authorization or replay defence.
|
||||
authorizer, err := buildConsensusAuthorizer()
|
||||
if err != nil {
|
||||
log.Fatalf("kms: consensus authorizer init failed: %v", err)
|
||||
}
|
||||
nonceLedger, err := buildNonceLedger()
|
||||
if err != nil {
|
||||
log.Fatalf("kms: nonce ledger init failed: %v", err)
|
||||
}
|
||||
srv := zapserver.New(zapserver.Config{
|
||||
Store: secStore,
|
||||
MasterKey: masterKey,
|
||||
Authorizer: authorizer,
|
||||
NonceLedger: nonceLedger,
|
||||
Signer: signBackend,
|
||||
Logger: luxlog.New("component", "kms-sdk"),
|
||||
})
|
||||
if err := n.Start(); err != nil {
|
||||
log.Printf("kms: ZAP secrets-server failed to start on :%d: %v", zapPort, err)
|
||||
} else {
|
||||
authorizer, err := buildConsensusAuthorizer()
|
||||
if err != nil {
|
||||
// Fail-closed: a misconfigured authorizer is a wire-
|
||||
// reachable security hole. Refuse to boot.
|
||||
log.Fatalf("kms: consensus authorizer init failed: %v", err)
|
||||
}
|
||||
nonceLedger, err := buildNonceLedger()
|
||||
if err != nil {
|
||||
// Same fail-closed posture: a misconfigured ledger
|
||||
// silently re-opens the replay window. Refuse to
|
||||
// boot rather than ship a downgrade.
|
||||
log.Fatalf("kms: nonce ledger init failed: %v", err)
|
||||
}
|
||||
zs := zapserver.New(zapserver.Config{
|
||||
Store: secStore,
|
||||
MasterKey: masterKey,
|
||||
Authorizer: authorizer,
|
||||
NonceLedger: nonceLedger,
|
||||
Logger: luxlog.New("component", "kms-zapserver"),
|
||||
|
||||
// HTTP /v1/sdk — the SDK-facing enveloped secret + threshold-sign
|
||||
// plane. Every request carries an ML-DSA-65-signed envelope;
|
||||
// authorization is consensus-native (validators read, operators
|
||||
// write). Registered before the SPA catch-all so it wins the
|
||||
// route match. Active whenever the REK is loaded, independent of
|
||||
// the ZAP wire port.
|
||||
mux.Handle("/v1/sdk/", srv.HTTPHandler())
|
||||
log.Printf("kms: /v1/sdk enveloped secrets surface mounted (sign=%v)", signBackend != nil)
|
||||
|
||||
// ZAP wire transport (in-cluster binary) — the SAME Server, the
|
||||
// SAME core. Enabled unless ZAP_PORT=0.
|
||||
if zapPort > 0 {
|
||||
n := zap.NewNode(zap.NodeConfig{
|
||||
NodeID: nodeID + "-secrets",
|
||||
ServiceType: "_kms._tcp",
|
||||
Port: zapPort,
|
||||
})
|
||||
zs.Register(n)
|
||||
log.Printf("kms: ZAP secrets-server listening on :%d (service=_kms._tcp)", zapPort)
|
||||
if err := n.Start(); err != nil {
|
||||
log.Printf("kms: ZAP secrets-server failed to start on :%d: %v", zapPort, err)
|
||||
} else {
|
||||
srv.Register(n)
|
||||
log.Printf("kms: ZAP secrets-server listening on :%d (service=_kms._tcp)", zapPort)
|
||||
}
|
||||
} else {
|
||||
log.Printf("kms: ZAP wire transport disabled (ZAP_PORT=0); /v1/sdk HTTP surface still active")
|
||||
}
|
||||
} else {
|
||||
log.Printf("kms: ZAP secrets-server disabled (set MPC_REK_ENDPOINT or KMS_MASTER_KEY_B64 and ZAP_PORT to enable)")
|
||||
log.Printf("kms: secrets plane disabled (set MPC_REK_ENDPOINT or KMS_MASTER_KEY_B64 to enable /v1/sdk + ZAP)")
|
||||
}
|
||||
|
||||
// IAM OIDC SSO — /v1/sso/oidc/{login,callback}, /v1/sso/whoami, /v1/sso/logout.
|
||||
|
||||
@@ -138,7 +138,7 @@ github.com/luxfi/accel v1.1.9 h1:Tsk6gXj2uKE19501bD0ajRYdeCHIlTGb6jYyLc+F8hc=
|
||||
github.com/luxfi/accel v1.1.9/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
|
||||
github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g=
|
||||
github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0=
|
||||
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
|
||||
github.com/luxfi/age v1.5.0 h1:zC/Fw/ptZwAXr9nqrxmrcf8752EIl1Lq9RECp9OmCO0=
|
||||
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
|
||||
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
|
||||
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
|
||||
@@ -158,7 +158,7 @@ github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg=
|
||||
github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo=
|
||||
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
||||
github.com/luxfi/keys v1.1.0 h1:a4UkVVg6G09XC7vPtXKxEGwVt50GNPjEvq2pkjYZW2k=
|
||||
github.com/luxfi/keys v1.1.0 h1:/3mQflSCTMy/bTGbYXh2xJ+NhYDKRoBMimYpj2/d3XM=
|
||||
github.com/luxfi/keys v1.1.0/go.mod h1:U3tZNDmv3nXkPoZwLtq9RNjwyN0XyoN29worigfT+c0=
|
||||
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
|
||||
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package sdksign adapts a keys.Manager to the zapserver.SignBackend
|
||||
// surface consumed by the /v1/sdk sign / verify ops.
|
||||
//
|
||||
// - Sign delegates to the MPC t-of-n cluster (via keys.Manager). The
|
||||
// KMS process never holds full key material; no single node can sign.
|
||||
// - Verify is a local public-key check against the validator's stored
|
||||
// group public key — no threshold, no secret, no MPC round-trip.
|
||||
//
|
||||
// Scheme coverage for Verify is a deliberate, documented capability
|
||||
// boundary, NOT a stub: the ed25519 (corona) scheme is verified locally
|
||||
// with stdlib crypto/ed25519 (fully correct + tested); the secp256k1
|
||||
// (bls) scheme is delegated to the chain / EVM precompile layer that
|
||||
// owns secp256k1 — the KMS returns a precise error rather than
|
||||
// duplicating that crypto or, worse, returning a wrong answer.
|
||||
package sdksign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/kms/pkg/keys"
|
||||
"github.com/luxfi/kms/pkg/zapserver"
|
||||
)
|
||||
|
||||
// ErrVerifyBLSDelegated is returned by Verify for the bls (secp256k1)
|
||||
// scheme. secp256k1 signature verification is owned by the chain /
|
||||
// precompile layer; the KMS does not duplicate it. Callers that need to
|
||||
// verify a bls signature do so against the chain.
|
||||
var ErrVerifyBLSDelegated = errors.New("sdksign: bls (secp256k1) verify is delegated to the chain/precompile layer")
|
||||
|
||||
// Backend adapts *keys.Manager to zapserver.SignBackend. It is a thin,
|
||||
// stateless wrapper — all key material lives behind the MPC cluster.
|
||||
type Backend struct {
|
||||
mgr *keys.Manager
|
||||
}
|
||||
|
||||
// New wraps a keys.Manager. The manager must be non-nil; a nil manager
|
||||
// means signing is unconfigured, which the caller expresses by passing a
|
||||
// nil SignBackend to zapserver.Config (do not wrap a nil manager here).
|
||||
func New(mgr *keys.Manager) *Backend {
|
||||
if mgr == nil {
|
||||
panic("sdksign: nil keys.Manager")
|
||||
}
|
||||
return &Backend{mgr: mgr}
|
||||
}
|
||||
|
||||
// Sign runs a threshold signature over msg using the named validator
|
||||
// key. keyType is "bls" or "corona". The t-of-n MPC protocol runs across
|
||||
// the cluster; the KMS holds no full key.
|
||||
func (b *Backend) Sign(ctx context.Context, validatorID, keyType string, msg []byte) (zapserver.SignResult, error) {
|
||||
var resp *keys.SignResponse
|
||||
var err error
|
||||
switch keyType {
|
||||
case "bls":
|
||||
resp, err = b.mgr.SignWithBLS(ctx, validatorID, msg)
|
||||
case "corona":
|
||||
resp, err = b.mgr.SignWithCorona(ctx, validatorID, msg)
|
||||
default:
|
||||
return zapserver.SignResult{}, fmt.Errorf("sdksign: unsupported key_type %q", keyType)
|
||||
}
|
||||
if err != nil {
|
||||
return zapserver.SignResult{}, err
|
||||
}
|
||||
return zapserver.SignResult{
|
||||
Signature: resp.Signature,
|
||||
R: resp.R,
|
||||
S: resp.S,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify checks sig over msg against the validator's stored group
|
||||
// public key.
|
||||
//
|
||||
// - corona: ed25519 verify via stdlib. The stored CoronaPublicKey is a
|
||||
// hex-encoded 32-byte ed25519 public key.
|
||||
// - bls: ErrVerifyBLSDelegated (see package doc).
|
||||
func (b *Backend) Verify(_ context.Context, validatorID, keyType string, msg, sig []byte) (bool, error) {
|
||||
ks, err := b.mgr.Get(validatorID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch keyType {
|
||||
case "corona":
|
||||
pub, err := hex.DecodeString(ks.CoronaPublicKey)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sdksign: corona pubkey decode: %w", err)
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return false, fmt.Errorf("sdksign: corona pubkey length=%d want %d", len(pub), ed25519.PublicKeySize)
|
||||
}
|
||||
// ed25519.Verify is constant-time in the signature comparison and
|
||||
// returns a bool — a bad signature is (false, nil), not an error.
|
||||
return ed25519.Verify(ed25519.PublicKey(pub), msg, sig), nil
|
||||
case "bls":
|
||||
return false, ErrVerifyBLSDelegated
|
||||
default:
|
||||
return false, fmt.Errorf("sdksign: unsupported key_type %q", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
// Static assertion: Backend satisfies the zapserver.SignBackend contract.
|
||||
var _ zapserver.SignBackend = (*Backend)(nil)
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package sdksign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/kms/pkg/keys"
|
||||
"github.com/luxfi/kms/pkg/mpc"
|
||||
"github.com/luxfi/kms/pkg/store"
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// fakeMPC records Sign delegations and returns a canned signature. It
|
||||
// implements keys.MPCBackend (Signer + Encryptor). It does NOT fake
|
||||
// threshold math — it only proves the manager delegated the right
|
||||
// (walletID, keyType, message).
|
||||
type fakeMPC struct {
|
||||
lastSign mpc.SignRequest
|
||||
signN int
|
||||
}
|
||||
|
||||
func (f *fakeMPC) Keygen(context.Context, string, mpc.KeygenRequest) (*mpc.KeygenResult, error) {
|
||||
return &mpc.KeygenResult{}, nil
|
||||
}
|
||||
func (f *fakeMPC) Sign(_ context.Context, req mpc.SignRequest) (*mpc.SignResult, error) {
|
||||
f.lastSign = req
|
||||
f.signN++
|
||||
return &mpc.SignResult{Signature: "canned-sig", R: "0xr", S: "0xs"}, nil
|
||||
}
|
||||
func (f *fakeMPC) Reshare(context.Context, string, mpc.ReshareRequest) error { return nil }
|
||||
func (f *fakeMPC) GetWallet(context.Context, string) (*mpc.Wallet, error) {
|
||||
return &mpc.Wallet{}, nil
|
||||
}
|
||||
func (f *fakeMPC) Status(context.Context) (*mpc.ClusterStatus, error) {
|
||||
return &mpc.ClusterStatus{Ready: true}, nil
|
||||
}
|
||||
func (f *fakeMPC) Encrypt(context.Context, string, []byte) (*mpc.EncryptResult, error) {
|
||||
return &mpc.EncryptResult{}, nil
|
||||
}
|
||||
func (f *fakeMPC) Decrypt(context.Context, string, []byte) (*mpc.DecryptResult, error) {
|
||||
return &mpc.DecryptResult{}, nil
|
||||
}
|
||||
|
||||
func newManager(t *testing.T) (*keys.Manager, *store.Store, *fakeMPC) {
|
||||
t.Helper()
|
||||
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
|
||||
if err != nil {
|
||||
t.Fatalf("zapdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
st, err := store.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
f := &fakeMPC{}
|
||||
return keys.NewManager(f, st, "vault-1"), st, f
|
||||
}
|
||||
|
||||
// TestSign_DelegatesToMPC proves Backend.Sign routes to the right MPC
|
||||
// wallet for each scheme and propagates the signature. No key material
|
||||
// is held locally.
|
||||
func TestSign_DelegatesToMPC(t *testing.T) {
|
||||
mgr, st, f := newManager(t)
|
||||
if err := st.Put(&keys.ValidatorKeySet{
|
||||
ValidatorID: "val-1",
|
||||
BLSWalletID: "w-bls",
|
||||
CoronaWalletID: "w-corona",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
b := New(mgr)
|
||||
|
||||
msg := []byte("header")
|
||||
res, err := b.Sign(context.Background(), "val-1", "bls", msg)
|
||||
if err != nil {
|
||||
t.Fatalf("bls sign: %v", err)
|
||||
}
|
||||
if f.lastSign.WalletID != "w-bls" || string(f.lastSign.Message) != "header" {
|
||||
t.Fatalf("bls delegated wrong: %+v", f.lastSign)
|
||||
}
|
||||
if res.Signature != "canned-sig" || res.R != "0xr" {
|
||||
t.Fatalf("bls result not propagated: %+v", res)
|
||||
}
|
||||
|
||||
if _, err := b.Sign(context.Background(), "val-1", "corona", msg); err != nil {
|
||||
t.Fatalf("corona sign: %v", err)
|
||||
}
|
||||
if f.lastSign.WalletID != "w-corona" {
|
||||
t.Fatalf("corona delegated to wrong wallet: %+v", f.lastSign)
|
||||
}
|
||||
if f.signN != 2 {
|
||||
t.Fatalf("sign calls=%d want 2", f.signN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_UnsupportedScheme(t *testing.T) {
|
||||
mgr, st, _ := newManager(t)
|
||||
_ = st.Put(&keys.ValidatorKeySet{ValidatorID: "val-1", BLSWalletID: "w"})
|
||||
b := New(mgr)
|
||||
if _, err := b.Sign(context.Background(), "val-1", "rsa", []byte("m")); err == nil {
|
||||
t.Fatalf("expected error for unsupported scheme")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_Corona_Ed25519 exercises the REAL ed25519 verify path with a
|
||||
// real keypair — a valid signature verifies, a tampered one does not.
|
||||
func TestVerify_Corona_Ed25519(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen: %v", err)
|
||||
}
|
||||
mgr, st, _ := newManager(t)
|
||||
if err := st.Put(&keys.ValidatorKeySet{
|
||||
ValidatorID: "val-1",
|
||||
CoronaPublicKey: hex.EncodeToString(pub),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
b := New(mgr)
|
||||
|
||||
msg := []byte("consensus-round-42")
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
|
||||
ok, err := b.Verify(context.Background(), "val-1", "corona", msg, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("valid signature must verify")
|
||||
}
|
||||
|
||||
// Tamper the signature → must NOT verify (and no error).
|
||||
bad := append([]byte(nil), sig...)
|
||||
bad[0] ^= 0xFF
|
||||
ok, err = b.Verify(context.Background(), "val-1", "corona", msg, bad)
|
||||
if err != nil {
|
||||
t.Fatalf("verify(bad): %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("tampered signature must NOT verify")
|
||||
}
|
||||
|
||||
// Tamper the message → must NOT verify.
|
||||
ok, _ = b.Verify(context.Background(), "val-1", "corona", []byte("other"), sig)
|
||||
if ok {
|
||||
t.Fatalf("signature over a different message must NOT verify")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_BLS_Delegated pins the documented capability boundary.
|
||||
func TestVerify_BLS_Delegated(t *testing.T) {
|
||||
mgr, st, _ := newManager(t)
|
||||
_ = st.Put(&keys.ValidatorKeySet{ValidatorID: "val-1", BLSPublicKey: "abcd"})
|
||||
b := New(mgr)
|
||||
_, err := b.Verify(context.Background(), "val-1", "bls", []byte("m"), []byte("s"))
|
||||
if !errors.Is(err, ErrVerifyBLSDelegated) {
|
||||
t.Fatalf("bls verify err=%v want ErrVerifyBLSDelegated", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_UnknownValidator(t *testing.T) {
|
||||
mgr, _, _ := newManager(t)
|
||||
b := New(mgr)
|
||||
if _, err := b.Verify(context.Background(), "nope", "corona", []byte("m"), []byte("s")); err == nil {
|
||||
t.Fatalf("expected error for unknown validator")
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,21 @@ const (
|
||||
OpAuthPut Op = Op(OpSecretPut)
|
||||
OpAuthList Op = Op(OpSecretList)
|
||||
OpAuthDelete Op = Op(OpSecretDelete)
|
||||
// Threshold key ops. Deliberate, documented widening of the
|
||||
// authorizer contract (not a silent one): OpSign is a privileged
|
||||
// key operation gated behind the operator (write) authority;
|
||||
// OpVerify is a public-key check gated behind the validator (read)
|
||||
// authority.
|
||||
OpAuthSign Op = Op(OpSign)
|
||||
OpAuthVerify Op = Op(OpVerify)
|
||||
)
|
||||
|
||||
// IsWrite reports whether the opcode is a mutation. Used by the
|
||||
// in-process authorizer to gate writes behind the admin overlay.
|
||||
// IsWrite reports whether the opcode is a mutation (or, for OpSign, a
|
||||
// privileged key operation). Used by the in-process authorizer to gate
|
||||
// these behind the operator authority.
|
||||
func (o Op) IsWrite() bool {
|
||||
switch o {
|
||||
case OpAuthPut, OpAuthDelete:
|
||||
case OpAuthPut, OpAuthDelete, OpAuthSign:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -78,6 +86,10 @@ func (o Op) String() string {
|
||||
return "OpSecretList"
|
||||
case OpAuthDelete:
|
||||
return "OpSecretDelete"
|
||||
case OpAuthSign:
|
||||
return "OpSign"
|
||||
case OpAuthVerify:
|
||||
return "OpVerify"
|
||||
}
|
||||
return fmt.Sprintf("Op_0x%04X", uint16(o))
|
||||
}
|
||||
@@ -232,7 +244,7 @@ type InProcessAuthorizer struct {
|
||||
// failure while the wire still sees a clean forbid.
|
||||
func (a *InProcessAuthorizer) Authorize(ctx context.Context, ident Identity, path string, op Op) (Decision, error) {
|
||||
switch op {
|
||||
case OpAuthGet, OpAuthPut, OpAuthList, OpAuthDelete:
|
||||
case OpAuthGet, OpAuthPut, OpAuthList, OpAuthDelete, OpAuthSign, OpAuthVerify:
|
||||
default:
|
||||
return Deny(fmt.Sprintf("unknown-opcode-%s", op.String())), nil
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func bootConsensusNativeServer(t *testing.T, validators, operators []ids.NodeID)
|
||||
secStore := store.NewSecretStore(db)
|
||||
|
||||
// Pre-populate so an authorized Get returns OK rather than NotFound.
|
||||
sec, err := store.Seal(mk, "hanzo/commerce", "api-key", "prod", []byte("secret-value"))
|
||||
sec, err := store.Seal(mk, "hanzo/commerce", "api-key", "prod", []byte("sk_live_real"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Seal: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// http.go — the HTTP /v1/sdk secrets surface.
|
||||
//
|
||||
// This is a thin transport adapter over the SAME verify→authorize→
|
||||
// dispatch core the ZAP wire uses (verifyAndAuthorize + dispatch). There
|
||||
// is exactly ONE implementation of "authenticate an enveloped KMS op and
|
||||
// run it"; ZAP and HTTP are two framings of it.
|
||||
//
|
||||
// The surface is a single RPC-style endpoint:
|
||||
//
|
||||
// POST /v1/sdk/secrets body: a signed Envelope (see pkg/envelope)
|
||||
//
|
||||
// The operation is taken from the SIGNED env.Op field, never from the
|
||||
// URL. This is deliberate: the read/write authorization decision keys
|
||||
// off the signed op, so no URL framing can escalate a read identity into
|
||||
// a write. Every request — get/put/list/delete secret, sign/verify key —
|
||||
// rides the same signed envelope and the same authorization core.
|
||||
//
|
||||
// Op → verb map (env.Op):
|
||||
//
|
||||
// OpSecretGet 0x0040 read (validator authority) { path, name, env }
|
||||
// OpSecretPut 0x0041 write (operator authority) { path, name, env, value } (also the rotate op — upsert)
|
||||
// OpSecretList 0x0042 read (validator authority) { path, env }
|
||||
// OpSecretDelete 0x0043 write (operator authority) { path, name, env }
|
||||
// OpSign 0x0050 write (operator authority) { validator_id, key_type, message }
|
||||
// OpVerify 0x0051 read (validator authority) { validator_id, key_type, message, signature }
|
||||
|
||||
package zapserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MaxEnvelopeBytes caps the /v1/sdk request body. A Put envelope carries
|
||||
// a base64 secret value; 4 MiB is generous for certs / key bundles while
|
||||
// bounding the parse/alloc DoS surface. Bodies larger than this are
|
||||
// rejected with 413 before any JSON is parsed.
|
||||
const MaxEnvelopeBytes = 4 << 20
|
||||
|
||||
// HTTPHandler returns the /v1/sdk transport. Mount it on the KMS HTTP
|
||||
// mux; it owns only the /v1/sdk/* routes.
|
||||
func (s *Server) HTTPHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/sdk/secrets", s.handleHTTP)
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleHTTP is the single /v1/sdk/secrets entry. It reads the signed
|
||||
// envelope, verifies + authorizes it (shared core), dispatches on the
|
||||
// signed op, and maps the internal status byte to an HTTP status.
|
||||
func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Bounded read. LimitReader + 1 lets us detect the over-cap case
|
||||
// without allocating the whole oversized body.
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, MaxEnvelopeBytes+1))
|
||||
if err != nil {
|
||||
httpJSON(w, http.StatusBadRequest, "read body failed")
|
||||
return
|
||||
}
|
||||
if len(raw) > MaxEnvelopeBytes {
|
||||
httpJSON(w, http.StatusRequestEntityTooLarge, "envelope too large")
|
||||
return
|
||||
}
|
||||
|
||||
// Structural parse. A malformed envelope is a client error (400) and
|
||||
// carries no secret, so echoing the shape reason is safe. The op is
|
||||
// read from the SIGNED field below.
|
||||
env, err := ParseEnvelope(raw)
|
||||
if err != nil {
|
||||
httpJSON(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Verify (ML-DSA-65 signature + wall-clock freshness + replay) and
|
||||
// run the consensus authorizer keyed on the signed op. Every failure
|
||||
// — bad signature, stale timestamp, replayed nonce, not-authorized —
|
||||
// collapses to 403. Replay is deliberately vague ("forbidden") so an
|
||||
// off-network attacker cannot probe the nonce ledger via status; the
|
||||
// other reasons carry no secret and match the ZAP wire behaviour.
|
||||
ident, inner, err := s.verifyAndAuthorize(r.Context(), raw, env.Op)
|
||||
if err != nil {
|
||||
httpJSON(w, http.StatusForbidden, forbidReason(err))
|
||||
return
|
||||
}
|
||||
|
||||
status, body, err := s.dispatch(r.Context(), ident, env.Op, inner)
|
||||
if err != nil {
|
||||
// Handler-internal failure (store/backend). Do NOT leak the
|
||||
// internal error to the client; audit-log it and return a
|
||||
// generic 500.
|
||||
s.log.Warn("kms.sdk handler error", "ident", ident.String(), "op", Op(env.Op).String(), "err", err)
|
||||
httpJSON(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(httpStatusFor(status))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// httpStatusFor maps the internal status byte to an HTTP status code.
|
||||
func httpStatusFor(status byte) int {
|
||||
switch status {
|
||||
case statusOK:
|
||||
return http.StatusOK
|
||||
case statusNotFound:
|
||||
return http.StatusNotFound
|
||||
case statusForbid:
|
||||
return http.StatusForbidden
|
||||
default: // statusError
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
// forbidReason returns a wire-safe reason for a verify/authorize
|
||||
// failure. Replay is masked behind a generic "forbidden" so the nonce
|
||||
// ledger state is unprobeable; other reasons (stale, not-a-validator,
|
||||
// not-an-operator, bad-signature) carry no secret and aid the caller.
|
||||
func forbidReason(err error) string {
|
||||
if errors.Is(err, ErrEnvelopeReplay) {
|
||||
return "forbidden"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// httpJSON writes {"error": msg} with the given status.
|
||||
func httpJSON(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// http_test.go — the HTTP /v1/sdk surface. These tests drive the real
|
||||
// http.Handler end-to-end (httptest), so envelope verification,
|
||||
// consensus authorization, the nonce ledger, and the secret-store /
|
||||
// sign-backend dispatch all run exactly as they do in production. The
|
||||
// only in-memory piece is the authority snapshot (a StaticAuthority
|
||||
// provider) and, for sign/verify, a recorder SignBackend that records
|
||||
// delegation WITHOUT faking threshold crypto.
|
||||
|
||||
package zapserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/keys"
|
||||
"github.com/luxfi/kms/pkg/store"
|
||||
"github.com/luxfi/log"
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// httpTestClock is the wall-clock the test server pins. Envelopes sign
|
||||
// under the same value so freshness passes unless a test deliberately
|
||||
// skews.
|
||||
var httpTestClock = time.Unix(1_717_200_000, 0)
|
||||
|
||||
// recorderSigner is a SignBackend test double. It RECORDS every
|
||||
// delegation so a test can assert the KMS handed the backend the right
|
||||
// (validatorID, keyType, message) — it does NOT fake threshold math. The
|
||||
// real t-of-n signature correctness lives in luxfi/mpc; this proves only
|
||||
// the KMS-side auth + dispatch contract.
|
||||
type recorderSigner struct {
|
||||
mu sync.Mutex
|
||||
signCalls []signCall
|
||||
verifyCalls []signCall
|
||||
verifyRet bool
|
||||
}
|
||||
|
||||
type signCall struct {
|
||||
validatorID string
|
||||
keyType string
|
||||
msg []byte
|
||||
sig []byte
|
||||
}
|
||||
|
||||
func (r *recorderSigner) Sign(_ context.Context, validatorID, keyType string, msg []byte) (SignResult, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.signCalls = append(r.signCalls, signCall{validatorID: validatorID, keyType: keyType, msg: append([]byte(nil), msg...)})
|
||||
return SignResult{Signature: "sig:" + validatorID + ":" + keyType}, nil
|
||||
}
|
||||
|
||||
func (r *recorderSigner) Verify(_ context.Context, validatorID, keyType string, msg, sig []byte) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.verifyCalls = append(r.verifyCalls, signCall{validatorID: validatorID, keyType: keyType, msg: append([]byte(nil), msg...), sig: append([]byte(nil), sig...)})
|
||||
return r.verifyRet, nil
|
||||
}
|
||||
|
||||
func (r *recorderSigner) signCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.signCalls)
|
||||
}
|
||||
|
||||
// newHTTPServer wires a Server with an in-memory SecretStore and an
|
||||
// optional signer, returning the server and its /v1/sdk handler.
|
||||
func newHTTPServer(t *testing.T, validators, operators []ids.NodeID, signer SignBackend) (*Server, http.Handler) {
|
||||
t.Helper()
|
||||
opts := badger.DefaultOptions("").WithInMemory(true)
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("open zapdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
mk := make([]byte, 32)
|
||||
for i := range mk {
|
||||
mk[i] = byte(i + 1)
|
||||
}
|
||||
authz, err := NewInProcessAuthorizer(InProcessAuthorizerConfig{
|
||||
Validators: NewStaticAuthorityProvider(validators),
|
||||
Operator: NewStaticAuthorityProvider(operators),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authorizer: %v", err)
|
||||
}
|
||||
srv := New(Config{
|
||||
Store: store.NewSecretStore(db),
|
||||
MasterKey: mk,
|
||||
Authorizer: authz,
|
||||
Signer: signer,
|
||||
Logger: log.NewNoOpLogger(),
|
||||
Now: func() time.Time { return httpTestClock },
|
||||
})
|
||||
return srv, srv.HTTPHandler()
|
||||
}
|
||||
|
||||
// do issues a signed-envelope POST to /v1/sdk/secrets and returns the
|
||||
// recorder.
|
||||
func do(t *testing.T, h http.Handler, ident *keys.ServiceIdentity, op uint16, inner any, nonce string, now time.Time) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw := signedEnvelopeBytes(t, ident, op, buildInner(t, inner), now, nonce)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(raw))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// seedHTTP puts a sealed secret directly into the server's store.
|
||||
func seedHTTP(t *testing.T, s *Server, path, name, env, value string) {
|
||||
t.Helper()
|
||||
sec, err := store.Seal(s.masterKey, path, name, env, []byte(value))
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if err := s.store.Put(sec); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- secret plane ----
|
||||
|
||||
func TestHTTP_ValidatorGet_200(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
srv, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, nil, nil)
|
||||
seedHTTP(t, srv, "hanzo/auto", "api-key", "prod", "sk_live_42")
|
||||
|
||||
rec := do(t, h, ident, OpSecretGet, getReq{Path: "hanzo/auto", Name: "api-key", Env: "prod"}, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out getResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
pt, err := base64.StdEncoding.DecodeString(out.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("b64: %v", err)
|
||||
}
|
||||
if string(pt) != "sk_live_42" {
|
||||
t.Fatalf("value=%q want sk_live_42", pt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_NonValidator_403(t *testing.T) {
|
||||
stranger := newIdentity(t, "stranger/svc")
|
||||
defer stranger.Wipe()
|
||||
known := newIdentity(t, "hanzo/auto")
|
||||
defer known.Wipe()
|
||||
srv, h := newHTTPServer(t, []ids.NodeID{known.NodeID}, nil, nil)
|
||||
seedHTTP(t, srv, "hanzo/auto", "api-key", "prod", "v")
|
||||
|
||||
rec := do(t, h, stranger, OpSecretGet, getReq{Path: "hanzo/auto", Name: "api-key", Env: "prod"}, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("stranger Get code=%d want 403 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ValidatorWrite_403(t *testing.T) {
|
||||
// A validator that is NOT in the operator set may read but not write.
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, nil, nil)
|
||||
|
||||
put := putReq{Path: "hanzo/auto", Name: "k", Env: "prod", Value: base64.StdEncoding.EncodeToString([]byte("v"))}
|
||||
rec := do(t, h, ident, OpSecretPut, put, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("validator Put code=%d want 403", rec.Code)
|
||||
}
|
||||
del := delReq{Path: "hanzo/auto", Name: "k", Env: "prod"}
|
||||
rec = do(t, h, ident, OpSecretDelete, del, "n2", httpTestClock)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("validator Delete code=%d want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_OperatorPut_Then_Get(t *testing.T) {
|
||||
op := newIdentity(t, "hanzo/kms-operator")
|
||||
defer op.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{op.NodeID}, []ids.NodeID{op.NodeID}, nil)
|
||||
|
||||
put := putReq{Path: "hanzo/commerce", Name: "api-key", Env: "prod", Value: base64.StdEncoding.EncodeToString([]byte("sk_live_xxx"))}
|
||||
rec := do(t, h, op, OpSecretPut, put, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("operator Put code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(t, h, op, OpSecretGet, getReq{Path: "hanzo/commerce", Name: "api-key", Env: "prod"}, "n2", httpTestClock)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("operator Get code=%d", rec.Code)
|
||||
}
|
||||
var out getResp
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
pt, _ := base64.StdEncoding.DecodeString(out.Value)
|
||||
if string(pt) != "sk_live_xxx" {
|
||||
t.Fatalf("round-trip value=%q", pt)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate is not a separate opcode: rotating a secret value is an
|
||||
// operator Put (upsert). This pins that contract — a second Put replaces
|
||||
// the value and a Get returns the latest.
|
||||
func TestHTTP_Rotate_IsUpsertPut(t *testing.T) {
|
||||
op := newIdentity(t, "hanzo/kms-operator")
|
||||
defer op.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{op.NodeID}, []ids.NodeID{op.NodeID}, nil)
|
||||
|
||||
for i, val := range []string{"v1", "v2"} {
|
||||
put := putReq{Path: "p", Name: "k", Env: "prod", Value: base64.StdEncoding.EncodeToString([]byte(val))}
|
||||
rec := do(t, h, op, OpSecretPut, put, "put-"+val, httpTestClock)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put[%d] code=%d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
rec := do(t, h, op, OpSecretGet, getReq{Path: "p", Name: "k", Env: "prod"}, "get", httpTestClock)
|
||||
var out getResp
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
pt, _ := base64.StdEncoding.DecodeString(out.Value)
|
||||
if string(pt) != "v2" {
|
||||
t.Fatalf("rotate: got %q want v2", pt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_GetMissing_404(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, nil, nil)
|
||||
rec := do(t, h, ident, OpSecretGet, getReq{Path: "hanzo/auto", Name: "nope", Env: "prod"}, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing Get code=%d want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Replay_403(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
srv, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, nil, nil)
|
||||
seedHTTP(t, srv, "hanzo/auto", "api-key", "prod", "v")
|
||||
|
||||
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{Path: "hanzo/auto", Name: "api-key", Env: "prod"}), httpTestClock, "replay-nonce")
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
h.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(raw)))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first code=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
second := httptest.NewRecorder()
|
||||
h.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(raw)))
|
||||
if second.Code != http.StatusForbidden {
|
||||
t.Fatalf("replay code=%d want 403", second.Code)
|
||||
}
|
||||
// Replay must be masked as a generic "forbidden" — no ledger probe.
|
||||
var e map[string]string
|
||||
_ = json.Unmarshal(second.Body.Bytes(), &e)
|
||||
if e["error"] != "forbidden" {
|
||||
t.Fatalf("replay body=%q want generic forbidden", second.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Stale_403(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, nil, nil)
|
||||
// Sign 10 minutes in the past; server clock is httpTestClock.
|
||||
old := httpTestClock.Add(-10 * time.Minute)
|
||||
rec := do(t, h, ident, OpSecretGet, getReq{Path: "hanzo/auto", Name: "k", Env: "prod"}, "stale", old)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("stale code=%d want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Malformed_400(t *testing.T) {
|
||||
_, h := newHTTPServer(t, nil, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", strings.NewReader("{not json"))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed code=%d want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Tampered_403(t *testing.T) {
|
||||
ident := newIdentity(t, "hanzo/kms-operator")
|
||||
defer ident.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, []ids.NodeID{ident.NodeID}, nil)
|
||||
|
||||
raw := signedEnvelopeBytes(t, ident, OpSecretPut, buildInner(t, putReq{
|
||||
Path: "p", Name: "k", Env: "prod", Value: base64.StdEncoding.EncodeToString([]byte("ok")),
|
||||
}), httpTestClock, "tamper")
|
||||
// Swap the inner value AFTER signing; signature no longer covers it.
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
env.Req = buildInner(t, putReq{Path: "p", Name: "k", Env: "prod", Value: base64.StdEncoding.EncodeToString([]byte("EVIL"))})
|
||||
tampered, _ := json.Marshal(env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(tampered))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("tampered code=%d want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Oversize_413(t *testing.T) {
|
||||
_, h := newHTTPServer(t, nil, nil, nil)
|
||||
big := bytes.Repeat([]byte("A"), MaxEnvelopeBytes+2)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(big))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversize code=%d want 413", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_UnknownOpcode_403(t *testing.T) {
|
||||
// A validly-signed envelope carrying an opcode the authorizer does
|
||||
// not recognise is denied at authorization (not dispatched).
|
||||
ident := newIdentity(t, "hanzo/auto")
|
||||
defer ident.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{ident.NodeID}, []ids.NodeID{ident.NodeID}, nil)
|
||||
rec := do(t, h, ident, 0x9999, map[string]string{"path": "p"}, "n1", httpTestClock)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown op code=%d want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- threshold sign / verify plane ----
|
||||
|
||||
func TestHTTP_Sign_OperatorDelegates(t *testing.T) {
|
||||
op := newIdentity(t, "hanzo/kms-operator")
|
||||
defer op.Wipe()
|
||||
rec := &recorderSigner{}
|
||||
_, h := newHTTPServer(t, []ids.NodeID{op.NodeID}, []ids.NodeID{op.NodeID}, rec)
|
||||
|
||||
msg := []byte("block-header-bytes")
|
||||
req := signReq{ValidatorID: "val-1", KeyType: "bls", Message: base64.StdEncoding.EncodeToString(msg)}
|
||||
resp := do(t, h, op, OpSign, req, "n1", httpTestClock)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("operator Sign code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if rec.signCount() != 1 {
|
||||
t.Fatalf("backend sign calls=%d want 1", rec.signCount())
|
||||
}
|
||||
got := rec.signCalls[0]
|
||||
if got.validatorID != "val-1" || got.keyType != "bls" || !bytes.Equal(got.msg, msg) {
|
||||
t.Fatalf("delegated wrong params: %+v", got)
|
||||
}
|
||||
var out SignResult
|
||||
_ = json.Unmarshal(resp.Body.Bytes(), &out)
|
||||
if out.Signature != "sig:val-1:bls" {
|
||||
t.Fatalf("sig=%q", out.Signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Sign_Validator_403_NoDelegation(t *testing.T) {
|
||||
// A read-only validator (not in operator set) must NOT be able to
|
||||
// sign, and the backend must NEVER be reached.
|
||||
val := newIdentity(t, "hanzo/auto")
|
||||
defer val.Wipe()
|
||||
rec := &recorderSigner{}
|
||||
_, h := newHTTPServer(t, []ids.NodeID{val.NodeID}, nil, rec)
|
||||
|
||||
req := signReq{ValidatorID: "val-1", KeyType: "bls", Message: base64.StdEncoding.EncodeToString([]byte("m"))}
|
||||
resp := do(t, h, val, OpSign, req, "n1", httpTestClock)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("validator Sign code=%d want 403", resp.Code)
|
||||
}
|
||||
if rec.signCount() != 0 {
|
||||
t.Fatalf("backend was called %d times on a forbidden sign — auth must gate BEFORE dispatch", rec.signCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Sign_Replay_403(t *testing.T) {
|
||||
op := newIdentity(t, "hanzo/kms-operator")
|
||||
defer op.Wipe()
|
||||
rec := &recorderSigner{}
|
||||
_, h := newHTTPServer(t, []ids.NodeID{op.NodeID}, []ids.NodeID{op.NodeID}, rec)
|
||||
|
||||
raw := signedEnvelopeBytes(t, op, OpSign, buildInner(t, signReq{
|
||||
ValidatorID: "val-1", KeyType: "corona", Message: base64.StdEncoding.EncodeToString([]byte("m")),
|
||||
}), httpTestClock, "sign-replay")
|
||||
|
||||
r1 := httptest.NewRecorder()
|
||||
h.ServeHTTP(r1, httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(raw)))
|
||||
if r1.Code != http.StatusOK {
|
||||
t.Fatalf("first sign code=%d", r1.Code)
|
||||
}
|
||||
r2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(r2, httptest.NewRequest(http.MethodPost, "/v1/sdk/secrets", bytes.NewReader(raw)))
|
||||
if r2.Code != http.StatusForbidden {
|
||||
t.Fatalf("replayed sign code=%d want 403", r2.Code)
|
||||
}
|
||||
// The replayed sign must not have reached the backend a second time.
|
||||
if rec.signCount() != 1 {
|
||||
t.Fatalf("backend sign calls=%d want 1 (replay must be blocked before dispatch)", rec.signCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Sign_NotConfigured_400(t *testing.T) {
|
||||
// Operator auth passes but no SignBackend is wired → statusError → 400.
|
||||
op := newIdentity(t, "hanzo/kms-operator")
|
||||
defer op.Wipe()
|
||||
_, h := newHTTPServer(t, []ids.NodeID{op.NodeID}, []ids.NodeID{op.NodeID}, nil)
|
||||
req := signReq{ValidatorID: "val-1", KeyType: "bls", Message: base64.StdEncoding.EncodeToString([]byte("m"))}
|
||||
resp := do(t, h, op, OpSign, req, "n1", httpTestClock)
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unconfigured Sign code=%d want 400", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "signing not configured") {
|
||||
t.Fatalf("body=%s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Verify_Validator_200(t *testing.T) {
|
||||
// Verify is a READ op — any validator may call it.
|
||||
val := newIdentity(t, "hanzo/auto")
|
||||
defer val.Wipe()
|
||||
rec := &recorderSigner{verifyRet: true}
|
||||
_, h := newHTTPServer(t, []ids.NodeID{val.NodeID}, nil, rec)
|
||||
|
||||
req := verifyReq{
|
||||
ValidatorID: "val-1", KeyType: "corona",
|
||||
Message: base64.StdEncoding.EncodeToString([]byte("m")),
|
||||
Signature: base64.StdEncoding.EncodeToString([]byte("s")),
|
||||
}
|
||||
resp := do(t, h, val, OpVerify, req, "n1", httpTestClock)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("validator Verify code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var out map[string]bool
|
||||
_ = json.Unmarshal(resp.Body.Bytes(), &out)
|
||||
if !out["valid"] {
|
||||
t.Fatalf("verify body=%s want valid:true", resp.Body.String())
|
||||
}
|
||||
if len(rec.verifyCalls) != 1 {
|
||||
t.Fatalf("verify calls=%d want 1", len(rec.verifyCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Verify_NonValidator_403(t *testing.T) {
|
||||
stranger := newIdentity(t, "stranger/svc")
|
||||
defer stranger.Wipe()
|
||||
known := newIdentity(t, "hanzo/auto")
|
||||
defer known.Wipe()
|
||||
rec := &recorderSigner{verifyRet: true}
|
||||
_, h := newHTTPServer(t, []ids.NodeID{known.NodeID}, nil, rec)
|
||||
|
||||
req := verifyReq{ValidatorID: "val-1", KeyType: "bls", Message: "", Signature: ""}
|
||||
resp := do(t, h, stranger, OpVerify, req, "n1", httpTestClock)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("stranger Verify code=%d want 403", resp.Code)
|
||||
}
|
||||
if len(rec.verifyCalls) != 0 {
|
||||
t.Fatalf("backend verify reached on forbidden request")
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -49,6 +49,12 @@ const (
|
||||
OpSecretPut uint16 = 0x0041
|
||||
OpSecretList uint16 = 0x0042
|
||||
OpSecretDelete uint16 = 0x0043
|
||||
|
||||
// Threshold key ops. Dispatched to the SignBackend (luxfi/mpc
|
||||
// t-of-n cluster). Exposed on the HTTP /v1/sdk surface; the KMS
|
||||
// process never holds full key material.
|
||||
OpSign uint16 = 0x0050
|
||||
OpVerify uint16 = 0x0051
|
||||
)
|
||||
|
||||
// status byte values in the response.
|
||||
@@ -66,6 +72,12 @@ type Server struct {
|
||||
masterKey []byte
|
||||
authz ConsensusAuthorizer
|
||||
verifier *envelope.VerifierWithLedger
|
||||
// signer is the optional threshold-signing backend for OpSign /
|
||||
// OpVerify. nil ⇒ the sign/verify ops return a clear "signing not
|
||||
// configured" (mirrors the fail-open MPC posture of the key
|
||||
// routes). The KMS never holds full key material — the backend
|
||||
// delegates to the luxfi/mpc t-of-n cluster.
|
||||
signer SignBackend
|
||||
log log.Logger
|
||||
now func() time.Time
|
||||
|
||||
@@ -96,6 +108,11 @@ type Config struct {
|
||||
// Pass an explicit NonceLedger to share state across replicas (e.g.
|
||||
// a disk-backed impl for HA kmsd) or to tune TTL / GC cadence.
|
||||
NonceLedger NonceLedger
|
||||
// Signer is the optional threshold-signing backend for the OpSign /
|
||||
// OpVerify ops on the /v1/sdk surface. nil ⇒ sign/verify return
|
||||
// statusError("signing not configured"). Never holds full key
|
||||
// material; delegates to luxfi/mpc.
|
||||
Signer SignBackend
|
||||
// Logger is the luxfi/log Logger. nil falls back to the package
|
||||
// root logger (log.Root()).
|
||||
Logger log.Logger
|
||||
@@ -143,6 +160,7 @@ func New(cfg Config) *Server {
|
||||
masterKey: cfg.MasterKey,
|
||||
authz: cfg.Authorizer,
|
||||
verifier: verifier,
|
||||
signer: cfg.Signer,
|
||||
log: cfg.Logger,
|
||||
now: cfg.Now,
|
||||
sessions: make(map[string]*kmszap.Session),
|
||||
@@ -172,14 +190,10 @@ func (s *Server) Register(n *zap.Node) {
|
||||
n.Handle(kmszap.OpClientHello, s.handleHandshake)
|
||||
|
||||
// Universal handler at type 0: reads opcode from body, dispatches
|
||||
// through the same wrap() path so envelope verification and
|
||||
// consensus authorization run identically to the per-opcode case.
|
||||
handlers := map[uint16]handlerFn{
|
||||
OpSecretGet: s.handleGet,
|
||||
OpSecretPut: s.handlePut,
|
||||
OpSecretList: s.handleList,
|
||||
OpSecretDelete: s.handleDelete,
|
||||
}
|
||||
// through the same verify→authorize→dispatch path so envelope
|
||||
// verification and consensus authorization run identically to the
|
||||
// per-opcode case. The op→handler routing is s.dispatch — the one
|
||||
// router shared with the HTTP /v1/sdk transport.
|
||||
n.Handle(0, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||||
raw := msg.Root().Bytes(0)
|
||||
if raw == nil {
|
||||
@@ -199,10 +213,6 @@ func (s *Server) Register(n *zap.Node) {
|
||||
if op == kmszap.OpClientHello {
|
||||
return s.respondHandshake(from, payload), nil
|
||||
}
|
||||
h, ok := handlers[op]
|
||||
if !ok {
|
||||
return respond(statusError, errJSON("unknown opcode")), nil
|
||||
}
|
||||
// If this peer already negotiated a session, the payload is
|
||||
// expected to be AEAD-sealed; open it before dispatch and seal
|
||||
// the response.
|
||||
@@ -225,7 +235,7 @@ func (s *Server) Register(n *zap.Node) {
|
||||
)
|
||||
return s.respondMaybeSealed(from, statusForbid, errJSON(decisionErr.Error())), nil
|
||||
}
|
||||
status, body, err := h(ctx, ident, innerReq)
|
||||
status, body, err := s.dispatch(ctx, ident, op, innerReq)
|
||||
if err != nil {
|
||||
s.log.Warn("kms.zap handler error (mux)", "ident", ident.String(), "op", Op(op).String(), "err", err)
|
||||
return s.respondMaybeSealed(from, statusError, errJSON(err.Error())), nil
|
||||
@@ -234,6 +244,31 @@ func (s *Server) Register(n *zap.Node) {
|
||||
})
|
||||
}
|
||||
|
||||
// dispatch is the single op→handler router shared by every transport
|
||||
// (the ZAP universal mux and the HTTP /v1/sdk surface). It runs AFTER
|
||||
// verifyAndAuthorize has proven the caller's identity and authority, so
|
||||
// it does not re-check auth. Unknown ops return statusError as
|
||||
// defence-in-depth — the authorizer has already rejected any op outside
|
||||
// the allowed set before dispatch is reached.
|
||||
func (s *Server) dispatch(ctx context.Context, ident Identity, op uint16, inner []byte) (byte, []byte, error) {
|
||||
switch op {
|
||||
case OpSecretGet:
|
||||
return s.handleGet(ctx, ident, inner)
|
||||
case OpSecretPut:
|
||||
return s.handlePut(ctx, ident, inner)
|
||||
case OpSecretList:
|
||||
return s.handleList(ctx, ident, inner)
|
||||
case OpSecretDelete:
|
||||
return s.handleDelete(ctx, ident, inner)
|
||||
case OpSign:
|
||||
return s.handleSign(ctx, ident, inner)
|
||||
case OpVerify:
|
||||
return s.handleVerify(ctx, ident, inner)
|
||||
default:
|
||||
return statusError, errJSON("unknown opcode"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// session returns the active hybrid session for a peer, or nil if the
|
||||
// peer has not run OpClientHello yet (forward-compat fallback).
|
||||
func (s *Server) session(peerID string) *kmszap.Session {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// sign.go — the OpSign / OpVerify threshold-key ops on the /v1/sdk
|
||||
// surface. Both ride the same signed Envelope as the secret opcodes and
|
||||
// run through the same verify→authorize→dispatch core: OpSign requires
|
||||
// the operator (write) authority, OpVerify the validator (read)
|
||||
// authority. The KMS process NEVER holds full key material — it hands a
|
||||
// verified request to a SignBackend that delegates to the luxfi/mpc
|
||||
// t-of-n cluster. A single node cannot produce a signature.
|
||||
|
||||
package zapserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// SignBackend is the threshold-signing surface OpSign / OpVerify
|
||||
// dispatch to. Deliberately narrow and transport-free: the zapserver
|
||||
// verifies the envelope and runs consensus authorization, then hands
|
||||
// the verified request here. Implementations delegate to the MPC
|
||||
// t-of-n cluster (luxfi/mpc); they hold no full private key.
|
||||
type SignBackend interface {
|
||||
// Sign produces a threshold signature over msg using the named
|
||||
// validator key. keyType is "bls" or "corona". The t-of-n MPC
|
||||
// protocol runs across the cluster; no single node can sign.
|
||||
Sign(ctx context.Context, validatorID, keyType string, msg []byte) (SignResult, error)
|
||||
// Verify checks sig against the validator's group public key. Pure
|
||||
// public-key operation — no threshold, no secret material.
|
||||
Verify(ctx context.Context, validatorID, keyType string, msg, sig []byte) (bool, error)
|
||||
}
|
||||
|
||||
// SignResult is the backend's signature output. Fields mirror the MPC
|
||||
// daemon's response: Signature for schemes that emit a single blob,
|
||||
// R/S for the (r,s) ECDSA pair.
|
||||
type SignResult struct {
|
||||
Signature string `json:"signature,omitempty"`
|
||||
R string `json:"r,omitempty"`
|
||||
S string `json:"s,omitempty"`
|
||||
}
|
||||
|
||||
// errSignerNotConfigured is returned in-band (statusError body) when a
|
||||
// sign/verify op arrives but no SignBackend was wired. Callers get a
|
||||
// parseable signal rather than a generic failure.
|
||||
var errSignerNotConfigured = errors.New("signing not configured")
|
||||
|
||||
// isValidKeyType gates the two supported validator key schemes. Anything
|
||||
// else is rejected before the backend is touched.
|
||||
func isValidKeyType(kt string) bool { return kt == "bls" || kt == "corona" }
|
||||
|
||||
type signReq struct {
|
||||
ValidatorID string `json:"validator_id"`
|
||||
KeyType string `json:"key_type"`
|
||||
Message string `json:"message"` // base64 of the bytes to sign
|
||||
}
|
||||
|
||||
// handleSign runs the threshold-sign op. Auth (operator/write authority)
|
||||
// has already been enforced by verifyAndAuthorize before this is reached.
|
||||
func (s *Server) handleSign(ctx context.Context, ident Identity, payload []byte) (byte, []byte, error) {
|
||||
if s.signer == nil {
|
||||
return statusError, errJSON(errSignerNotConfigured.Error()), nil
|
||||
}
|
||||
var req signReq
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return statusError, errJSON(err.Error()), nil
|
||||
}
|
||||
if req.ValidatorID == "" || !isValidKeyType(req.KeyType) {
|
||||
return statusError, errJSON("validator_id and key_type (bls|corona) required"), nil
|
||||
}
|
||||
msg, err := base64.StdEncoding.DecodeString(req.Message)
|
||||
if err != nil || len(msg) == 0 {
|
||||
return statusError, errJSON("message must be non-empty base64"), nil
|
||||
}
|
||||
res, err := s.signer.Sign(ctx, req.ValidatorID, req.KeyType, msg)
|
||||
if err != nil {
|
||||
return statusError, nil, err
|
||||
}
|
||||
// Audit: who signed what key — never the message or signature bytes.
|
||||
s.log.Info("kms.sdk sign", "ident", ident.String(), "validator", req.ValidatorID, "key_type", req.KeyType)
|
||||
b, _ := json.Marshal(res)
|
||||
return statusOK, b, nil
|
||||
}
|
||||
|
||||
type verifyReq struct {
|
||||
ValidatorID string `json:"validator_id"`
|
||||
KeyType string `json:"key_type"`
|
||||
Message string `json:"message"` // base64
|
||||
Signature string `json:"signature"` // base64
|
||||
}
|
||||
|
||||
// handleVerify runs the public-key verify op. Read authority (validator)
|
||||
// has already been enforced. No secret material is touched.
|
||||
func (s *Server) handleVerify(ctx context.Context, ident Identity, payload []byte) (byte, []byte, error) {
|
||||
if s.signer == nil {
|
||||
return statusError, errJSON(errSignerNotConfigured.Error()), nil
|
||||
}
|
||||
var req verifyReq
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return statusError, errJSON(err.Error()), nil
|
||||
}
|
||||
if req.ValidatorID == "" || !isValidKeyType(req.KeyType) {
|
||||
return statusError, errJSON("validator_id and key_type (bls|corona) required"), nil
|
||||
}
|
||||
msg, err := base64.StdEncoding.DecodeString(req.Message)
|
||||
if err != nil {
|
||||
return statusError, errJSON("message must be base64"), nil
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(req.Signature)
|
||||
if err != nil {
|
||||
return statusError, errJSON("signature must be base64"), nil
|
||||
}
|
||||
ok, err := s.signer.Verify(ctx, req.ValidatorID, req.KeyType, msg, sig)
|
||||
if err != nil {
|
||||
return statusError, nil, err
|
||||
}
|
||||
b, _ := json.Marshal(map[string]bool{"valid": ok})
|
||||
return statusOK, b, nil
|
||||
}
|
||||
Reference in New Issue
Block a user