mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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.)
242 lines
10 KiB
Go
242 lines
10 KiB
Go
package webauthn
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// 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:
|
|
//
|
|
// - Signature is valid for the configured provider public key
|
|
// - Subject (user) matches the authenticated caller
|
|
// - Timestamp is fresh (< MaxAge)
|
|
// - Score >= MinScore (PAD-2 default is 0.8)
|
|
// - R3-8: CredentialHash (if present) == sha256(enrollment public key)
|
|
// OR ChallengeID (if present) == the WebAuthn challenge being enrolled.
|
|
// Binds the attestation to the specific enrollment ceremony — a stolen
|
|
// envelope within the 2-minute validity window cannot be replayed to
|
|
// enroll an attacker-controlled public key.
|
|
//
|
|
// Clients MUST NOT send a bare liveness score — the server-to-server trust
|
|
// 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"`
|
|
UserID string `json:"userId"`
|
|
Score float64 `json:"score"`
|
|
Timestamp int64 `json:"timestamp"` // unix seconds
|
|
Nonce string `json:"nonce"`
|
|
// R3-8: at least one of these MUST be present when the server is in
|
|
// strict binding mode (BindingMode = BindingStrict). They bind the
|
|
// envelope to the specific WebAuthn enrollment it was minted for:
|
|
//
|
|
// - CredentialHash = sha256(uncompressed P-256 public key bytes),
|
|
// base64 (standard). Equivalent to the "I attested the device
|
|
// holding THIS key" claim.
|
|
// - ChallengeID = the WebAuthn challenge being enrolled, base64url.
|
|
// Equivalent to "I attested THIS ceremony".
|
|
//
|
|
// Empty fields mean "provider did not assert this binding." Verify
|
|
// returns an error if BindingMode requires one and the envelope
|
|
// supplies none, or if a supplied field does not match the opts.
|
|
CredentialHash string `json:"credentialHash,omitempty"`
|
|
ChallengeID string `json:"challengeId,omitempty"`
|
|
}
|
|
|
|
// BindingMode selects how strictly VerifyLiveness enforces the R3-8
|
|
// envelope→enrollment binding.
|
|
type BindingMode int
|
|
|
|
const (
|
|
// BindingStrict rejects any envelope that supplies neither
|
|
// CredentialHash nor ChallengeID. This is the only safe default for
|
|
// production biometric enrollment on mainnet.
|
|
BindingStrict BindingMode = iota
|
|
|
|
// BindingLax logs a warning (via opts.WarnFn) when neither field is
|
|
// supplied but does not reject. Intended ONLY for the transition
|
|
// window while the liveness provider rolls out the extended envelope. Tracked
|
|
// as a deployment blocker in the MPC LLM.md.
|
|
BindingLax
|
|
)
|
|
|
|
// LivenessOpts configures server-side verification.
|
|
type LivenessOpts struct {
|
|
PubKey ed25519.PublicKey
|
|
ProviderID string // optional: if non-empty, attestation.ProviderID must match
|
|
UserID string // attestation.UserID MUST match this (the authenticated caller)
|
|
MinScore float64
|
|
MaxAge time.Duration
|
|
Now func() time.Time // overridable for tests; nil → time.Now
|
|
|
|
// R3-8 binding inputs. When either is non-empty the verifier
|
|
// requires the envelope's corresponding field to match. In
|
|
// BindingStrict mode at least one must be non-empty OR the envelope
|
|
// MUST carry one (otherwise the call is rejected).
|
|
ExpectedCredentialHash string // base64(sha256(publicKeyBytes))
|
|
ExpectedChallengeID string // base64url(challengeBytes)
|
|
BindingMode BindingMode // default BindingStrict
|
|
WarnFn func(msg string)
|
|
}
|
|
|
|
// VerifyLiveness decodes the attestation envelope and returns the verified
|
|
// attestation. The envelope is JSON with `{"attestation":{...}, "sig":"b64"}`.
|
|
// The signature is Ed25519(canonicalJSON(attestation)).
|
|
func VerifyLiveness(envelopeB64 string, opts *LivenessOpts) (*LivenessAttestation, error) {
|
|
if opts == nil || len(opts.PubKey) == 0 {
|
|
return nil, errors.New("liveness: verifier not configured")
|
|
}
|
|
if opts.UserID == "" {
|
|
return nil, errors.New("liveness: expected user id required")
|
|
}
|
|
if opts.MinScore <= 0 {
|
|
return nil, errors.New("liveness: min score required")
|
|
}
|
|
if opts.MaxAge <= 0 {
|
|
opts.MaxAge = 2 * time.Minute
|
|
}
|
|
now := time.Now
|
|
if opts.Now != nil {
|
|
now = opts.Now
|
|
}
|
|
|
|
raw, err := base64.StdEncoding.DecodeString(envelopeB64)
|
|
if err != nil {
|
|
if r2, err2 := base64.URLEncoding.DecodeString(envelopeB64); err2 == nil {
|
|
raw = r2
|
|
} else {
|
|
return nil, errors.New("liveness: invalid envelope encoding")
|
|
}
|
|
}
|
|
var env struct {
|
|
Attestation json.RawMessage `json:"attestation"`
|
|
Sig string `json:"sig"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, errors.New("liveness: malformed envelope")
|
|
}
|
|
if len(env.Attestation) == 0 || env.Sig == "" {
|
|
return nil, errors.New("liveness: envelope missing fields")
|
|
}
|
|
|
|
sig, err := base64.StdEncoding.DecodeString(env.Sig)
|
|
if err != nil {
|
|
return nil, errors.New("liveness: invalid signature encoding")
|
|
}
|
|
if !ed25519.Verify(opts.PubKey, env.Attestation, sig) {
|
|
return nil, errors.New("liveness: signature verification failed")
|
|
}
|
|
// Parse the verified attestation after the signature check to avoid a
|
|
// JSON-only oracle.
|
|
var att LivenessAttestation
|
|
if err := json.Unmarshal(env.Attestation, &att); err != nil {
|
|
return nil, errors.New("liveness: malformed attestation")
|
|
}
|
|
|
|
if opts.ProviderID != "" && att.ProviderID != opts.ProviderID {
|
|
return nil, fmt.Errorf("liveness: providerId mismatch: %q", att.ProviderID)
|
|
}
|
|
if att.UserID != opts.UserID {
|
|
return nil, errors.New("liveness: userId mismatch")
|
|
}
|
|
if att.Score < opts.MinScore {
|
|
return nil, fmt.Errorf("liveness: score %.2f below threshold %.2f", att.Score, opts.MinScore)
|
|
}
|
|
ts := time.Unix(att.Timestamp, 0)
|
|
age := now().Sub(ts)
|
|
if age < 0 || age > opts.MaxAge {
|
|
return nil, fmt.Errorf("liveness: attestation too old: age=%s max=%s", age, opts.MaxAge)
|
|
}
|
|
|
|
// R3-8 / F4: envelope→enrollment binding. Only applies when the caller
|
|
// supplies a binding expectation (ExpectedCredentialHash and/or
|
|
// ExpectedChallengeID). Callers that don't care about binding (e.g.
|
|
// generic liveness checks unrelated to WebAuthn enrollment) are
|
|
// unaffected.
|
|
requiresBinding := opts.ExpectedCredentialHash != "" || opts.ExpectedChallengeID != ""
|
|
if requiresBinding {
|
|
// Mismatch is always fatal — a supplied-but-wrong value means
|
|
// the envelope was minted for a different ceremony.
|
|
if att.CredentialHash != "" && att.CredentialHash != opts.ExpectedCredentialHash && opts.ExpectedCredentialHash != "" {
|
|
return nil, errors.New("liveness: credentialHash mismatch — envelope not bound to this enrollment")
|
|
}
|
|
if att.ChallengeID != "" && att.ChallengeID != opts.ExpectedChallengeID && opts.ExpectedChallengeID != "" {
|
|
return nil, errors.New("liveness: challengeId mismatch — envelope not bound to this ceremony")
|
|
}
|
|
|
|
// F4 (2026-04-18): tighten LAX. Previously LAX accepted an
|
|
// envelope where EITHER field matched. That permitted a
|
|
// 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 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.
|
|
//
|
|
// Two tightenings (both layered on top of the existing
|
|
// "mismatch always rejects" guards above):
|
|
//
|
|
// 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 the liveness provider
|
|
// has already rolled out the extended envelope.
|
|
//
|
|
// 2. credentialHash is mandatory at minimum in LAX. The
|
|
// envelope MUST carry CredentialHash whenever the server
|
|
// expects one. challengeId alone is insufficient — it can
|
|
// be observed and replayed faster than credentialHash can
|
|
// be forged against an attacker-controlled pubkey.
|
|
//
|
|
// STRICT already enforced the missing-both rejection; these
|
|
// rules add the LAX-only refinements.
|
|
if att.CredentialHash != "" && att.ChallengeID != "" &&
|
|
opts.ExpectedCredentialHash != "" && opts.ExpectedChallengeID != "" {
|
|
// Both sides have both fields — require both to match.
|
|
// The prior "mismatch rejects" guards have already caught
|
|
// any individual mismatch; this is a belt-and-braces
|
|
// assertion that closes the pathological both-supplied-
|
|
// one-blank-expected corner.
|
|
if att.CredentialHash != opts.ExpectedCredentialHash ||
|
|
att.ChallengeID != opts.ExpectedChallengeID {
|
|
return nil, errors.New("liveness: binding mismatch — both credentialHash and challengeId must match when both are present")
|
|
}
|
|
}
|
|
|
|
if opts.BindingMode == BindingLax &&
|
|
opts.ExpectedCredentialHash != "" &&
|
|
att.CredentialHash == "" {
|
|
// F4: LAX + expected credentialHash + envelope omits it =
|
|
// reject. This is the "challengeId-only replay" gate. The
|
|
// envelope must commit to the enrollment pubkey hash.
|
|
return nil, errors.New("liveness: credentialHash required in LAX mode — envelope without credentialHash is replay-vulnerable")
|
|
}
|
|
|
|
// A "matching" envelope has at least one field that matches its
|
|
// corresponding expected value. In STRICT mode, a missing field
|
|
// is rejected; in LAX mode we warn and accept.
|
|
matched := false
|
|
if opts.ExpectedCredentialHash != "" && att.CredentialHash == opts.ExpectedCredentialHash {
|
|
matched = true
|
|
}
|
|
if opts.ExpectedChallengeID != "" && att.ChallengeID == opts.ExpectedChallengeID {
|
|
matched = true
|
|
}
|
|
if !matched {
|
|
if opts.BindingMode == BindingStrict {
|
|
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 the liveness provider rollout")
|
|
}
|
|
}
|
|
}
|
|
return &att, nil
|
|
}
|