mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 05:54:18 +00:00
* 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>
265 lines
7.7 KiB
Go
265 lines
7.7 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// consensus_e2e_test.go — end-to-end test of the consensus-native auth
|
|
// flow over real ZAP transport.
|
|
//
|
|
// What this proves:
|
|
//
|
|
// 1. A client built with a mnemonic-derived ServiceIdentity (luxfi/keys)
|
|
// signs every envelope with ML-DSA-65.
|
|
// 2. The kmsd verifies the envelope, asks the ConsensusAuthorizer (an
|
|
// in-process Polaris-cert-attested validator set), and dispatches
|
|
// to the SecretStore on Allow.
|
|
// 3. Identities outside the validator set are rejected at the wire
|
|
// with statusForbid.
|
|
// 4. The full chain — handshake → envelope sign → consensus check →
|
|
// secret release — works under a real luxfi/zap Node.
|
|
//
|
|
// This is the "spin up a luxd, mock-or-real Polaris cert authorization,
|
|
// kmsd with consensus pointer, kmsclient with mnemonic" gate from the
|
|
// LP roadmap. The Polaris cert is mocked in-process via the consensus
|
|
// authority providers; the wire path is real.
|
|
|
|
package zapserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"net"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/keys"
|
|
"github.com/luxfi/kms/pkg/envelope"
|
|
"github.com/luxfi/kms/pkg/store"
|
|
"github.com/luxfi/kms/pkg/zapclient"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/zap"
|
|
badger "github.com/luxfi/zapdb"
|
|
)
|
|
|
|
// bootConsensusNativeServer spins up a real luxfi/zap.Node backed by
|
|
// an InProcessAuthorizer whose Validator + Operator authorities are
|
|
// caller-supplied. Same shape as the boot path on prod (kmsd) — only
|
|
// the authority data is in-memory instead of luxd-RPC sourced.
|
|
func bootConsensusNativeServer(t *testing.T, validators, operators []ids.NodeID) (addr string) {
|
|
t.Helper()
|
|
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
_ = l.Close()
|
|
|
|
n := zap.NewNode(zap.NodeConfig{
|
|
NodeID: "kms-e2e-" + strconv.Itoa(port),
|
|
ServiceType: "_kms._tcp",
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
if err := n.Start(); err != nil {
|
|
t.Fatalf("zap.Node start: %v", err)
|
|
}
|
|
t.Cleanup(func() { n.Stop() })
|
|
|
|
opts := badger.DefaultOptions("").WithInMemory(true)
|
|
db, err := badger.Open(opts)
|
|
if err != nil {
|
|
t.Fatalf("zapdb open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
mk := make([]byte, 32)
|
|
if _, err := rand.Read(mk); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
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("sk_live_real"))
|
|
if err != nil {
|
|
t.Fatalf("store.Seal: %v", err)
|
|
}
|
|
if err := secStore.Put(sec); err != nil {
|
|
t.Fatalf("store.Put: %v", err)
|
|
}
|
|
|
|
authz, err := NewInProcessAuthorizer(InProcessAuthorizerConfig{
|
|
Validators: NewStaticAuthorityProvider(validators),
|
|
Operator: NewStaticAuthorityProvider(operators),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authorizer: %v", err)
|
|
}
|
|
srv := New(Config{
|
|
Store: secStore,
|
|
MasterKey: mk,
|
|
Authorizer: authz,
|
|
Logger: log.NewNoOpLogger(),
|
|
})
|
|
srv.Register(n)
|
|
|
|
return "127.0.0.1:" + strconv.Itoa(port)
|
|
}
|
|
|
|
// TestConsensusE2E_ValidatorReadsSecret — the canonical happy path:
|
|
// service identity is in the validator authority, signs envelopes
|
|
// over ZAP, server verifies + authorizes + releases.
|
|
func TestConsensusE2E_ValidatorReadsSecret(t *testing.T) {
|
|
// Service mnemonic is provisioned out-of-band by the kms-operator
|
|
// (k8s Secret). Same mnemonic + path on every pod replica → same
|
|
// NodeID byte-for-byte.
|
|
const serviceMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
const servicePath = "hanzo/commerce"
|
|
|
|
ident, err := keys.NewServiceIdentity(serviceMnemonic, servicePath)
|
|
if err != nil {
|
|
t.Fatalf("identity: %v", err)
|
|
}
|
|
defer ident.Wipe()
|
|
|
|
// Consensus authority: the operator's snapshot says this NodeID is
|
|
// in the validator set (read-authorised).
|
|
addr := bootConsensusNativeServer(t,
|
|
[]ids.NodeID{ident.NodeID},
|
|
nil,
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
hdr := envelope.IdentityHeader{
|
|
NodeID: ident.NodeID,
|
|
FullDigest: ident.FullDigest,
|
|
ServicePath: ident.ServicePath,
|
|
PublicKey: ident.PublicKey,
|
|
}
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "test-client",
|
|
PeerAddr: addr,
|
|
DefaultPath: "hanzo/commerce",
|
|
IdentityHeader: hdr,
|
|
Signer: ident,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
value, err := c.Get(ctx, "api-key", "prod")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if value != "sk_live_real" {
|
|
t.Fatalf("Get value mismatch: got %q want %q", value, "sk_live_real")
|
|
}
|
|
}
|
|
|
|
// TestConsensusE2E_NonValidatorDenied — an identity NOT in the
|
|
// validator authority is rejected at the wire. statusForbid surfaces
|
|
// as zapclient.ErrForbidden.
|
|
func TestConsensusE2E_NonValidatorDenied(t *testing.T) {
|
|
const serviceMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
|
|
knownIdent, err := keys.NewServiceIdentity(serviceMnemonic, "hanzo/known")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer knownIdent.Wipe()
|
|
|
|
strangerIdent, err := keys.NewServiceIdentity(serviceMnemonic, "stranger/service")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer strangerIdent.Wipe()
|
|
|
|
// Authority lists only the known identity. The stranger is
|
|
// outside the validator set.
|
|
addr := bootConsensusNativeServer(t,
|
|
[]ids.NodeID{knownIdent.NodeID},
|
|
nil,
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
hdr := envelope.IdentityHeader{
|
|
NodeID: strangerIdent.NodeID,
|
|
FullDigest: strangerIdent.FullDigest,
|
|
ServicePath: strangerIdent.ServicePath,
|
|
PublicKey: strangerIdent.PublicKey,
|
|
}
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "test-client-stranger",
|
|
PeerAddr: addr,
|
|
DefaultPath: "hanzo/commerce",
|
|
IdentityHeader: hdr,
|
|
Signer: strangerIdent,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if _, err := c.Get(ctx, "api-key", "prod"); err == nil {
|
|
t.Fatalf("stranger Get must be rejected (got nil error)")
|
|
}
|
|
}
|
|
|
|
// TestConsensusE2E_OperatorWritesSecret — a NodeID in BOTH validator
|
|
// and operator authorities may Put. Writes are the kms-operator's
|
|
// privilege; read-only validators never touch the store.
|
|
func TestConsensusE2E_OperatorWritesSecret(t *testing.T) {
|
|
const serviceMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
|
|
opIdent, err := keys.NewServiceIdentity(serviceMnemonic, "hanzo/kms-operator")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer opIdent.Wipe()
|
|
|
|
addr := bootConsensusNativeServer(t,
|
|
[]ids.NodeID{opIdent.NodeID},
|
|
[]ids.NodeID{opIdent.NodeID},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
hdr := envelope.IdentityHeader{
|
|
NodeID: opIdent.NodeID,
|
|
FullDigest: opIdent.FullDigest,
|
|
ServicePath: opIdent.ServicePath,
|
|
PublicKey: opIdent.PublicKey,
|
|
}
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "test-client-operator",
|
|
PeerAddr: addr,
|
|
DefaultPath: "hanzo/commerce",
|
|
IdentityHeader: hdr,
|
|
Signer: opIdent,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if err := c.PutAt(ctx, "hanzo/commerce", "rotation-key", "prod", "newSecret"); err != nil {
|
|
t.Fatalf("operator Put: %v", err)
|
|
}
|
|
got, err := c.GetAt(ctx, "hanzo/commerce", "rotation-key", "prod")
|
|
if err != nil {
|
|
t.Fatalf("operator Get-after-Put: %v", err)
|
|
}
|
|
if got != "newSecret" {
|
|
t.Fatalf("round-trip mismatch: got %q want newSecret", got)
|
|
}
|
|
}
|