webauthn: vendor-neutral pluggable liveness Verifier; drop

lux OSS names no PAD-2 / liveness vendor. Decomplect the verifier into a
pluggable extension point so a white-label deployment (e.g. liquidity)
registers its own provider without forking:

- pkg/webauthn: add Verifier interface + Binding (vendor-neutral per-call
  input) + Ed25519EnvelopeVerifier (OSS default, delegates to the audited
  VerifyLiveness) + RegisterVerifier/LookupVerifier registry. Tests cover
  the default verifier, the registry, and a custom non-Ed25519 scheme.
- pkg/api: resolve the active Verifier at boot from MPC_LIVENESS_PROVIDER_ID
  (registered custom wins; else Ed25519 from MPC_LIVENESS_PUBKEY_ED25519;
  else nil → enroll fails closed 503). handleBiometricEnroll calls
  s.livenessVerifier.Verify(...) — no vendor code in lux.
- Rename MPC__* env vars → MPC_LIVENESS_*; drop every 
  identifier/comment across code + LLM.md. Zero /onyxd in lux/mpc.

Also finish the half-done decomplect from a326cc6 'rip zapauth gate' that
left pkg/api/zap_kms_server.go calling a removed checkAuth and missing the
encoding/json import — restore the import, drop the orphaned guard.

