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