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:
z
2026-07-03 10:37:07 -07:00
committed by GitHub
co-authored by Hanzo Dev
parent f9d4832cdf
commit 343872f5da
11 changed files with 1170 additions and 50 deletions
+37
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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=
+108
View File
@@ -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)
+174
View File
@@ -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")
}
}
+16 -4
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+135
View File
@@ -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})
}
+470
View File
@@ -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
View File
@@ -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 {
+121
View File
@@ -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
}