R3-8: bind liveness envelope to WebAuthn credential + challenge

A stolen  attestation (within its 2-minute validity window)
could be replayed against a different WebAuthn enrollment because the
envelope only attested (userID, score, timestamp) — it said nothing
about WHICH public key the user was actually enrolling.

Fix: extend LivenessAttestation with two optional binding fields:

  credentialHash = sha256(publicKey) [base64-std]
  challengeId    = the WebAuthn challenge being enrolled [base64url]

handleBiometricEnroll computes both values from the real enrollment
data (consumed challenge bytes + submitted public key) and passes them
as ExpectedCredentialHash / ExpectedChallengeID to VerifyLiveness.
Mismatches are always fatal. A missing binding is fatal in STRICT
mode (the default) and warns-and-accepts in LAX.

BindingMode is server-configured via MPC_LIVENESS_BINDING=strict|lax.
Production mainnet runs STRICT —  must ship the extended
envelope as a deployment blocker. LAX exists solely for the rollout
window; every permissive accept emits an `mpc.biometric.binding_warn`
log line so operators can see rollout progress.

Tests added in pkg/webauthn/liveness_test.go:
  - R38_AcceptsMatchingCredentialHash
  - R38_RejectsMismatchedCredentialHash
  - R38_RejectsMissingBindingStrict
  - R38_LaxWarnsOnMissing
  - R38_AcceptsMatchingChallengeID
  - R38_RejectsMismatchedChallengeID

