mpc: rip zapauth bearer-token gate (decomplect)

Trust at the network boundary (NetworkPolicy + ZAP wire), not in the
application envelope. Companion to luxfi/keys v1.2.0 (LoadMnemonic
identity-free) and luxfi/kms v1.12.0 (iamclient gone).

Killed:
  - pkg/zapauth/     (JWKS verifier + AuthHelloFrame parser)
  - pkg/api/zap_kms_server.go: KMSZapConfig, auth+authRequired fields,
    handleAuthHello, respondAuthHello, checkAuth, StartKMSZAPWith
  - cmd/mpcd: ZAP_JWKS_URL, ZAP_EXPECTED_ISS, ZAP_EXPECTED_AUDIENCES,
    ZAP_AUTH_REQUIRED — all gone.

Note: mpc still has a pre-existing build block from upstream luxfi/geth
v1.16.79 → precompile pseudo-version (unrelated to this change).
This commit is contained in:
2026-06-06 21:06:17 -07:00
parent be3d9f3ee7
commit a326cc6418
5 changed files with 10 additions and 1314 deletions
+4 -39
View File
@@ -50,7 +50,6 @@ import (
"github.com/luxfi/mpc/pkg/mpc"
"github.com/luxfi/mpc/pkg/transport"
"github.com/luxfi/mpc/pkg/types"
"github.com/luxfi/mpc/pkg/zapauth"
)
const (
@@ -732,46 +731,12 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
// Start KMS-facing ZAP server. KMS dials this for threshold-signing
// requests after running the ML-KEM-768 hybrid handshake. Empty
// --kms-zap-listen disables it (e.g., for legacy compose stacks that
// only need the HTTP dashboard). Default :9653 matches the
// Liquid operator's zapPort so no LiquidMPC CR change is needed.
//
// Optional bearer-token gate (LP-103, v1.14.0):
// ZAP_JWKS_URL — JWKS endpoint (default cluster-internal IAM)
// ZAP_EXPECTED_ISS — expected JWT iss
// ZAP_EXPECTED_AUDIENCES — comma-separated allow-list (e.g. "")
// ZAP_AUTH_REQUIRED — "true" to reject unauthenticated peers
// Default :9653 matches the Liquid operator's zapPort so no
// LiquidMPC CR change is needed. Trust at the network boundary
// (NetworkPolicy + ZAP wire).
kmsZapAddr := c.String("kms-zap-listen")
if kmsZapAddr != "" {
kmsZapCfg := mpcapi.KMSZapConfig{}
if jwksURL := os.Getenv("ZAP_JWKS_URL"); jwksURL != "" {
expIss := os.Getenv("ZAP_EXPECTED_ISS")
audCSV := os.Getenv("ZAP_EXPECTED_AUDIENCES")
var auds []string
for _, a := range strings.Split(audCSV, ",") {
if a = strings.TrimSpace(a); a != "" {
auds = append(auds, a)
}
}
if expIss == "" || len(auds) == 0 {
logger.Error("ZAP auth requested but ZAP_EXPECTED_ISS or ZAP_EXPECTED_AUDIENCES missing",
fmt.Errorf("incomplete config"),
"jwksUrl", jwksURL, "iss", expIss, "auds", audCSV)
} else {
v, vErr := zapauth.NewVerifier(zapauth.Config{
JWKSURL: jwksURL, ExpectedIssuer: expIss, ExpectedAudiences: auds,
})
if vErr != nil {
logger.Error("ZAP auth verifier init failed", vErr,
"jwksUrl", jwksURL, "iss", expIss)
} else {
kmsZapCfg.Verifier = v
kmsZapCfg.AuthRequired = strings.EqualFold(os.Getenv("ZAP_AUTH_REQUIRED"), "true")
logger.Info("ZAP auth verifier configured",
"iss", expIss, "auds", auds, "required", kmsZapCfg.AuthRequired)
}
}
}
kmsZap, kmsZapErr := mpcapi.StartKMSZAPWith(mpcBackend, fmt.Sprintf("mpcd-kms-zap-%s", nodeID), kmsZapAddr, kmsZapCfg)
kmsZap, kmsZapErr := mpcapi.StartKMSZAP(mpcBackend, fmt.Sprintf("mpcd-kms-zap-%s", nodeID), kmsZapAddr)
if kmsZapErr != nil {
logger.Error("Failed to start KMS ZAP server", kmsZapErr, "addr", kmsZapAddr)
} else {
+6 -131
View File
@@ -18,7 +18,6 @@ import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
@@ -26,7 +25,6 @@ import (
"github.com/luxfi/zap"
kmszap "github.com/luxfi/mpc/pkg/zap"
"github.com/luxfi/mpc/pkg/zapauth"
)
// KMS-facing opcode constants. These are the contract with
@@ -56,30 +54,9 @@ type KMSZapServer struct {
// is on by default; we never opt out unilaterally.
localCaps uint16
// auth, when non-nil, validates a JWKS-bearer JWT presented in
// OpAuthHello (zapauth.OpAuthHello = 0x00EF) BEFORE the hybrid PQ
// handshake. The peer's verified claims are attached to the
// connection by ZAP peer ID and looked up on every subsequent
// dispatch. nil means "no JWT verification" — secure-by-default
// requires this to be non-nil; the wrapper in StartKMSZAP enforces
// that behavior gating off ZAP_AUTH_REQUIRED.
auth *zapauth.Verifier
authRequired bool
node *zap.Node
}
// KMSZapConfig configures the optional zapauth bearer-token gate on the
// KMS-facing ZAP server. Fields are mutually consistent: a non-nil
// Verifier paired with AuthRequired=true rejects unauthenticated peers
// at OpClientHello; with AuthRequired=false (the v1.14.0 default) the
// connection still completes and a warning logs so operators can roll
// out KMS-side bearer minting before flipping the flag.
type KMSZapConfig struct {
Verifier *zapauth.Verifier
AuthRequired bool
}
// StartKMSZAP creates a luxfi/zap.Node bound to listenAddr and registers KMS
// opcode handlers. Returns the server (so callers can stop it on shutdown).
//
@@ -87,25 +64,12 @@ type KMSZapConfig struct {
// principal for sign/keygen requests. Every MPC StatefulSet pod gets a unique
// ID (mpc-kms-zap-<pod>) so KMS audit rows can pin a request to a node.
//
// This wrapper preserves the v1.13.x signature for callers that have not
// yet wired zapauth. New deployments should call StartKMSZAPWith to pass
// a zapauth.Verifier.
// Trust is enforced at the network boundary (NetworkPolicy + ZAP wire).
func StartKMSZAP(backend MPCBackend, nodeID, listenAddr string) (*KMSZapServer, error) {
return StartKMSZAPWith(backend, nodeID, listenAddr, KMSZapConfig{})
}
// StartKMSZAPWith is the configurable form of StartKMSZAP. cfg.Verifier
// gates OpClientHello on a verified JWT in OpAuthHello when
// cfg.AuthRequired is true. cfg.Verifier without AuthRequired logs but
// still serves unauthenticated peers — used during the rollout window.
func StartKMSZAPWith(backend MPCBackend, nodeID, listenAddr string, cfg KMSZapConfig) (*KMSZapServer, error) {
port, err := parsePort(listenAddr)
if err != nil {
return nil, fmt.Errorf("zap: parse listen %q: %w", listenAddr, err)
}
if cfg.AuthRequired && cfg.Verifier == nil {
return nil, fmt.Errorf("zap: AuthRequired=true requires a non-nil Verifier")
}
node := zap.NewNode(zap.NodeConfig{
NodeID: nodeID,
@@ -115,21 +79,12 @@ func StartKMSZAPWith(backend MPCBackend, nodeID, listenAddr string, cfg KMSZapCo
})
s := &KMSZapServer{
backend: backend,
sessions: make(map[string]*kmszap.Session),
localCaps: kmszap.CapMLKEM768,
auth: cfg.Verifier,
authRequired: cfg.AuthRequired,
node: node,
backend: backend,
sessions: make(map[string]*kmszap.Session),
localCaps: kmszap.CapMLKEM768,
node: node,
}
// Pre-handshake bearer token. When auth is configured, OpAuthHello
// MUST precede OpClientHello. The handler verifies + attaches claims
// keyed by ZAP peer ID. Forward-compat: we register the handler even
// when auth is nil so peers that send the frame don't get an unknown-
// opcode error during the rollout window.
node.Handle(zapauth.OpAuthHello, s.handleAuthHello)
// Application-layer hybrid handshake. Distinct opcode so a client can
// negotiate before sending any threshold-op payload.
node.Handle(kmszap.OpClientHello, s.handleHandshake)
@@ -149,16 +104,9 @@ func StartKMSZAPWith(backend MPCBackend, nodeID, listenAddr string, cfg KMSZapCo
if err := node.Start(); err != nil {
return nil, fmt.Errorf("zap: start node: %w", err)
}
authMode := "off"
switch {
case cfg.AuthRequired:
authMode = "required"
case cfg.Verifier != nil:
authMode = "advisory"
}
logger.Info("KMS ZAP server started",
"addr", listenAddr, "nodeID", nodeID,
"caps", "X25519+ML-KEM-768", "auth", authMode)
"caps", "X25519+ML-KEM-768")
return s, nil
}
@@ -182,9 +130,6 @@ func (s *KMSZapServer) handle(op handlerFn) zap.Handler {
return s.respond(from, 0, errBody("empty payload")), nil
}
opcode := binary.LittleEndian.Uint16(raw[:2])
if !s.checkAuth(from, opcode) {
return s.respond(from, opcode, errBody("auth required")), nil
}
payload := raw[2:]
// Session-sealed payloads are AEAD-opened before dispatch.
if sess := s.session(from); sess != nil {
@@ -212,18 +157,9 @@ func (s *KMSZapServer) muxByOpcode(ctx context.Context, from string, msg *zap.Me
return s.respond(from, 0, errBody("empty payload")), nil
}
op := binary.LittleEndian.Uint16(raw[:2])
if op == zapauth.OpAuthHello {
return s.respondAuthHello(ctx, from, raw[2:]), nil
}
if op == kmszap.OpClientHello {
if !s.checkAuth(from, op) {
return s.respond(from, kmszap.OpServerHello, errBody("auth required")), nil
}
return s.respondHandshake(from, raw[2:]), nil
}
if !s.checkAuth(from, op) {
return s.respond(from, op, errBody("auth required")), nil
}
dispatch := map[uint16]handlerFn{
OpKMSStatus: s.handleStatus,
OpKMSKeygen: s.handleKeygen,
@@ -252,67 +188,6 @@ func (s *KMSZapServer) muxByOpcode(ctx context.Context, from string, msg *zap.Me
return s.respond(from, op, body), nil
}
// checkAuth gates an opcode dispatch on the auth state of the peer.
// Returns true when the peer may proceed: either auth is disabled, or
// the peer presented a valid JWT in OpAuthHello. Logs and returns false
// when authRequired is on and the peer is unauthenticated.
func (s *KMSZapServer) checkAuth(from string, op uint16) bool {
if s.auth == nil || !s.authRequired {
if s.auth != nil {
if _, ok := s.auth.Lookup(from); !ok {
logger.Warn("kms-zap unauthenticated peer (advisory mode)",
"from", from, "op", fmt.Sprintf("0x%04x", op))
}
}
return true
}
if _, ok := s.auth.Lookup(from); !ok {
logger.Warn("kms-zap rejecting unauthenticated peer",
"from", from, "op", fmt.Sprintf("0x%04x", op))
return false
}
return true
}
// handleAuthHello is the per-opcode handler for OpAuthHello. The body
// shape is opcode(2 LE) || zapauth.AuthHelloFrame.
func (s *KMSZapServer) handleAuthHello(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
raw := extractPayload(msg)
if len(raw) < 2 {
return s.respond(from, zapauth.OpAuthHello, errBody("empty auth payload")), nil
}
return s.respondAuthHello(ctx, from, raw[2:]), nil
}
// respondAuthHello validates the bearer token in the AuthHello frame
// and attaches the verified claims to the peer ID. Returns a tiny JSON
// body with {"ok":true} on success or {"error":"..."} on failure.
func (s *KMSZapServer) respondAuthHello(ctx context.Context, from string, framed []byte) *zap.Message {
frame, err := zapauth.UnmarshalAuthHello(framed)
if err != nil {
logger.Warn("kms-zap auth hello parse failed", "from", from, "err", err)
return s.respond(from, zapauth.OpAuthHello, errBody(err.Error()))
}
if s.auth == nil {
// No verifier configured. Accept the frame so a forward-rolling
// KMS client doesn't trip on a backwards MPC, but log it.
logger.Info("kms-zap auth hello received but verifier disabled",
"from", from, "tokenBytes", len(frame.Token))
ok, _ := json.Marshal(map[string]bool{"ok": true})
return s.respond(from, zapauth.OpAuthHello, ok)
}
claims, err := s.auth.Verify(ctx, frame.Token)
if err != nil {
logger.Warn("kms-zap auth verify failed", "from", from, "err", err)
return s.respond(from, zapauth.OpAuthHello, errBody(err.Error()))
}
s.auth.AttachClaims(from, *claims)
logger.Info("kms-zap auth hello accepted",
"from", from, "sub", claims.Subject, "iss", claims.Issuer)
ok, _ := json.Marshal(map[string]bool{"ok": true})
return s.respond(from, zapauth.OpAuthHello, ok)
}
// extractPayload pulls the application bytes out of a ZAP message,
// handling both the structured-Object form and the raw-bytes-after-header
// form used by zap.Builder.WriteBytes.
-250
View File
@@ -1,250 +0,0 @@
// Integration tests for the zapauth bearer-on-handshake gate.
//
// Spins a real KMSZapServer with a Verifier configured against a
// self-contained ES256 JWKS endpoint, dials it as a synthetic client,
// and exercises the AuthHello → ClientHello → Sign sequence end to end.
package api
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/luxfi/mpc/pkg/zapauth"
"github.com/luxfi/zap"
)
type authTestEnv struct {
jwksServer *httptest.Server
priv *ecdsa.PrivateKey
kid string
}
func newAuthTestEnv(t *testing.T) *authTestEnv {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa: %v", err)
}
jwk := map[string]any{
"kty": "EC", "kid": "kid-1", "alg": "ES256", "use": "sig", "crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(priv.PublicKey.X.Bytes()),
"y": base64.RawURLEncoding.EncodeToString(priv.PublicKey.Y.Bytes()),
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
}))
t.Cleanup(srv.Close)
return &authTestEnv{jwksServer: srv, priv: priv, kid: "kid-1"}
}
func (e *authTestEnv) sign(t *testing.T, claims map[string]any) string {
t.Helper()
hdr := map[string]any{"alg": "ES256", "typ": "JWT", "kid": e.kid}
hb, _ := json.Marshal(hdr)
cb, _ := json.Marshal(claims)
signing := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(cb)
digest := sha256.Sum256([]byte(signing))
r, s, err := ecdsa.Sign(rand.Reader, e.priv, digest[:])
if err != nil {
t.Fatalf("ecdsa sign: %v", err)
}
rb, sb := r.Bytes(), s.Bytes()
out := make([]byte, 64)
copy(out[32-len(rb):32], rb)
copy(out[64-len(sb):], sb)
_ = big.NewInt
return signing + "." + base64.RawURLEncoding.EncodeToString(out)
}
func newVerifier(t *testing.T, env *authTestEnv) *zapauth.Verifier {
t.Helper()
v, err := zapauth.NewVerifier(zapauth.Config{
JWKSURL: env.jwksServer.URL,
ExpectedIssuer: "https://iam.test.local",
ExpectedAudiences: []string{""},
})
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
return v
}
func startServerWithAuth(t *testing.T, v *zapauth.Verifier, required bool) (*KMSZapServer, string, func()) {
t.Helper()
addr := pickPort(t)
srv, err := StartKMSZAPWith(&stubBackend{}, "test-server-auth", addr, KMSZapConfig{
Verifier: v, AuthRequired: required,
})
if err != nil {
t.Fatalf("StartKMSZAPWith: %v", err)
}
time.Sleep(50 * time.Millisecond)
return srv, addr, func() { srv.Stop() }
}
func sendAuthHello(t *testing.T, client *zap.Node, peer string, token string) (uint16, []byte) {
t.Helper()
frame, err := zapauth.MarshalAuthHello(&zapauth.AuthHelloFrame{Version: zapauth.CurrentFrameVersion, Token: token})
if err != nil {
t.Fatalf("MarshalAuthHello: %v", err)
}
body := make([]byte, 2+len(frame))
binary.LittleEndian.PutUint16(body[:2], zapauth.OpAuthHello)
copy(body[2:], frame)
b := zap.NewBuilder(len(body) + 64)
b.WriteBytes(body)
msg, err := zap.Parse(b.Finish())
if err != nil {
t.Fatalf("parse: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.Call(ctx, peer, msg)
if err != nil {
t.Fatalf("Call AuthHello: %v", err)
}
raw := resp.Bytes()
rb := raw[zap.HeaderSize:]
return binary.LittleEndian.Uint16(rb[:2]), rb[2:]
}
// TestKMSZAP_Auth_RequiredHappyPath: AuthRequired=true, valid JWT,
// then sign request succeeds.
func TestKMSZAP_Auth_RequiredHappyPath(t *testing.T) {
env := newAuthTestEnv(t)
v := newVerifier(t, env)
_, addr, stop := startServerWithAuth(t, v, true)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
tok := env.sign(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
op, body := sendAuthHello(t, client, peer, tok)
if op != zapauth.OpAuthHello {
t.Fatalf("auth resp opcode: got 0x%04x want 0x%04x", op, zapauth.OpAuthHello)
}
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("auth resp body: got %s", string(body))
}
// Now a sign call should succeed.
respOp, signBody := callOp(t, client, peer, OpKMSSign, kmsZapSignRequest{
VaultID: "v1", WalletID: "w1", Payload: []byte("x"),
})
if respOp != OpKMSSign {
t.Fatalf("sign opcode: got 0x%04x want 0x%04x", respOp, OpKMSSign)
}
var sr SignResult
if err := json.Unmarshal(signBody, &sr); err != nil {
t.Fatalf("unmarshal sign: %v body=%s", err, string(signBody))
}
if sr.R == "" {
t.Fatalf("expected sign result R, got empty")
}
}
// TestKMSZAP_Auth_RequiredRejectsMissing: AuthRequired=true and no
// AuthHello frame → sign request returns "auth required".
func TestKMSZAP_Auth_RequiredRejectsMissing(t *testing.T) {
env := newAuthTestEnv(t)
v := newVerifier(t, env)
_, addr, stop := startServerWithAuth(t, v, true)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
op, body := callOp(t, client, peer, OpKMSSign, kmsZapSignRequest{
VaultID: "v1", WalletID: "w1", Payload: []byte("x"),
})
if op != OpKMSSign {
t.Fatalf("opcode echo: got 0x%04x want 0x%04x", op, OpKMSSign)
}
if !strings.Contains(string(body), "auth required") {
t.Fatalf("expected auth required, got %s", string(body))
}
}
// TestKMSZAP_Auth_RequiredRejectsBadToken: AuthHello with a token that
// fails JWKS verification → AuthHello replies with the verifier error
// and a follow-up sign request is rejected because no claims attached.
func TestKMSZAP_Auth_RequiredRejectsBadToken(t *testing.T) {
env := newAuthTestEnv(t)
v := newVerifier(t, env)
_, addr, stop := startServerWithAuth(t, v, true)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
tok := env.sign(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "wrong-audience",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
_, body := sendAuthHello(t, client, peer, tok)
if !strings.Contains(string(body), "audience mismatch") {
t.Fatalf("expected audience mismatch, got %s", string(body))
}
op, signBody := callOp(t, client, peer, OpKMSSign, kmsZapSignRequest{
VaultID: "v1", WalletID: "w1", Payload: []byte("x"),
})
if op != OpKMSSign {
t.Fatalf("opcode echo: got 0x%04x want 0x%04x", op, OpKMSSign)
}
if !strings.Contains(string(signBody), "auth required") {
t.Fatalf("expected auth required after bad token, got %s", string(signBody))
}
}
// TestKMSZAP_Auth_AdvisoryAcceptsMissing: ZAP_AUTH_REQUIRED=false (the
// v1.14.0 default) → sign request still succeeds even without auth.
func TestKMSZAP_Auth_AdvisoryAcceptsMissing(t *testing.T) {
env := newAuthTestEnv(t)
v := newVerifier(t, env)
_, addr, stop := startServerWithAuth(t, v, false)
defer stop()
client, peer := dialClient(t, addr)
defer client.Stop()
op, body := callOp(t, client, peer, OpKMSSign, kmsZapSignRequest{
VaultID: "v1", WalletID: "w1", Payload: []byte("x"),
})
if op != OpKMSSign {
t.Fatalf("opcode echo: got 0x%04x want 0x%04x", op, OpKMSSign)
}
var sr SignResult
if err := json.Unmarshal(body, &sr); err != nil {
t.Fatalf("unmarshal sign: %v body=%s", err, string(body))
}
if sr.R == "" {
t.Fatalf("advisory mode should still serve sign; got empty R")
}
}
// TestKMSZAP_Auth_RequiredVerifierMustBeNonNil: Without a verifier you
// cannot ask for AuthRequired=true.
func TestKMSZAP_Auth_RequiredVerifierMustBeNonNil(t *testing.T) {
addr := pickPort(t)
_, err := StartKMSZAPWith(&stubBackend{}, "id", addr, KMSZapConfig{Verifier: nil, AuthRequired: true})
if err == nil {
t.Fatalf("expected start error when AuthRequired=true and Verifier=nil")
}
}
-550
View File
@@ -1,550 +0,0 @@
// Package zapauth gates the MPC ZAP server on a JWKS-validated bearer
// token presented BEFORE the X25519+ML-KEM-768 handshake (pkg/zap).
//
// Wire contract (LP-103):
//
// 1. Client sends OpAuthHello (0x00EF) carrying a bearer JWT in a
// versioned frame. The server verifies signature via a cached JWKS,
// checks iss + aud + exp, and records the claims keyed by the
// ZAP peer ID for the lifetime of the connection.
// 2. Client sends OpClientHello (0x00F0). Server runs the existing
// hybrid handshake unchanged. The session AEAD remains the only
// authenticator on the wire after this point.
// 3. Subsequent KMS opcodes (0x00xx) dispatch as before. The
// connection's claims are available on every handler via Session().
//
// Security boundary: zapauth authenticates the *peer*, not individual
// requests. A peer that successfully authenticates once may issue any
// opcode that its claims permit. Authorization (per-opcode policy) is
// the caller's responsibility — zapauth records claims and exposes them
// for downstream checks.
//
// Backwards compatibility: ZAP_AUTH_REQUIRED defaults to false in v1.14.0.
// When false, missing/invalid auth is logged and the connection proceeds
// in unauthenticated mode (legacy KMS clients keep working). When true,
// missing auth is rejected at OpClientHello and never reaches handshake
// state. The flag flips to true in v1.15.0 once every KMS deployment
// has rolled to a build that mints the bearer.
package zapauth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
// OpAuthHello is the opcode for the pre-handshake bearer-token frame.
// Reserved at 0x00EF — one slot below OpClientHello (0x00F0) so the
// auth message is wire-adjacent to the handshake it gates.
const OpAuthHello uint16 = 0x00EF
// CurrentFrameVersion is the wire revision of the auth hello frame.
// An incompatible peer rejects with ErrUnsupportedVersion.
const CurrentFrameVersion uint8 = 1
// Errors surfaced to callers and (in error frames) to remote peers.
// Keep error strings stable across releases — operators alert on them.
var (
ErrMissingToken = errors.New("zapauth: bearer token required")
ErrInvalidToken = errors.New("zapauth: token validation failed")
ErrIssuerMismatch = errors.New("zapauth: issuer mismatch")
ErrAudienceMismatch = errors.New("zapauth: audience mismatch")
ErrTokenExpired = errors.New("zapauth: token expired")
ErrUnsupportedAlg = errors.New("zapauth: unsupported alg")
ErrUnsupportedVersion = errors.New("zapauth: unsupported frame version")
ErrJWKSFetch = errors.New("zapauth: jwks fetch failed")
ErrKidNotFound = errors.New("zapauth: kid not in jwks")
ErrTruncated = errors.New("zapauth: frame truncated")
)
// Config controls one Verifier. Zero-value is invalid; call Validate()
// before NewVerifier to surface a missing JWKSURL early.
type Config struct {
// JWKSURL is the absolute URL of the JWKS document. Required.
JWKSURL string
// ExpectedIssuer is matched exactly against the JWT iss claim. Required.
ExpectedIssuer string
// ExpectedAudiences is the allow-list. The JWT aud must contain at
// least one of these. Required (empty list disables aud check, which
// is rejected at Validate so callers don't ship a permissive setup).
ExpectedAudiences []string
// CacheTTL governs how long a JWKS fetch is reused. Default 5 minutes.
CacheTTL time.Duration
// HTTPClient overrides the default JWKS fetch client. Default has a
// 10-second timeout and a 1MiB response size cap.
HTTPClient *http.Client
// Clock substitution for tests. Defaults to time.Now.
Now func() time.Time
}
// Validate enforces invariants Config-wise so NewVerifier never returns
// a partially configured object.
func (c *Config) Validate() error {
if strings.TrimSpace(c.JWKSURL) == "" {
return errors.New("zapauth: JWKSURL required")
}
if strings.TrimSpace(c.ExpectedIssuer) == "" {
return errors.New("zapauth: ExpectedIssuer required")
}
if len(c.ExpectedAudiences) == 0 {
return errors.New("zapauth: at least one ExpectedAudience required")
}
for _, a := range c.ExpectedAudiences {
if strings.TrimSpace(a) == "" {
return errors.New("zapauth: ExpectedAudience entries must be non-empty")
}
}
return nil
}
// Claims is the verified-claims subset zapauth surfaces to callers.
// Raw is the full claims map for callers that need additional fields.
type Claims struct {
Subject string
Issuer string
Audience []string
ExpiresAt time.Time
Raw map[string]any
}
// Verifier validates JWTs against a remote JWKS endpoint and tracks
// per-peer claims. A single Verifier is safe for concurrent use.
type Verifier struct {
cfg Config
jwks *jwksCache
now func() time.Time
mu sync.RWMutex
claims map[string]Claims // keyed by ZAP peer ID
}
// NewVerifier constructs a Verifier from cfg. Returns an error when cfg
// is incomplete (see Config.Validate). The verifier does not contact the
// JWKS URL eagerly — first Verify() triggers the fetch.
func NewVerifier(cfg Config) (*Verifier, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if cfg.CacheTTL == 0 {
cfg.CacheTTL = 5 * time.Minute
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Verifier{
cfg: cfg,
jwks: &jwksCache{url: cfg.JWKSURL, client: cfg.HTTPClient, ttl: cfg.CacheTTL, now: cfg.Now},
now: cfg.Now,
claims: make(map[string]Claims),
}, nil
}
// Verify validates a bearer token against the configured JWKS, issuer,
// and audience set. Returns the verified claims subset on success or a
// classified zapauth.* error on failure.
func (v *Verifier) Verify(ctx context.Context, token string) (*Claims, error) {
if strings.TrimSpace(token) == "" {
return nil, ErrMissingToken
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w: malformed jwt", ErrInvalidToken)
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w: header b64: %v", ErrInvalidToken, err)
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, fmt.Errorf("%w: header json: %v", ErrInvalidToken, err)
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: payload b64: %v", ErrInvalidToken, err)
}
var raw map[string]any
if err := json.Unmarshal(payloadBytes, &raw); err != nil {
return nil, fmt.Errorf("%w: payload json: %v", ErrInvalidToken, err)
}
if iss, _ := raw["iss"].(string); iss != v.cfg.ExpectedIssuer {
return nil, fmt.Errorf("%w: got %q want %q", ErrIssuerMismatch, iss, v.cfg.ExpectedIssuer)
}
if !audMatches(raw, v.cfg.ExpectedAudiences) {
return nil, ErrAudienceMismatch
}
if expFloat, ok := raw["exp"].(float64); ok {
exp := time.Unix(int64(expFloat), 0)
if v.now().After(exp) {
return nil, ErrTokenExpired
}
}
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w: signature b64: %v", ErrInvalidToken, err)
}
key, err := v.jwks.getKey(ctx, header.Kid)
if err != nil {
return nil, err
}
signed := []byte(parts[0] + "." + parts[1])
if err := verifySignature(header.Alg, key, signed, sigBytes); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
}
c := Claims{Raw: raw}
c.Subject, _ = raw["sub"].(string)
c.Issuer, _ = raw["iss"].(string)
c.Audience = audSlice(raw)
if expFloat, ok := raw["exp"].(float64); ok {
c.ExpiresAt = time.Unix(int64(expFloat), 0)
}
return &c, nil
}
// AttachClaims associates verified claims with a ZAP peer ID. The
// server stores this after a successful AuthHello. Subsequent handlers
// look up claims via Lookup() to enforce per-opcode policy.
func (v *Verifier) AttachClaims(peerID string, c Claims) {
v.mu.Lock()
v.claims[peerID] = c
v.mu.Unlock()
}
// Lookup returns the claims attached to a peer ID, or false if the peer
// never authenticated (or auth was disabled by config).
func (v *Verifier) Lookup(peerID string) (Claims, bool) {
v.mu.RLock()
defer v.mu.RUnlock()
c, ok := v.claims[peerID]
return c, ok
}
// Drop releases per-peer claim state. Called by the ZAP server when a
// peer disconnects so claim memory doesn't grow unbounded.
func (v *Verifier) Drop(peerID string) {
v.mu.Lock()
delete(v.claims, peerID)
v.mu.Unlock()
}
// audMatches returns true iff the JWT aud claim contains any allowed
// audience. JWT aud may be a string or an array of strings (RFC 7519).
func audMatches(claims map[string]any, allowed []string) bool {
got := audSlice(claims)
for _, a := range got {
for _, want := range allowed {
if a == want {
return true
}
}
}
return false
}
// audSlice normalizes the aud claim to a slice.
func audSlice(claims map[string]any) []string {
switch v := claims["aud"].(type) {
case string:
if v == "" {
return nil
}
return []string{v}
case []any:
out := make([]string, 0, len(v))
for _, a := range v {
if s, ok := a.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}
// ----------------------------------------------------------------------
// JWKS cache — stdlib only (mirrors goa/pkg/iam/oidc.go pattern).
// ----------------------------------------------------------------------
type jwkKey struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
type jwksResponse struct {
Keys []jwkKey `json:"keys"`
}
type jwksCache struct {
url string
client *http.Client
ttl time.Duration
now func() time.Time
mu sync.RWMutex
keys map[string]crypto.PublicKey
fetchedAt time.Time
}
func (c *jwksCache) getKey(ctx context.Context, kid string) (crypto.PublicKey, error) {
c.mu.RLock()
if c.keys != nil && c.now().Sub(c.fetchedAt) < c.ttl {
if k, ok := c.keys[kid]; ok {
c.mu.RUnlock()
return k, nil
}
}
c.mu.RUnlock()
return c.fetchAndGet(ctx, kid)
}
func (c *jwksCache) fetchAndGet(ctx context.Context, kid string) (crypto.PublicKey, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.keys != nil && c.now().Sub(c.fetchedAt) < c.ttl {
if k, ok := c.keys[kid]; ok {
return k, nil
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrJWKSFetch, err)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrJWKSFetch, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: status %d", ErrJWKSFetch, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrJWKSFetch, err)
}
var jwks jwksResponse
if err := json.Unmarshal(body, &jwks); err != nil {
return nil, fmt.Errorf("%w: %v", ErrJWKSFetch, err)
}
keys := make(map[string]crypto.PublicKey, len(jwks.Keys))
for _, k := range jwks.Keys {
if k.Use != "" && k.Use != "sig" {
continue
}
pub, err := parseJWK(k)
if err != nil {
continue
}
keys[k.Kid] = pub
}
c.keys = keys
c.fetchedAt = c.now()
k, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("%w: kid=%q", ErrKidNotFound, kid)
}
return k, nil
}
func parseJWK(k jwkKey) (crypto.PublicKey, error) {
switch k.Kty {
case "RSA":
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := 0
for _, b := range eBytes {
e = e<<8 | int(b)
}
return &rsa.PublicKey{N: n, E: e}, nil
case "EC":
xBytes, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, err
}
yBytes, err := base64.RawURLEncoding.DecodeString(k.Y)
if err != nil {
return nil, err
}
var curve elliptic.Curve
switch k.Crv {
case "P-256":
curve = elliptic.P256()
case "P-384":
curve = elliptic.P384()
case "P-521":
curve = elliptic.P521()
default:
return nil, fmt.Errorf("zapauth: unsupported curve %q", k.Crv)
}
return &ecdsa.PublicKey{Curve: curve, X: new(big.Int).SetBytes(xBytes), Y: new(big.Int).SetBytes(yBytes)}, nil
case "OKP":
xBytes, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, err
}
if len(xBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("zapauth: invalid ed25519 key size %d", len(xBytes))
}
return ed25519.PublicKey(xBytes), nil
default:
return nil, fmt.Errorf("zapauth: unsupported key type %q", k.Kty)
}
}
func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error {
switch alg {
case "RS256":
return verifyRSA(crypto.SHA256, key, signed, sig)
case "RS384":
return verifyRSA(crypto.SHA384, key, signed, sig)
case "RS512":
return verifyRSA(crypto.SHA512, key, signed, sig)
case "ES256":
return verifyECDSA(crypto.SHA256, key, signed, sig)
case "ES384":
return verifyECDSA(crypto.SHA384, key, signed, sig)
case "ES512":
return verifyECDSA(crypto.SHA512, key, signed, sig)
case "EdDSA":
return verifyEdDSA(key, signed, sig)
default:
return fmt.Errorf("%w: %s", ErrUnsupportedAlg, alg)
}
}
func verifyRSA(hash crypto.Hash, key crypto.PublicKey, signed, sig []byte) error {
rsaKey, ok := key.(*rsa.PublicKey)
if !ok {
return errors.New("zapauth: key is not RSA")
}
h := hash.New()
h.Write(signed)
return rsa.VerifyPKCS1v15(rsaKey, hash, h.Sum(nil), sig)
}
func verifyECDSA(hash crypto.Hash, key crypto.PublicKey, signed, sig []byte) error {
ecKey, ok := key.(*ecdsa.PublicKey)
if !ok {
return errors.New("zapauth: key is not ECDSA")
}
h := hash.New()
h.Write(signed)
digest := h.Sum(nil)
// Try ASN.1 first (RFC 3279, what crypto/ecdsa.SignASN1 emits).
if ecdsa.VerifyASN1(ecKey, digest, sig) {
return nil
}
// JWS ES256/384/512 emit fixed-width R||S (RFC 7518 §3.4). Accept that.
octetSize := (ecKey.Curve.Params().BitSize + 7) / 8
if len(sig) == 2*octetSize {
r := new(big.Int).SetBytes(sig[:octetSize])
s := new(big.Int).SetBytes(sig[octetSize:])
if ecdsa.Verify(ecKey, digest, r, s) {
return nil
}
}
return errors.New("zapauth: ecdsa signature invalid")
}
func verifyEdDSA(key crypto.PublicKey, signed, sig []byte) error {
edKey, ok := key.(ed25519.PublicKey)
if !ok {
return errors.New("zapauth: key is not Ed25519")
}
if !ed25519.Verify(edKey, signed, sig) {
return errors.New("zapauth: ed25519 signature invalid")
}
return nil
}
// ----------------------------------------------------------------------
// Wire framing for OpAuthHello.
// ----------------------------------------------------------------------
//
// AuthHello frame layout (little-endian):
//
// +0 uint8 version // CurrentFrameVersion
// +1 uint16 token_len
// +3 [token_len]byte token
//
// Total = 3 + len(token). Tokens >64 KiB are rejected at MarshalAuthHello.
const maxTokenSize = 64 * 1024
// AuthHelloFrame is the parsed AuthHello payload (no opcode prefix).
type AuthHelloFrame struct {
Version uint8
Token string
}
// MarshalAuthHello serializes an AuthHelloFrame to its on-the-wire bytes.
func MarshalAuthHello(f *AuthHelloFrame) ([]byte, error) {
if len(f.Token) > maxTokenSize {
return nil, fmt.Errorf("zapauth: token too large (%d > %d)", len(f.Token), maxTokenSize)
}
out := make([]byte, 3+len(f.Token))
out[0] = f.Version
binary.LittleEndian.PutUint16(out[1:3], uint16(len(f.Token)))
copy(out[3:], f.Token)
return out, nil
}
// UnmarshalAuthHello parses an AuthHello wire frame. Rejects future
// versions with ErrUnsupportedVersion so peers that ship a newer wire
// can negotiate downward instead of mis-parsing.
func UnmarshalAuthHello(b []byte) (*AuthHelloFrame, error) {
if len(b) < 3 {
return nil, ErrTruncated
}
f := &AuthHelloFrame{Version: b[0]}
if f.Version != CurrentFrameVersion {
return nil, fmt.Errorf("%w: got=%d want=%d", ErrUnsupportedVersion, f.Version, CurrentFrameVersion)
}
tokenLen := binary.LittleEndian.Uint16(b[1:3])
if int(tokenLen) > maxTokenSize {
return nil, fmt.Errorf("zapauth: declared token length %d exceeds max %d", tokenLen, maxTokenSize)
}
if len(b) < 3+int(tokenLen) {
return nil, ErrTruncated
}
f.Token = string(b[3 : 3+int(tokenLen)])
return f, nil
}
-344
View File
@@ -1,344 +0,0 @@
package zapauth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Tests build a self-contained ES256 signer + JWKS endpoint so the
// verifier exercises the full path (fetch, parse, verify, claim
// extraction) without external dependencies.
type testEnv struct {
jwksServer *httptest.Server
priv *ecdsa.PrivateKey
kid string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa generate: %v", err)
}
kid := "test-kid-1"
jwk := map[string]any{
"kty": "EC",
"kid": kid,
"alg": "ES256",
"use": "sig",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(priv.PublicKey.X.Bytes()),
"y": base64.RawURLEncoding.EncodeToString(priv.PublicKey.Y.Bytes()),
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
}))
t.Cleanup(srv.Close)
return &testEnv{jwksServer: srv, priv: priv, kid: kid}
}
func (e *testEnv) signES256(t *testing.T, claims map[string]any) string {
t.Helper()
header := map[string]any{"alg": "ES256", "typ": "JWT", "kid": e.kid}
hdrJSON, _ := json.Marshal(header)
clmJSON, _ := json.Marshal(claims)
hdr := base64.RawURLEncoding.EncodeToString(hdrJSON)
clm := base64.RawURLEncoding.EncodeToString(clmJSON)
signing := hdr + "." + clm
digest := sha256.Sum256([]byte(signing))
r, s, err := ecdsa.Sign(rand.Reader, e.priv, digest[:])
if err != nil {
t.Fatalf("ecdsa sign: %v", err)
}
// JWS ES256 expects fixed-width R||S (32 bytes each), not ASN.1.
sig := jwsRS(r, s, 32)
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// signES256ASN1 produces a malformed ES256 sig (ASN.1) so verifyECDSA's
// ASN.1 path runs against an ES256 fixed-width-expecting JWT layer. We
// use this only for the "invalid signature" negative test below, by
// flipping a byte in the produced signature instead.
func jwsRS(r, s *big.Int, octetSize int) []byte {
rb, sb := r.Bytes(), s.Bytes()
out := make([]byte, 2*octetSize)
copy(out[octetSize-len(rb):octetSize], rb)
copy(out[2*octetSize-len(sb):], sb)
return out
}
func newConfig(env *testEnv) Config {
return Config{
JWKSURL: env.jwksServer.URL,
ExpectedIssuer: "https://iam.test.local",
ExpectedAudiences: []string{""},
CacheTTL: time.Minute,
}
}
// verifyES256Adapter replaces the package's verifySignature() ECDSA
// path with one that accepts JWS-style fixed-width R||S signatures
// (what real JWTs produce). The package currently expects ASN.1.
//
// To keep production code stdlib-only and JWS-correct, we update the
// production verifyECDSA to detect both forms: tries ASN.1 first, then
// falls back to fixed-width R||S. The test below exercises both.
//
// For the test we just call the production Verifier directly and
// assert behavior.
func TestZapAuthValidJWT(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
c, err := v.Verify(context.Background(), tok)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if c.Subject != "kms-pod-1" {
t.Fatalf("subject=%q want kms-pod-1", c.Subject)
}
if len(c.Audience) != 1 || c.Audience[0] != "" {
t.Fatalf("audience=%v want []", c.Audience)
}
}
func TestZapAuthInvalidSignature(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
// Replace the entire signature segment with all-A's (decodable
// base64url but cryptographically guaranteed-invalid bytes). A
// single-char flip can be probabilistically valid for ECDSA ≈
// 1/2^6, which made this test flake in CI. Wiping the whole sig
// makes the verify failure deterministic.
parts := strings.Split(tok, ".")
badSig := strings.Repeat("A", len(parts[2]))
bad := parts[0] + "." + parts[1] + "." + badSig
if _, err := v.Verify(context.Background(), bad); err == nil {
t.Fatalf("Verify: expected error on tampered signature")
}
}
func TestZapAuthExpiredToken(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(-time.Minute).Unix()),
})
_, err = v.Verify(context.Background(), tok)
if err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("Verify: got %v, want token expired", err)
}
}
func TestZapAuthWrongAudience(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "wrong-audience",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
_, err = v.Verify(context.Background(), tok)
if err == nil || !strings.Contains(err.Error(), "audience mismatch") {
t.Fatalf("Verify: got %v, want audience mismatch", err)
}
}
func TestZapAuthWrongIssuer(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.evil.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
_, err = v.Verify(context.Background(), tok)
if err == nil || !strings.Contains(err.Error(), "issuer mismatch") {
t.Fatalf("Verify: got %v, want issuer mismatch", err)
}
}
func TestZapAuthMissingHeader(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
if _, err := v.Verify(context.Background(), ""); err != ErrMissingToken {
t.Fatalf("Verify(empty): got %v, want ErrMissingToken", err)
}
}
func TestZapAuthAudienceArray(t *testing.T) {
env := newTestEnv(t)
cfg := newConfig(env)
cfg.ExpectedAudiences = []string{"", ""}
v, err := NewVerifier(cfg)
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": []any{"other", ""},
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
c, err := v.Verify(context.Background(), tok)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if len(c.Audience) != 2 {
t.Fatalf("audience=%v want 2 entries", c.Audience)
}
}
func TestZapAuthConfigValidate(t *testing.T) {
cases := []struct {
name string
cfg Config
}{
{"missing JWKSURL", Config{ExpectedIssuer: "x", ExpectedAudiences: []string{"y"}}},
{"missing issuer", Config{JWKSURL: "x", ExpectedAudiences: []string{"y"}}},
{"empty audiences", Config{JWKSURL: "x", ExpectedIssuer: "y"}},
{"blank audience entry", Config{JWKSURL: "x", ExpectedIssuer: "y", ExpectedAudiences: []string{" "}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.cfg.Validate(); err == nil {
t.Fatalf("expected validation error")
}
})
}
}
func TestZapAuthFrameRoundTrip(t *testing.T) {
in := &AuthHelloFrame{Version: CurrentFrameVersion, Token: "abc.def.ghi"}
wire, err := MarshalAuthHello(in)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
out, err := UnmarshalAuthHello(wire)
if err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if out.Token != in.Token {
t.Fatalf("token round-trip: got %q want %q", out.Token, in.Token)
}
}
func TestZapAuthFrameUnsupportedVersion(t *testing.T) {
wire := []byte{99, 0, 0}
if _, err := UnmarshalAuthHello(wire); err == nil {
t.Fatalf("expected ErrUnsupportedVersion")
}
}
func TestZapAuthFrameTruncated(t *testing.T) {
if _, err := UnmarshalAuthHello([]byte{1}); err != ErrTruncated {
t.Fatalf("got %v, want ErrTruncated", err)
}
if _, err := UnmarshalAuthHello([]byte{1, 10, 0, 'a'}); err != ErrTruncated {
t.Fatalf("got %v, want ErrTruncated on declared > present", err)
}
}
func TestZapAuthClaimsLifecycle(t *testing.T) {
env := newTestEnv(t)
v, err := NewVerifier(newConfig(env))
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
v.AttachClaims("peer-1", Claims{Subject: "kms-pod-1"})
c, ok := v.Lookup("peer-1")
if !ok || c.Subject != "kms-pod-1" {
t.Fatalf("Lookup(peer-1): %+v ok=%v", c, ok)
}
v.Drop("peer-1")
if _, ok := v.Lookup("peer-1"); ok {
t.Fatalf("Lookup after Drop should miss")
}
}
func TestZapAuthJWKSCacheReuses(t *testing.T) {
env := newTestEnv(t)
calls := 0
cfg := newConfig(env)
cfg.HTTPClient = &http.Client{
Transport: countingTransport{base: http.DefaultTransport, calls: &calls},
Timeout: 5 * time.Second,
}
v, err := NewVerifier(cfg)
if err != nil {
t.Fatalf("NewVerifier: %v", err)
}
tok := env.signES256(t, map[string]any{
"iss": "https://iam.test.local",
"aud": "",
"sub": "kms-pod-1",
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
for i := 0; i < 5; i++ {
if _, err := v.Verify(context.Background(), tok); err != nil {
t.Fatalf("Verify[%d]: %v", i, err)
}
}
if calls != 1 {
t.Fatalf("expected 1 JWKS fetch, got %d", calls)
}
}
type countingTransport struct {
base http.RoundTripper
calls *int
}
func (c countingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
*c.calls++
return c.base.RoundTrip(r)
}