test(zapserver): red-team proofs — identity impersonation + future-dated replay

Two failing adversarial tests that encode the SECURE invariants the
/v1/sdk envelope verifier currently violates:

- TestRedTeam_PubkeyDigestUnbound_Impersonation (CRITICAL): the verify
  path never rebinds env.ID.PubKey -> env.ID.Digest/NodeID, so an
  attacker with a throwaway ML-DSA-65 key and any public operator NodeID
  mints a signature accepted AS that operator. Test exfiltrates a seeded
  secret with a non-victim key. Turns green once verify recomputes
  NodeID/FullDigest via ids.NodeIDSchemeMLDSA65.DeriveMLDSA(pubkey).

- TestRedTeam_FutureDatedEnvelope_ReplayAfterTTL (HIGH): symmetric +/-5m
  freshness lets a future-dated envelope outlive its 6m nonce-ledger
  entry, replaying up to ~4m past first sight while still fresh. Turns
  green once DefaultNonceLedgerTTL >= 2*MaxClockSkew (or freshness is
  asymmetric).

Control TestRedTeam_ForgedNonMemberStillDenied passes — isolates the
break to member impersonation, not generic parse failure.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-03 09:54:08 -07:00
co-authored by Hanzo Dev
parent 44e392e273
commit e3ddfdf3ac
2 changed files with 273 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// redteam_impersonation_test.go — RED TEAM adversarial proof.
//
// FINDING (CRITICAL): the envelope verifier binds the authorized NodeID to
// the attacker-supplied env.ID.Digest and checks the signature against the
// attacker-supplied env.ID.PubKey, but NEVER checks that the digest is the
// canonical SHAKE256-384 commitment to that pubkey
// (ids.NodeIDScheme.DeriveMLDSA). Because pubkey and digest are unbound, an
// attacker who controls their OWN throwaway ML-DSA-65 key and knows any
// authorized NodeID (a PUBLIC 20-byte identifier) can mint a signature that
// verifies AS THAT NodeID — full impersonation of any validator/operator.
//
// This test encodes the SECURE invariant: a signature-valid envelope whose
// pubkey does NOT derive the claimed NodeID MUST be rejected. It FAILS on the
// current tree (proving the exploit) and passes once the verify path rebinds
// pubkey→digest.
package zapserver
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"testing"
"time"
mldsa "github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
"github.com/luxfi/kms/pkg/envelope"
)
// forgingSigner signs an envelope's canonical bytes binding an ARBITRARY
// FullDigest (the victim's) into the prehash, using an attacker-controlled
// ML-DSA-65 key. This is exactly what a real attacker does off-line: the
// digest construction (envelope.Digest) and domain (envelope.EnvelopeDomain)
// are public, and mldsa signing needs no secret beyond the attacker's own key.
type forgingSigner struct {
priv *mldsa.PrivateKey
boundDigest ids.FullDigest
}
func (f *forgingSigner) Sign(canonical []byte) ([]byte, error) {
d := envelope.Digest(f.boundDigest, canonical)
return f.priv.SignCtx(nil, d, []byte(envelope.EnvelopeDomain))
}
func TestRedTeam_PubkeyDigestUnbound_Impersonation(t *testing.T) {
// Victim: a REAL operator identity. Only its NodeID (public, 20 bytes)
// is known to the attacker — not its private key, not its full digest.
victim := newIdentity(t, "hanzo/kms-operator")
defer victim.Wipe()
// The KMS trusts victim.NodeID for both read and write.
s := newTestServer(t, []ids.NodeID{victim.NodeID}, []ids.NodeID{victim.NodeID})
seed(t, s, "hanzo/kms-operator", "api-key", "prod", "sk_live_TOP_SECRET")
now := time.Unix(1_717_200_000, 0)
// Attacker holds ONLY a throwaway ML-DSA-65 key and the victim's public
// NodeID. They reconstruct a 48-byte digest as NodeID||zeros — the
// trailing 28 bytes are never checked, so the victim's real full digest
// is NOT needed.
attackerPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("attacker keygen: %v", err)
}
attackerPub := attackerPriv.PublicKey.Bytes()
var forgedDigest ids.FullDigest
copy(forgedDigest[:], victim.NodeID[:]) // NodeID || zero-padding
// Sanity: the attacker's pubkey is NOT the victim's — this is a genuine
// key mismatch, not a coincidental match.
if base64.StdEncoding.EncodeToString(attackerPub) ==
base64.StdEncoding.EncodeToString(victim.PublicKey) {
t.Fatalf("attacker/victim pubkeys collided — test is meaningless")
}
hdr := envelope.IdentityHeader{
NodeID: victim.NodeID, // claim the victim's identity
FullDigest: forgedDigest, // NodeID||zeros, prefix == victim NodeID
ServicePath: "attacker/evil", // arbitrary — never verified vs key
PublicKey: attackerPub, // ATTACKER's key verifies the sig
}
inner := buildInner(t, getReq{Path: "hanzo/kms-operator", Name: "api-key", Env: "prod"})
forged, err := envelope.Build(hdr, &forgingSigner{priv: attackerPriv, boundDigest: forgedDigest},
OpSecretGet, inner, "attacker-nonce-1", now)
if err != nil {
t.Fatalf("build forged envelope: %v", err)
}
raw, err := json.Marshal(forged)
if err != nil {
t.Fatalf("marshal forged envelope: %v", err)
}
ident, payload, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet)
if err != nil {
// SECURE: verifier rebound pubkey→digest and rejected the forgery.
t.Logf("GUARD HOLDS: forged-identity envelope rejected: %v", err)
return
}
// VULNERABLE: the forgery was accepted as the victim. Prove full impact
// by exfiltrating the victim-authorized secret with the attacker's key.
st, body, herr := s.handleGet(context.Background(), ident, payload)
pt := ""
if st == statusOK && herr == nil {
var gr getResp
_ = json.Unmarshal(body, &gr)
if dec, e := base64.StdEncoding.DecodeString(gr.Value); e == nil {
pt = string(dec)
}
}
t.Errorf("CRITICAL IMPERSONATION: forged envelope signed by an attacker "+
"key was accepted as victim NodeID=%s (verified ident=%s); "+
"handleGet status=0x%02X exfiltrated secret=%q. The attacker never "+
"held the victim's private key — only its public NodeID. Fix: the "+
"verify path MUST recompute NodeID/FullDigest from env.ID.PubKey via "+
"ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ServiceChainID, pubkey) and "+
"reject unless it equals env.ID.Digest (and env.ID.Node).",
victim.NodeID, ident.String(), st, pt)
}
// TestRedTeam_ForgedNonMemberStillDenied is a control: a forged identity for
// a NodeID that is NOT in any authority set must be denied regardless. This
// isolates the impersonation break (targeting a MEMBER) from ordinary
// non-membership denial.
func TestRedTeam_ForgedNonMemberStillDenied(t *testing.T) {
member := newIdentity(t, "hanzo/kms-operator")
defer member.Wipe()
s := newTestServer(t, []ids.NodeID{member.NodeID}, []ids.NodeID{member.NodeID})
now := time.Unix(1_717_200_000, 0)
attackerPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("keygen: %v", err)
}
// A random NodeID that is in no authority set.
var strangerDigest ids.FullDigest
if _, err := rand.Read(strangerDigest[:]); err != nil {
t.Fatalf("rand: %v", err)
}
var strangerNode ids.NodeID
copy(strangerNode[:], strangerDigest[:ids.NodeIDLen])
hdr := envelope.IdentityHeader{
NodeID: strangerNode,
FullDigest: strangerDigest,
ServicePath: "attacker/evil",
PublicKey: attackerPriv.PublicKey.Bytes(),
}
inner := buildInner(t, getReq{Path: "x", Name: "y", Env: "prod"})
env, err := envelope.Build(hdr, &forgingSigner{priv: attackerPriv, boundDigest: strangerDigest},
OpSecretGet, inner, "stranger-nonce", now)
if err != nil {
t.Fatalf("build: %v", err)
}
raw, _ := json.Marshal(env)
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err == nil {
t.Fatalf("non-member forged identity must be denied")
}
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// redteam_replay_ttl_test.go — RED TEAM adversarial proof.
//
// FINDING (HIGH): the nonce ledger TTL (DefaultNonceLedgerTTL = MaxClockSkew
// + 1m = 6m) is SHORTER than the maximum freshness lifetime of an envelope.
// The freshness check is SYMMETRIC (|now - ts| <= MaxClockSkew), so an
// envelope may be timestamped up to +MaxClockSkew in the FUTURE and still be
// accepted. Such an envelope stays fresh for up to 2*MaxClockSkew (10m) after
// it is first seen, but its ledger entry ages out after only 6m — leaving a
// window (up to 4m) in which the SAME envelope replays successfully because
// the ledger has GC'd/expired the nonce while the wall-clock check still
// passes.
//
// The nonce_ledger.go comment claims "Once an entry has aged past TTL, the
// clock-skew check would reject any envelope bearing the matching timestamp
// anyway." That reasoning is FALSE for future-dated envelopes: the entry ages
// relative to FIRST-SEEN wall-clock, not relative to ts.
//
// This test encodes the SECURE invariant: an envelope accepted once must not
// be accepted a second time while it is still within the freshness window. It
// FAILS on the current tree (TTL under-provisioned) and passes once
// DefaultNonceLedgerTTL >= 2*MaxClockSkew (+ GC margin), or the freshness
// window is made asymmetric with a tiny future allowance.
package zapserver
import (
"context"
"crypto/rand"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/kms/pkg/store"
"github.com/luxfi/log"
badger "github.com/luxfi/zapdb"
)
func TestRedTeam_FutureDatedEnvelope_ReplayAfterTTL(t *testing.T) {
victim := newIdentity(t, "hanzo/auto")
defer victim.Wipe()
// Server with a MUTABLE clock so we can advance past the nonce TTL while
// keeping the (future-dated) envelope inside the freshness window.
base := int64(1_717_200_000)
var nowUnix int64 = base
nowFn := func() time.Time { return time.Unix(atomic.LoadInt64(&nowUnix), 0) }
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)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("rand: %v", err)
}
authz, err := NewInProcessAuthorizer(InProcessAuthorizerConfig{
Validators: NewStaticAuthorityProvider([]ids.NodeID{victim.NodeID}),
Operator: NewStaticAuthorityProvider(nil),
})
if err != nil {
t.Fatalf("authorizer: %v", err)
}
// Default nonce ledger (TTL = MaxClockSkew + 1m = 6m).
s := New(Config{
Store: store.NewSecretStore(db),
MasterKey: mk,
Authorizer: authz,
Logger: log.NewNoOpLogger(),
Now: nowFn,
})
seed(t, s, "hanzo/auto", "api-key", "prod", "secret-1")
skew := int64(EnvelopeMaxClockSkew / time.Second) // 300s
ttl := int64((EnvelopeMaxClockSkew + time.Minute) / time.Second) // 360s
// Envelope timestamped ~max-future (base + 290s, inside the +300s skew).
ts := time.Unix(base+skew-10, 0)
raw := signedEnvelopeBytes(t, victim, OpSecretGet, buildInner(t, getReq{
Path: "hanzo/auto", Name: "api-key", Env: "prod",
}), ts, "future-nonce")
// First sighting at now == base. d = base - (base+290) = -290, |d| < 300 → fresh.
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err != nil {
t.Fatalf("first (legit) delivery should be accepted: %v", err)
}
// Advance the clock PAST the nonce TTL but keep the envelope fresh.
// now2 = base + 365s: ledger entry (stored at base) is now 365s old > 360s
// TTL → treated as expired. Freshness: d = 365 - 290 = 75, |d| < 300 → still fresh.
atomic.StoreInt64(&nowUnix, base+ttl+5)
_, _, err = s.verifyAndAuthorize(context.Background(), raw, OpSecretGet)
if err != nil {
// SECURE: replay rejected even after the TTL boundary.
t.Logf("GUARD HOLDS: future-dated replay rejected after TTL: %v", err)
return
}
t.Errorf("HIGH REPLAY: a future-dated envelope (ts=base+%ds) accepted at "+
"now=base was REPLAYED successfully at now=base+%ds — still inside the "+
"freshness window (|now-ts|=%ds <= %ds) but past the nonce TTL (%ds). "+
"Fix: DefaultNonceLedgerTTL must be >= 2*MaxClockSkew (+GC margin), or "+
"make freshness asymmetric (tiny future allowance).",
skew-10, ttl+5, (base+ttl+5)-(base+skew-10), skew, ttl)
}