From 5435bfb52832a6ce2aaab502a7d1c16f0e8a5277 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sun, 13 Sep 2026 23:30:34 +0200 Subject: [PATCH] docs(f3): a row lock the shipped database does not take F3-R11 required a recovery code to be "matched and consumed atomically under a row lock". SQLite has no row lock. Laravel's SQLite grammar compiles lockForUpdate() to an empty string, so the call that appeared to satisfy the requirement emitted no SQL at all, and the requirement named a mechanism the shipped database does not implement. What made that worth changing rather than leaving decorative is the cost. F3-R13 requires a constant mismatch cost, which is ten bcrypt comparisons per attempt -- 3.5 seconds measured at rounds 12. The connection runs transaction_mode IMMEDIATE, taking the database-wide write lock at BEGIN, and both shells serve one request at a time. So a transaction held across the hashing freezes the whole application for three and a half seconds, from an endpoint reachable without any credential. The requirement now states the property instead of a mechanism: spend exactly one code, never spend a spent one, and keep the hashing out of the transaction that spends it. Atomicity is carried by a compare-and- set whose affected-row count is the answer, which needs no row lock and cannot be interleaved. Spec: F3-R11, F3-R13, GOV-R27 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Wessel Verheij --- .../f-platform/f3-auth-and-app-lock.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/10-functional/features/f-platform/f3-auth-and-app-lock.md b/10-functional/features/f-platform/f3-auth-and-app-lock.md index c01bf5e..ccd914c 100644 --- a/10-functional/features/f-platform/f3-auth-and-app-lock.md +++ b/10-functional/features/f-platform/f3-auth-and-app-lock.md @@ -78,7 +78,10 @@ so three paths exist: 1. **Recovery codes.** Ten single-use codes generated at account creation and shown **once**, drawn from a phone-readable alphabet that excludes visually ambiguous characters, hashed with the same scheme as passwords. Each code is - matched and consumed atomically inside one transaction under a row lock. Every + matched and consumed atomically: a redemption spends exactly one code, and a + code already spent cannot be spent again however many attempts are in flight. + The comparison itself is deliberately outside that transaction — see + [below](#a-row-lock-the-shipped-database-does-not-take). Every attempt — success or failure — writes an audit record, and a failure against an unknown username records no user, so the audit trail cannot be used to enumerate accounts. The mismatch message is constant regardless of whether the @@ -190,6 +193,28 @@ The lock screen, its verification endpoints, sign-out, and the mobile lock route are exempt from the lock. Biometric **enrolment** is deliberately not exempt: it needs the key that a locked session does not have. +### A row lock the shipped database does not take + +`F3-R11` said "under a row lock" until 2026-09-13. SQLite has no row lock, and +Laravel's SQLite grammar compiles `lockForUpdate()` to an empty string — asked +for the SQL, the driver returns `select * from "user_recovery_codes" where …` +and nothing more. The requirement named a mechanism the shipped database does +not implement, and the call that appeared to satisfy it was decorative. + +What the redemption actually costs is the reason this matters. A fixed ten +bcrypt comparisons run per attempt, so that response time cannot separate an +unknown username from a wrong code (F3-R13) — **3.5 seconds, measured.** The +connection runs `transaction_mode = IMMEDIATE`, which takes the database-wide +write lock at `BEGIN`, and both shells serve one request at a time. Holding +that transaction across the hashing therefore freezes the whole application for +three and a half seconds, and this endpoint needs no credential to reach. + +So the requirement now states the property rather than a mechanism: spend +exactly one code, never spend a spent one, and keep the hashing out of the +transaction that spends it. Atomicity is carried by a compare-and-set — a +single `UPDATE … WHERE id = ? AND used_at IS NULL`, whose affected-row count is +the answer — which needs no row lock and cannot be interleaved. + ## Edge cases | Situation | Behaviour | @@ -222,7 +247,7 @@ needs the key that a locked session does not have. | **F3-R8** | Ten single-use recovery codes MUST be generated at account creation and shown exactly once. | | **F3-R9** | Recovery codes MUST be hashed with the same scheme as passwords and MUST be distinct within a batch. | | **F3-R10** | The recovery alphabet MUST exclude visually ambiguous characters. | -| **F3-R11** | A recovery code MUST be matched and consumed atomically under a row lock. | +| **F3-R11** | A recovery code MUST be matched and consumed atomically: a redemption MUST spend exactly one code, and a code already spent MUST NOT be spendable again however many attempts are in flight. The hashing that decides the match MUST NOT be held inside the consuming transaction. | | **F3-R12** | Every recovery attempt MUST write an audit record, and a failure against an unknown username MUST record no user. | | **F3-R13** | The recovery mismatch message MUST be constant regardless of whether the username existed. | | **F3-R14** | A command-line reset path MUST exist as the last resort and MUST require access to the machine. |