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