(Pre-existing, unrelated: cmd/mpcd still references thresholdd.NewZapServer
from the same in-flight WIP — not touched here.)
This commit is contained in:
2026-06-10 21:42:27 -07:00
parent 7bbbe94236
commit ec0fb82eaa
9 changed files with 292 additions and 75 deletions
+32 -5
View File
@@ -477,7 +477,7 @@ make e2e-test
29. **Cluster API key forwarding**: api-deployment now passes `--cluster-api-key $(MPC_CLUSTER_API_KEY)` when proxying to internal MPC API. The `apiOnlyMPCBackend.doRequest` sends it as `Authorization: Bearer` header.
30. **R2-1 Biometric enroll hardened**: `handleBiometricEnroll` now verifies a WebAuthn create ceremony AND a -signed liveness attestation. `pkg/webauthn/verify.go` centralizes: challenge comparison against the random bytes we issued (not the DB row ID), origin allowlist, rpIDHash check, UP+UV flag enforcement. `pkg/webauthn/liveness.go` verifies Ed25519-signed `LivenessAttestation` envelopes against `MPC__PUBKEY_ED25519`; body-supplied scores are rejected. If pubkey is unconfigured, enroll returns 503.
30. **R2-1 Biometric enroll hardened**: `handleBiometricEnroll` now verifies a WebAuthn create ceremony AND a provider-signed liveness attestation. `pkg/webauthn/verify.go` centralizes: challenge comparison against the random bytes we issued (not the DB row ID), origin allowlist, rpIDHash check, UP+UV flag enforcement. `pkg/webauthn/liveness.go` verifies Ed25519-signed `LivenessAttestation` envelopes against `MPC_LIVENESS_PUBKEY_ED25519`; body-supplied scores are rejected. If pubkey is unconfigured, enroll returns 503.
31. **R2-2 CAS under Postgres**: Session consume + operation approve now use `orm.GetForUpdate` (SELECT ... FOR UPDATE) inside READ COMMITTED tx via `orm.RunInTransactionWith(IsolationReadCommitted)`. Requires `hanzoai/orm@v0.4.0`. Postgres-backed regression tests in `cas_postgres_test.go` — skipped when no Postgres available, `TEST_PG_DSN` overrides DSN. `pkg/db.New` now accepts `postgres://...` DSNs alongside SQLite.
@@ -485,23 +485,50 @@ make e2e-test
33. **R3-2 No `*` wildcard in API-key permissions**: `hasPermission` no longer honors `*`. Every API key must enumerate explicit permissions. Tests `TestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive` + `TestRequirePermission_WildcardRejected` assert `permissions=["*"]` is REJECTED for every sensitive name (`mpc:sign`, `mpc:settlement:sign`, `mpc:operations:approve`, `mpc:wallet:sweep`, `mpc:policy:write`, plus `trade:submit`). Breaking change: re-issue keys with explicit permissions; bump to v1.7.0.
34. **R3-8 Liveness envelope bound to enrollment**: `LivenessAttestation` now carries `credentialHash = sha256(pubKey)` and/or `challengeId`. `handleBiometricEnroll` passes expected values to `VerifyLiveness` so a stolen envelope cannot be replayed against a different WebAuthn public key. Mode toggle via `MPC_LIVENESS_BINDING=strict|lax` (default `strict`). **Blocker for mainnet biometric enroll**: must ship the extended envelope; while in LAX the server emits a `mpc.biometric.binding_warn` log on every permissive accept.
34. **R3-8 Liveness envelope bound to enrollment**: `LivenessAttestation` now carries `credentialHash = sha256(pubKey)` and/or `challengeId`. `handleBiometricEnroll` passes expected values to `VerifyLiveness` so a stolen envelope cannot be replayed against a different WebAuthn public key. Mode toggle via `MPC_LIVENESS_BINDING=strict|lax` (default `strict`). **Blocker for mainnet biometric enroll**: the liveness provider must ship the extended envelope; while in LAX the server emits a `mpc.biometric.binding_warn` log on every permissive accept.
35. **F4 (2026-04-18) LAX tightening — credentialHash floor + both-must-match**: Red round 4 flagged that the previous LAX mode accepted envelopes with ONLY `challengeId` (no `credentialHash`), enabling a cross-ceremony replay: an attacker observing a fresh challenge could trick into signing an envelope binding to the challenge alone and replay it against any enrollment for the same `userId`. Two tightenings in `VerifyLiveness`:
35. **F4 (2026-04-18) LAX tightening — credentialHash floor + both-must-match**: Red round 4 flagged that the previous LAX mode accepted envelopes with ONLY `challengeId` (no `credentialHash`), enabling a cross-ceremony replay: an attacker observing a fresh challenge could trick the liveness provider into signing an envelope binding to the challenge alone and replay it against any enrollment for the same `userId`. Two tightenings in `VerifyLiveness`:
- LAX + server expects `credentialHash` + envelope omits it → **reject** (`credentialHash required in LAX mode`). `credentialHash` is now a hard floor — `challengeId` alone cannot satisfy the binding requirement in LAX.
- Envelope supplies BOTH `credentialHash` AND `challengeId` AND server expects both → require BOTH to match (no more "either-or" loophole).
LAX warn-on-missing still works when the call site does NOT commit a `credentialHash` expectation. Tests in `pkg/webauthn/liveness_test.go`:
- `TestLiveness_R38_LaxRejectsMissingCredentialHash` — challengeId-only envelope rejected
- `TestLiveness_R38_LaxAcceptsBothMatch` — happy path (extended envelope after rollout)
- `TestLiveness_R38_LaxAcceptsBothMatch` — happy path (extended envelope after the liveness provider rollout)
- `TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent`
- `TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent`
- `TestLiveness_R38_LaxAcceptsCredentialHashOnly` — current envelope shape
- `TestLiveness_R38_LaxAcceptsCredentialHashOnly` — current provider envelope shape
- `TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected` — replaces the old lax-warns-missing path
- `TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly` — warn path still available when `credentialHash` not expected
`handleBiometricEnroll` always commits `ExpectedCredentialHash` (derived from the enrollment pubKey via `sha256`), so the LAX floor always engages for WebAuthn enrollment. `v1.7.1` follows `v1.7.0`.
36. **Pluggable liveness Verifier (vendor-neutral)**: lux OSS names NO PAD-2
/ liveness vendor. `pkg/webauthn` exposes the extension seam:
- `Verifier` interface — `Verify(envelope string, b Binding) (*LivenessAttestation, error)`.
- `Ed25519EnvelopeVerifier{PubKey, ProviderID}` — the one OSS-shipped
implementation, delegating to the audited `VerifyLiveness`. Zero-code
path for any vendor whose attestation is an Ed25519-signed envelope.
- `RegisterVerifier(providerID, Verifier)` / `LookupVerifier(providerID)`
— registry. A white-label deployment (e.g. liquidity wiring a
regulated PAD-2 vendor) registers its own `Verifier` before boot.
`pkg/api/server.go` resolves the active verifier at boot from
`MPC_LIVENESS_PROVIDER_ID`: a registered custom `Verifier` wins; else, if
`MPC_LIVENESS_PUBKEY_ED25519` is set, the OSS-default Ed25519 envelope
verifier is used; with neither, `livenessVerifier` is nil and
`/v1/mpc/biometric/enroll` fails closed (503). `handleBiometricEnroll`
calls `s.livenessVerifier.Verify(envelope, webauthn.Binding{...})` — no
vendor code in lux. To plug a vendor in from a downstream binary:
```go
import "github.com/luxfi/mpc/pkg/webauthn"
// Ed25519-envelope vendor — just supply the public key:
webauthn.RegisterVerifier("acme-pad2", webauthn.Ed25519EnvelopeVerifier{
PubKey: acmePubKey, ProviderID: "acme-pad2",
})
// or a wholly different scheme — implement webauthn.Verifier.
```
### Consensus-Embedded Transport (Jan 2026)
20. **ZAP Message Types**: MPC uses ZAP wire protocol message types 60-79:
+1 -1
View File
@@ -36,7 +36,7 @@ your auditor wants the trail.
| 3 | Provision Hanzo IAM org + invite signers | compliance | Each signer has logged in once and has WebAuthn registered |
| 4 | Spin up lux/mpc UI on `mpc.<your-domain>` | platform | TLS cert valid, login works |
| 5 | Configure Chainalysis KYT + (optional) TRM Labs | compliance | Test address screen returns Verdict |
| 6 | Configure (or alternative liveness provider) Ed25519 pubkey | platform | `MPC__PUBKEY_ED25519` set, biometric enroll returns 200 |
| 6 | Configure the liveness provider (or alternative liveness provider) Ed25519 pubkey | platform | `MPC_LIVENESS_PUBKEY_ED25519` set, biometric enroll returns 200 |
| 7 | Audit log dispatcher → choose mode: WORM file, M-Chain anchor, or composite | compliance | First audit event lands at the destination |
| 8 | Webhook receivers — every Fireblocks consumer rewires to the new endpoint | engineering | `/v1/webhooks/{id}/test` succeeds end-to-end |
| 9 | Backup procedure — wallet shares to S3 + KMS-wrapped Glacier | platform | `lux-mpc-cli recover` round-trip on a test wallet |
+13 -15
View File
@@ -29,7 +29,7 @@ import (
)
// logBiometricBindingWarning records a structured warning when the R3-8
// envelope→enrollment binding was weak (e.g. has not yet shipped
// envelope→enrollment binding was weak (e.g. the liveness provider has not yet shipped
// the extended envelope and the server is running in LAX mode). Never fires
// in STRICT mode — those calls are rejected outright.
func (s *Server) logBiometricBindingWarning(orgID, userID, msg string) {
@@ -615,15 +615,15 @@ func (s *Server) handleMpcSignSettlement(w http.ResponseWriter, r *http.Request)
// 3. authenticatorData[0:32] is compared against sha256(rpID); the UP and
// UV flags are enforced so locked authenticators are rejected.
// 4. Liveness score is NEVER accepted from the request body. Instead the
// client attaches a -signed Ed25519 attestation envelope
// client attaches a provider-signed Ed25519 attestation envelope
// over (providerId, userId, score, timestamp, nonce). The server verifies
// the signature against the configured pubkey and enforces
// the signature against the configured the liveness provider pubkey and enforces
// userId match, score floor, and max age.
func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
if len(s.PubKey) == 0 {
if s.livenessVerifier == nil {
writeError(w, http.StatusServiceUnavailable, "biometric enrollment unavailable: liveness verifier not configured")
return
}
@@ -668,8 +668,8 @@ func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
}
// R3-8: compute the credentialHash and challengeID the envelope must
// bind to. signs these into the attestation so a stolen
// envelope cannot be replayed to enroll an attacker-controlled key.
// bind to. The liveness provider signs these into the attestation so a
// stolen envelope cannot be replayed to enroll an attacker-controlled key.
pubKeyBytes, decErr := base64.StdEncoding.DecodeString(req.PublicKey)
if decErr != nil {
writeError(w, http.StatusBadRequest, "invalid publicKey encoding")
@@ -679,21 +679,19 @@ func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
expectedCredentialHash := base64.StdEncoding.EncodeToString(credentialHashRaw[:])
expectedChallengeID := base64.RawURLEncoding.EncodeToString(chalBytes)
// Verify the server-signed liveness attestation. Fails closed.
// Binding mode default = Strict; an operator may relax to Lax during
// the rollout window by setting MPC_LIVENESS_BINDING=lax.
// The relaxed mode still warns on every call so the rollout progress
// is observable in logs.
// Verify the provider-signed liveness attestation through the configured
// Verifier. Fails closed. Binding mode default = Strict; an operator may
// relax to Lax during a vendor's extended-envelope rollout by setting
// MPC_LIVENESS_BINDING=lax. The relaxed mode still warns on every call so
// the rollout progress is observable in logs.
const minLivenessScore = 0.8
if _, err := webauthn.VerifyLiveness(req.LivenessAttestation, &webauthn.LivenessOpts{
PubKey: s.PubKey,
ProviderID: s.ProviderID,
if _, err := s.livenessVerifier.Verify(req.LivenessAttestation, webauthn.Binding{
UserID: userID,
MinScore: minLivenessScore,
MaxAge: 2 * time.Minute,
ExpectedCredentialHash: expectedCredentialHash,
ExpectedChallengeID: expectedChallengeID,
BindingMode: s.livenessBindingMode,
Mode: s.livenessBindingMode,
WarnFn: func(msg string) {
s.logBiometricBindingWarning(orgID, userID, msg)
},
+35 -21
View File
@@ -77,15 +77,17 @@ type Server struct {
oidcIssuers []string
webauthnRPID string
webauthnOrigins map[string]bool
// PubKey is the Ed25519 verifying key used to validate
// (or any other configured PAD-2 liveness provider) attestations
// attached to /v1/mpc/biometric/enroll. Nil disables the endpoint.
PubKey ed25519.PublicKey
ProviderID string
// livenessVerifier validates the liveness attestation attached to
// /v1/mpc/biometric/enroll. Resolved at boot from the configured
// provider id: a white-label-registered Verifier if present, else the
// OSS-default Ed25519 envelope verifier built from the configured
// public key. Nil disables the endpoint (fails closed).
livenessVerifier webauthn.Verifier
livenessProviderID string
// livenessBindingMode controls the R3-8 envelope→enrollment binding.
// BindingStrict (default) rejects envelopes that don't carry
// credentialHash/challengeId. BindingLax logs a warning instead —
// intended only for the extended-envelope rollout window.
// intended only for the liveness provider extended-envelope rollout window.
// Configured via MPC_LIVENESS_BINDING=strict|lax (default strict).
livenessBindingMode webauthn.BindingMode
router chi.Router
@@ -155,24 +157,36 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
Database: database,
})
// liveness attestation verifier. Configure via:
// Liveness attestation verifier. Resolve the active Verifier from the
// configured provider id:
//
// MPC__PUBKEY_ED25519 — base64-standard Ed25519 public key
// MPC__PROVIDER_ID — optional provider id lock (e.g. "")
// MPC_LIVENESS_PROVIDER_ID — provider id; also the registry key a
// white-label deployment registers under
// MPC_LIVENESS_PUBKEY_ED25519 — base64-standard Ed25519 public key for
// the OSS-default envelope verifier
//
// Without the pubkey configured, /v1/mpc/biometric/enroll refuses every
// request. This is deliberate: an unconfigured verifier MUST NOT fall
// back to trusting body-supplied liveness scores.
var sgKey ed25519.PublicKey
if raw := os.Getenv("MPC__PUBKEY_ED25519"); raw != "" {
// A deployment that registers its own webauthn.Verifier under the
// provider id (e.g. liquidity wiring its regulated PAD-2 vendor) wins;
// otherwise, if a public key is configured, the OSS-default Ed25519
// envelope verifier is used. With neither, livenessVerifier stays nil
// and /v1/mpc/biometric/enroll refuses every request — an unconfigured
// verifier MUST NOT fall back to trusting body-supplied scores.
livenessProviderID := os.Getenv("MPC_LIVENESS_PROVIDER_ID")
var livenessVerifier webauthn.Verifier
if v, ok := webauthn.LookupVerifier(livenessProviderID); ok {
livenessVerifier = v
} else if raw := os.Getenv("MPC_LIVENESS_PUBKEY_ED25519"); raw != "" {
if dec, err := base64.StdEncoding.DecodeString(raw); err == nil && len(dec) == ed25519.PublicKeySize {
sgKey = ed25519.PublicKey(dec)
livenessVerifier = webauthn.Ed25519EnvelopeVerifier{
PubKey: ed25519.PublicKey(dec),
ProviderID: livenessProviderID,
}
}
}
// R3-8 binding mode. Default STRICT; ops may toggle to LAX during
// the extended-envelope rollout. LAX emits a warn log on
// every permissive accept so rollout progress is observable.
// R3-8 binding mode. Default STRICT; ops may toggle to LAX during a
// vendor's extended-envelope rollout. LAX emits a warn log on every
// permissive accept so rollout progress is observable.
bindingMode := webauthn.BindingStrict
if strings.ToLower(strings.TrimSpace(os.Getenv("MPC_LIVENESS_BINDING"))) == "lax" {
bindingMode = webauthn.BindingLax
@@ -186,9 +200,9 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
oidcIssuers: oidcIssuers,
webauthnRPID: rpID,
webauthnOrigins: webauthnOrigins,
PubKey: sgKey,
ProviderID: os.Getenv("MPC__PROVIDER_ID"),
livenessBindingMode: bindingMode,
livenessVerifier: livenessVerifier,
livenessProviderID: livenessProviderID,
livenessBindingMode: bindingMode,
replayGuard: newReplayGuard(),
Events: NewEventBus(),
}
+3 -3
View File
@@ -18,6 +18,7 @@ import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
@@ -250,9 +251,8 @@ func (s *KMSZapServer) setSession(peerID string, sess *kmszap.Session) {
// handleHandshake is the per-opcode handler for OpClientHello.
func (s *KMSZapServer) handleHandshake(_ context.Context, from string, msg *zap.Message) (*zap.Message, error) {
if !s.checkAuth(from, kmszap.OpClientHello) {
return s.respond(from, kmszap.OpServerHello, errBody("auth required")), nil
}
// Trust is at the network boundary (NetworkPolicy + ZAP wire), not an
// application-layer auth gate — see 7600a13 "rip zapauth gate".
raw := extractPayload(msg)
if len(raw) < 2 {
return s.respond(from, kmszap.OpServerHello, errBody("empty handshake payload")), nil
+7 -7
View File
@@ -9,7 +9,7 @@ import (
"time"
)
// LivenessAttestation is the signed envelope (or any other PAD-2
// LivenessAttestation is the signed envelope the liveness provider (or any other PAD-2
// liveness provider) returns when it scores a capture. The structure is
// canonicalized to JSON with sorted keys before signing. The KMS verifies:
//
@@ -24,7 +24,7 @@ import (
// enroll an attacker-controlled public key.
//
// Clients MUST NOT send a bare liveness score — the server-to-server trust
// anchor is the signature, not a float in the body. Without this
// anchor is the liveness provider signature, not a float in the body. Without this
// envelope the body is rejected.
type LivenessAttestation struct {
ProviderID string `json:"providerId"`
@@ -61,7 +61,7 @@ const (
// BindingLax logs a warning (via opts.WarnFn) when neither field is
// supplied but does not reject. Intended ONLY for the transition
// window while rolls out the extended envelope. Tracked
// window while the liveness provider rolls out the extended envelope. Tracked
// as a deployment blocker in the MPC LLM.md.
BindingLax
)
@@ -175,7 +175,7 @@ func VerifyLiveness(envelopeB64 string, opts *LivenessOpts) (*LivenessAttestatio
// cross-ceremony replay: an envelope whose challengeId matches
// the current ceremony's challenge but whose credentialHash is
// absent would be accepted — an attacker who observes a fresh
// challenge can trick into signing an envelope that
// challenge can trick the liveness provider into signing an envelope that
// binds to the challenge alone, then replay it against any
// ceremony for the same userId that reuses an uncaptured
// credentialHash expectation.
@@ -185,7 +185,7 @@ func VerifyLiveness(envelopeB64 string, opts *LivenessOpts) (*LivenessAttestatio
//
// 1. When BOTH expected fields are set AND the envelope
// supplies BOTH, require BOTH to match. This closes the
// "either-or" loophole for the happy path where
// "either-or" loophole for the happy path where the liveness provider
// has already rolled out the extended envelope.
//
// 2. credentialHash is mandatory at minimum in LAX. The
@@ -230,10 +230,10 @@ func VerifyLiveness(envelopeB64 string, opts *LivenessOpts) (*LivenessAttestatio
}
if !matched {
if opts.BindingMode == BindingStrict {
return nil, errors.New("liveness: envelope carries no binding (credentialHash/challengeId); must emit the extended envelope")
return nil, errors.New("liveness: envelope carries no binding (credentialHash/challengeId); the liveness provider must emit the extended envelope")
}
if opts.WarnFn != nil {
opts.WarnFn("liveness: envelope carries no binding — accepted in LAX mode; tracked rollout")
opts.WarnFn("liveness: envelope carries no binding — accepted in LAX mode; tracked the liveness provider rollout")
}
}
}
+23 -23
View File
@@ -25,7 +25,7 @@ func signEnvelope(t *testing.T, priv ed25519.PrivateKey, att LivenessAttestation
func TestLiveness_Accepts(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.95,
Timestamp: time.Now().Unix(),
@@ -34,7 +34,7 @@ func TestLiveness_Accepts(t *testing.T) {
env := signEnvelope(t, priv, att)
got, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub,
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
MinScore: 0.8,
MaxAge: 2 * time.Minute,
@@ -49,7 +49,7 @@ func TestLiveness_Accepts(t *testing.T) {
func TestLiveness_RejectsBadSig(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{ProviderID: "", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Unix()}
att := LivenessAttestation{ProviderID: "liveness", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Unix()}
env := signEnvelope(t, priv, att)
// Corrupt: swap a byte in the envelope (base64 re-decode still works but
// the JSON.sig no longer matches the attestation).
@@ -69,7 +69,7 @@ func TestLiveness_RejectsBadSig(t *testing.T) {
func TestLiveness_RejectsWrongUser(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{ProviderID: "", UserID: "attacker", Score: 0.9, Timestamp: time.Now().Unix()}
att := LivenessAttestation{ProviderID: "liveness", UserID: "attacker", Score: 0.9, Timestamp: time.Now().Unix()}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
if err == nil || !strings.Contains(err.Error(), "userId") {
@@ -79,7 +79,7 @@ func TestLiveness_RejectsWrongUser(t *testing.T) {
func TestLiveness_RejectsStale(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{ProviderID: "", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Add(-10 * time.Minute).Unix()}
att := LivenessAttestation{ProviderID: "liveness", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Add(-10 * time.Minute).Unix()}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
if err == nil || !strings.Contains(err.Error(), "too old") {
@@ -89,7 +89,7 @@ func TestLiveness_RejectsStale(t *testing.T) {
func TestLiveness_RejectsLowScore(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{ProviderID: "", UserID: "u-1", Score: 0.5, Timestamp: time.Now().Unix()}
att := LivenessAttestation{ProviderID: "liveness", UserID: "u-1", Score: 0.5, Timestamp: time.Now().Unix()}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
if err == nil || !strings.Contains(err.Error(), "below threshold") {
@@ -101,7 +101,7 @@ func TestLiveness_RejectsWrongProvider(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{ProviderID: "evil", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Unix()}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{PubKey: pub, ProviderID: "", UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
_, err := VerifyLiveness(env, &LivenessOpts{PubKey: pub, ProviderID: "liveness", UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
if err == nil || !strings.Contains(err.Error(), "providerId") {
t.Fatalf("want providerId mismatch, got %v", err)
}
@@ -114,7 +114,7 @@ func TestLiveness_RejectsWrongProvider(t *testing.T) {
func TestLiveness_R38_AcceptsMatchingCredentialHash(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -136,7 +136,7 @@ func TestLiveness_R38_AcceptsMatchingCredentialHash(t *testing.T) {
func TestLiveness_R38_RejectsMismatchedCredentialHash(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -158,7 +158,7 @@ func TestLiveness_R38_RejectsMismatchedCredentialHash(t *testing.T) {
func TestLiveness_R38_RejectsMissingBindingStrict(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -182,7 +182,7 @@ func TestLiveness_R38_RejectsMissingBindingStrict(t *testing.T) {
func TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -205,7 +205,7 @@ func TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected(t *test
func TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -232,7 +232,7 @@ func TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly(t *testing.T) {
func TestLiveness_R38_AcceptsMatchingChallengeID(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -253,7 +253,7 @@ func TestLiveness_R38_AcceptsMatchingChallengeID(t *testing.T) {
func TestLiveness_R38_RejectsMismatchedChallengeID(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -274,7 +274,7 @@ func TestLiveness_R38_RejectsMismatchedChallengeID(t *testing.T) {
//
// Previous LAX behavior accepted an envelope that had ONLY challengeId and
// no credentialHash, so long as the challengeId matched. An attacker
// observing a fresh challenge could persuade to sign an
// observing a fresh challenge could persuade the liveness provider to sign an
// envelope binding to the challenge alone and replay it against any
// enrollment for the same userId. The challengeId binds the ceremony;
// the credentialHash binds the specific pubkey being enrolled. Without
@@ -286,7 +286,7 @@ func TestLiveness_R38_RejectsMismatchedChallengeID(t *testing.T) {
func TestLiveness_R38_LaxRejectsMissingCredentialHash(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -305,11 +305,11 @@ func TestLiveness_R38_LaxRejectsMissingCredentialHash(t *testing.T) {
}
// LAX + both fields present + both match → accept (happy path for the
// extended envelope post--rollout).
// extended envelope post-the liveness provider-rollout).
func TestLiveness_R38_LaxAcceptsBothMatch(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -334,7 +334,7 @@ func TestLiveness_R38_LaxAcceptsBothMatch(t *testing.T) {
func TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -357,7 +357,7 @@ func TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent(t *testing.T
func TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
@@ -377,18 +377,18 @@ func TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent(t *testing.T) {
}
// LAX + credentialHash only (no challengeId) + match → accept.
// This is the " not yet rolled out challengeId" case. The
// This is the "the liveness provider not yet rolled out challengeId" case. The
// mandatory credentialHash floor is satisfied, so the envelope carries
// enough binding to be safe.
func TestLiveness_R38_LaxAcceptsCredentialHashOnly(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
ProviderID: "liveness",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
CredentialHash: "HASH-OK",
// no ChallengeID — simulates the current envelope
// no ChallengeID — simulates the current the liveness provider envelope
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
+101
View File
@@ -0,0 +1,101 @@
package webauthn
import (
"crypto/ed25519"
"sync"
"time"
)
// Binding is the vendor-neutral, per-call input to liveness verification:
// who the caller is, the score/freshness policy, and the enrollment
// ceremony the attestation must be bound to (R3-8). It deliberately holds
// no provider keys — those belong to the Verifier implementation. This is
// the seam that lets a white-label deployment swap the attestation scheme
// without the MPC call sites changing.
type Binding struct {
UserID string
MinScore float64
MaxAge time.Duration
ExpectedCredentialHash string
ExpectedChallengeID string
Mode BindingMode
Now func() time.Time
WarnFn func(string)
}
// Verifier verifies one provider's liveness attestation against a Binding,
// returning the verified attestation or an error. It is THE extension point
// for identity-verification vendors.
//
// lux OSS ships exactly one implementation — Ed25519EnvelopeVerifier — and
// names no vendor. A white-label deployment (e.g. a regulated broker that
// integrates a specific PAD-2 vendor) either:
//
// - constructs an Ed25519EnvelopeVerifier with its vendor's public key
// (zero code — its attestation is an Ed25519-signed envelope), or
// - implements Verifier for a wholly different scheme (a remote
// attestation service, a different signature suite, …),
//
// and registers it via RegisterVerifier before the MPC server boots. The
// server resolves the verifier by provider id — no fork of this package.
type Verifier interface {
Verify(envelope string, b Binding) (*LivenessAttestation, error)
}
// Ed25519EnvelopeVerifier is the OSS-default Verifier: a JSON attestation
// envelope `{"attestation":{...},"sig":"b64"}` signed with Ed25519 over the
// canonical attestation bytes. It delegates to VerifyLiveness — the same
// audited verification logic — so registering one is the zero-code path for
// any vendor whose attestation is an Ed25519-signed envelope.
type Ed25519EnvelopeVerifier struct {
// PubKey verifies the envelope signature.
PubKey ed25519.PublicKey
// ProviderID, when non-empty, locks attestation.ProviderID to this value.
ProviderID string
}
// Verify implements Verifier.
func (v Ed25519EnvelopeVerifier) Verify(envelope string, b Binding) (*LivenessAttestation, error) {
return VerifyLiveness(envelope, &LivenessOpts{
PubKey: v.PubKey,
ProviderID: v.ProviderID,
UserID: b.UserID,
MinScore: b.MinScore,
MaxAge: b.MaxAge,
ExpectedCredentialHash: b.ExpectedCredentialHash,
ExpectedChallengeID: b.ExpectedChallengeID,
BindingMode: b.Mode,
Now: b.Now,
WarnFn: b.WarnFn,
})
}
// --- registry -------------------------------------------------------------
var (
verifierMu sync.RWMutex
verifiers = map[string]Verifier{}
)
// RegisterVerifier registers a Verifier under a provider id. A white-label
// deployment calls this from an init() or main() before the MPC server
// boots; the server then resolves the verifier for its configured
// MPC_LIVENESS_PROVIDER_ID. Re-registering the same id replaces the prior
// entry (last write wins). Passing a nil Verifier removes the entry.
func RegisterVerifier(providerID string, v Verifier) {
verifierMu.Lock()
defer verifierMu.Unlock()
if v == nil {
delete(verifiers, providerID)
return
}
verifiers[providerID] = v
}
// LookupVerifier returns the Verifier registered under providerID, if any.
func LookupVerifier(providerID string) (Verifier, bool) {
verifierMu.RLock()
defer verifierMu.RUnlock()
v, ok := verifiers[providerID]
return v, ok
}
+77
View File
@@ -0,0 +1,77 @@
package webauthn
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"testing"
"time"
)
// TestEd25519EnvelopeVerifier confirms the OSS-default Verifier delegates to
// the audited VerifyLiveness path and that a Binding maps through cleanly.
func TestEd25519EnvelopeVerifier(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
v := Ed25519EnvelopeVerifier{PubKey: pub, ProviderID: "acme-pad2"}
env := signEnvelope(t, priv, LivenessAttestation{
ProviderID: "acme-pad2", UserID: "u-1", Score: 0.9, Timestamp: time.Now().Unix(),
})
att, err := v.Verify(env, Binding{UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute})
if err != nil {
t.Fatalf("verify: %v", err)
}
if att.UserID != "u-1" || att.Score != 0.9 {
t.Fatalf("unexpected attestation: %+v", att)
}
// Wrong provider id lock is rejected.
bad := Ed25519EnvelopeVerifier{PubKey: pub, ProviderID: "other"}
if _, err := bad.Verify(env, Binding{UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute}); err == nil {
t.Fatal("expected providerId mismatch rejection")
}
}
// TestVerifierRegistry confirms the white-label extension path: a deployment
// registers its own Verifier under a provider id and the server resolves it
// — including a fully custom scheme unrelated to Ed25519 envelopes.
func TestVerifierRegistry(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
RegisterVerifier("vendor-x", Ed25519EnvelopeVerifier{PubKey: pub, ProviderID: "vendor-x"})
t.Cleanup(func() { RegisterVerifier("vendor-x", nil) })
got, ok := LookupVerifier("vendor-x")
if !ok {
t.Fatal("vendor-x not registered")
}
env := signEnvelope(t, priv, LivenessAttestation{
ProviderID: "vendor-x", UserID: "u-9", Score: 0.95, Timestamp: time.Now().Unix(),
})
if _, err := got.Verify(env, Binding{UserID: "u-9", MinScore: 0.8, MaxAge: time.Minute}); err != nil {
t.Fatalf("registered verifier failed: %v", err)
}
// A wholly custom Verifier (no Ed25519 envelope) plugs in the same way.
RegisterVerifier("remote-attest", stubVerifier{})
t.Cleanup(func() { RegisterVerifier("remote-attest", nil) })
cv, ok := LookupVerifier("remote-attest")
if !ok {
t.Fatal("custom verifier not registered")
}
if _, err := cv.Verify("anything", Binding{UserID: "u-1"}); !errors.Is(err, errStub) {
t.Fatalf("custom verifier not invoked: %v", err)
}
// nil removes the entry.
RegisterVerifier("vendor-x", nil)
if _, ok := LookupVerifier("vendor-x"); ok {
t.Fatal("vendor-x should have been removed")
}
}
var errStub = errors.New("stub verifier called")
type stubVerifier struct{}
func (stubVerifier) Verify(string, Binding) (*LivenessAttestation, error) { return nil, errStub }