Full ./pkg/api/ suite green; the existing pre-R3-8 LAX-style liveness
tests still pass (they supply no Expected* values → the binding path
is a no-op for them).
This commit is contained in:
2026-04-18 17:30:14 -07:00
parent 9713f68c9d
commit 0313b7333d
5 changed files with 285 additions and 15 deletions
+5 -1
View File
@@ -471,7 +471,11 @@ make e2e-test
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.
32. **R2-3 Tightened API-key gate**: `requireRoleOrAPIPermission([]string{"owner","admin","signer"}, "mpc:sign")` replaces `requireRole("owner","admin","signer","api")` on `/v1/mpc/sign` and `/v1/mpc/settlement/sign`. Role `api` passes only if the key holds the named permission (or `*`). A bare API key with permissions=[] is rejected.
32. **R2-3 Tightened API-key gate**: `requireRoleOrAPIPermission([]string{"owner","admin","signer"}, "mpc:sign")` replaces `requireRole("owner","admin","signer","api")` on `/v1/mpc/sign` and `/v1/mpc/settlement/sign`. Role `api` passes only if the key holds the named permission. A bare API key with permissions=[] is rejected.
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. Tests in `pkg/webauthn/liveness_test.go` — matching-hash accept, mismatch-rejects, strict-rejects-missing, lax-warns-missing, challengeId accept/reject.
### Consensus-Embedded Transport (Jan 2026)
+47 -14
View File
@@ -11,10 +11,12 @@ package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
@@ -26,6 +28,14 @@ import (
"github.com/luxfi/mpc/pkg/webauthn"
)
// logBiometricBindingWarning records a structured warning when the R3-8
// envelope→enrollment binding was weak (e.g. 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) {
log.Printf("mpc.biometric.binding_warn org=%s user=%s msg=%q", orgID, userID, msg)
}
var errUnknownEncoding = errors.New("unknown encoding (must be hex|base64)")
func hexDecodeSafe(s string) ([]byte, error) {
@@ -648,27 +658,50 @@ func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
return
}
// Verify the server-signed liveness attestation. Fails closed.
const minLivenessScore = 0.8
if _, err := webauthn.VerifyLiveness(req.LivenessAttestation, &webauthn.LivenessOpts{
PubKey: s.PubKey,
ProviderID: s.ProviderID,
UserID: userID,
MinScore: minLivenessScore,
MaxAge: 2 * time.Minute,
}); err != nil {
writeError(w, http.StatusForbidden, "liveness attestation rejected")
return
}
// Atomically consume the WebAuthnCredential challenge row and capture the
// random challenge bytes we sent.
// random challenge bytes we sent. We do this BEFORE liveness verification
// so we have the challenge bytes to bind the attestation against (R3-8).
chalBytes, ok, err := s.consumeBiometricChallenge(r.Context(), orgID, userID, req.CredentialID)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "invalid or expired challenge")
return
}
// 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.
pubKeyBytes, decErr := base64.StdEncoding.DecodeString(req.PublicKey)
if decErr != nil {
writeError(w, http.StatusBadRequest, "invalid publicKey encoding")
return
}
credentialHashRaw := sha256.Sum256(pubKeyBytes)
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.
const minLivenessScore = 0.8
if _, err := webauthn.VerifyLiveness(req.LivenessAttestation, &webauthn.LivenessOpts{
PubKey: s.PubKey,
ProviderID: s.ProviderID,
UserID: userID,
MinScore: minLivenessScore,
MaxAge: 2 * time.Minute,
ExpectedCredentialHash: expectedCredentialHash,
ExpectedChallengeID: expectedChallengeID,
BindingMode: s.livenessBindingMode,
WarnFn: func(msg string) {
s.logBiometricBindingWarning(orgID, userID, msg)
},
}); err != nil {
writeError(w, http.StatusForbidden, "liveness attestation rejected")
return
}
// Verify the WebAuthn ceremony against:
// - the random challenge bytes we issued (not the DB row ID),
// - the origin allowlist,
+16
View File
@@ -16,6 +16,7 @@ import (
"github.com/luxfi/mpc/pkg/custody"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/txtracker"
"github.com/luxfi/mpc/pkg/webauthn"
)
// MPCBackend is the interface the API layer uses to trigger MPC operations.
@@ -74,6 +75,12 @@ type Server struct {
// attached to /v1/mpc/biometric/enroll. Nil disables the endpoint.
PubKey ed25519.PublicKey
ProviderID 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.
// Configured via MPC_LIVENESS_BINDING=strict|lax (default strict).
livenessBindingMode webauthn.BindingMode
router chi.Router
replayGuard *replayGuard
@@ -148,6 +155,14 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
}
}
// 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.
bindingMode := webauthn.BindingStrict
if strings.ToLower(strings.TrimSpace(os.Getenv("MPC_LIVENESS_BINDING"))) == "lax" {
bindingMode = webauthn.BindingLax
}
s := &Server{
db: database,
mpc: mpcBackend,
@@ -158,6 +173,7 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
webauthnOrigins: webauthnOrigins,
PubKey: sgKey,
ProviderID: os.Getenv("MPC__PROVIDER_ID"),
livenessBindingMode: bindingMode,
replayGuard: newReplayGuard(),
Events: NewEventBus(),
}
+81
View File
@@ -17,6 +17,11 @@ import (
// - 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 signature, not a float in the body. Without this
@@ -27,8 +32,40 @@ type LivenessAttestation struct {
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 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
@@ -37,6 +74,15 @@ type LivenessOpts struct {
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
@@ -107,5 +153,40 @@ func VerifyLiveness(envelopeB64 string, opts *LivenessOpts) (*LivenessAttestatio
if age < 0 || age > opts.MaxAge {
return nil, fmt.Errorf("liveness: attestation too old: age=%s max=%s", age, opts.MaxAge)
}
// R3-8: 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")
}
// 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); must emit the extended envelope")
}
if opts.WarnFn != nil {
opts.WarnFn("liveness: envelope carries no binding — accepted in LAX mode; tracked rollout")
}
}
}
return &att, nil
}
+136
View File
@@ -106,3 +106,139 @@ func TestLiveness_RejectsWrongProvider(t *testing.T) {
t.Fatalf("want providerId mismatch, got %v", err)
}
}
// R3-8: envelope→enrollment binding. A stolen envelope must not be
// replayable against a different enrollment.
// Accept: envelope credentialHash matches the enrollment pubkey hash.
func TestLiveness_R38_AcceptsMatchingCredentialHash(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
CredentialHash: "ABC123==",
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedCredentialHash: "ABC123==",
BindingMode: BindingStrict,
})
if err != nil {
t.Fatalf("matching credentialHash should accept: %v", err)
}
}
// Reject: envelope credentialHash present but does not match (replay
// attempt against a different public key).
func TestLiveness_R38_RejectsMismatchedCredentialHash(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
CredentialHash: "EVIL===",
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedCredentialHash: "VICTIM==",
BindingMode: BindingStrict,
})
if err == nil || !strings.Contains(err.Error(), "credentialHash mismatch") {
t.Fatalf("mismatched credentialHash must reject: got %v", err)
}
}
// Reject (STRICT): server expected binding, envelope carries none.
// This is the replay-prevention gate.
func TestLiveness_R38_RejectsMissingBindingStrict(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
// no CredentialHash, no ChallengeID — pre-R3-8 envelope
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedCredentialHash: "VICTIM==",
BindingMode: BindingStrict,
})
if err == nil || !strings.Contains(err.Error(), "no binding") {
t.Fatalf("STRICT + missing binding must reject: got %v", err)
}
}
// LAX: server expected binding, envelope missing — accept with warning.
// Used during extended-envelope rollout.
func TestLiveness_R38_LaxWarnsOnMissing(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
}
env := signEnvelope(t, priv, att)
var warned string
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedCredentialHash: "VICTIM==",
BindingMode: BindingLax,
WarnFn: func(m string) { warned = m },
})
if err != nil {
t.Fatalf("LAX should accept missing binding: %v", err)
}
if warned == "" {
t.Fatalf("LAX must emit a warn message on missing binding")
}
}
// Accept: envelope challengeId matches the WebAuthn challenge id.
func TestLiveness_R38_AcceptsMatchingChallengeID(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
ChallengeID: "chal-xyz",
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedChallengeID: "chal-xyz",
BindingMode: BindingStrict,
})
if err != nil {
t.Fatalf("matching challengeId should accept: %v", err)
}
}
// Reject: envelope challengeId mismatch.
func TestLiveness_R38_RejectsMismatchedChallengeID(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
att := LivenessAttestation{
ProviderID: "",
UserID: "u-1",
Score: 0.9,
Timestamp: time.Now().Unix(),
ChallengeID: "chal-ATTACKER",
}
env := signEnvelope(t, priv, att)
_, err := VerifyLiveness(env, &LivenessOpts{
PubKey: pub, UserID: "u-1", MinScore: 0.8, MaxAge: time.Minute,
ExpectedChallengeID: "chal-VICTIM",
BindingMode: BindingStrict,
})
if err == nil || !strings.Contains(err.Error(), "challengeId mismatch") {
t.Fatalf("mismatched challengeId must reject: got %v", err)
}
}