mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
+4
-39
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user