Red round 4 flagged (F4, MEDIUM-HIGH) that the previous LAX binding
mode in VerifyLiveness permitted a cross-ceremony replay: an attacker
who observed a fresh WebAuthn challenge could trick SecureGate into
signing a liveness envelope binding to the challenge alone (no
credentialHash), then replay that envelope against any enrollment for
the same userId that happened to reuse the challengeId-only
expectation.
Two LAX-only tightenings in pkg/webauthn/liveness.go (STRICT already
enforced the missing-both rejection; this sharpens LAX):
1. credentialHash is a hard floor. If the server expects
credentialHash and the envelope omits it, reject with
"credentialHash required in LAX mode — envelope without
credentialHash is replay-vulnerable". challengeId alone cannot
satisfy the binding requirement in LAX — challengeId can be
observed and replayed; credentialHash commits to the specific
pubKey being enrolled.
2. When the envelope supplies BOTH credentialHash AND challengeId AND
the server expects both, require BOTH to match. No more "either-or"
loophole. The existing mismatch-rejects guards already caught
individual mismatches; this closes the pathological
both-supplied-one-blank-expected corner.
handleBiometricEnroll always commits ExpectedCredentialHash (derived
from the enrollment pubKey via sha256), so the LAX floor always
engages in practice for WebAuthn enrollment — this hardens what
is already the exercised path.
Test coverage (pkg/webauthn/liveness_test.go):
- TestLiveness_R38_LaxRejectsMissingCredentialHash — primary F4 gate:
envelope with only challengeId, missing credentialHash → reject
- TestLiveness_R38_LaxAcceptsBothMatch — happy path (extended envelope
after SecureGate rollout)
- TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent
- TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent
- TestLiveness_R38_LaxAcceptsCredentialHashOnly — current SecureGate
envelope shape still accepted
- TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected —
replaces the old lax-warns-missing path
- TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly — warn path still
available when credentialHash is not expected
Full pkg/webauthn suite green (18/18). pkg/api regression tests pass.
pkg/custody pre-existing failures unrelated to this change (SQLite
json_extract missing, blame predates F4).
LLM.md entry R35 documents the full set. Cut v1.7.1 on merge.
A stolen SecureGate 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 — SecureGate 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).
R2-1: biometric enrollment is a forged-enrollment oracle fixed.
- pkg/webauthn/verify.go: full WebAuthn verification — compare against the
random challenge bytes we issued (not the DB row ID), origin allowlist,
rpIDHash equality, UP+UV authenticator-flag enforcement, ceremony type.
- pkg/webauthn/liveness.go: SecureGate-signed liveness attestation. Ed25519
envelope over {providerId, userId, score, timestamp, nonce}. MPC verifies
against MPC_SECUREGATE_PUBKEY_ED25519 and enforces userId match, score
floor, max age. Body-supplied livenessScore is never trusted again.
- If SecureGate pubkey is unconfigured, handleBiometricEnroll returns 503.
- 14 new tests cover each rejection path.
R2-2: CAS-under-Postgres. The previous in-Go check-then-write +
unconditional UPSERT works on SQLite (writeMu) but loses updates under
Postgres READ COMMITTED. Switched to SELECT ... FOR UPDATE inside READ
COMMITTED tx via orm.RunInTransactionWith + orm.GetForUpdate[T]. Requires
hanzoai/[email protected] (tagged today).
- Session consume: operationLimit strictly respected under concurrency;
valueAccum computed under the lock.
- Operation approve: ApprovedBy append under the row lock, quorum
finalization transitions at most once even under racing approvers.
- pkg/db.New now supports postgres:// DSNs.
- New Postgres-backed regression tests in cas_postgres_test.go —
auto-skipped when no TEST_PG_DSN reachable; 10 concurrent consumers
against operationLimit=5 yields exactly 5 successes and 5 denials.
Bumps github.com/hanzoai/orm v0.3.2 → v0.4.0.
Test plan:
go test -tags sqlite_json1 -count=10 \
-run 'CASUnderRace|CASUnderRace_Postgres|RequireRoleOrAPIPermission' \
./pkg/api/ ./pkg/webauthn/