Stage B - PIN-Sealing the phoneHash -> userId Resolution (encryptedUserIdBlob) โ
Date: 2026-06-23 Service: services/api/auth (Cloud Run, Express) ยท Client: apps/web/src/screens/auth/{PhonePinSignup,PhonePinLogin}.jsx, apps/web/src/components/ForgotPassphraseModal.jsx, apps/web/src/lib/encryption.jsStatus: Plan for operator review. No code written. Canonical refs: docs/privacy/SEALED_IDENTITY.md ยง7, ยง9, ยง11.0, ยง11.1, ยง11.5, ยง11.7; the OTP slice plan docs/planning/plans/2026-06-22-otp-provider-prelude-slice.md; the prior spike sketch docs/planning/plans/2026-05-10-sealed-identity-spike.md ยงB1-B7 (this plan supersedes it - the spike predates the custom-token bootstrap and underspecifies reuse/migration).
This plan was produced by reading the live code, not the doc summaries, and verifying each load-bearing claim. Where I could not verify something, it is flagged [UNVERIFIED].
0. The decision you need to make first โ
Stage B is the last of the three axes in ยง11.7: Stage A (phone -> phoneHash) is DONE; the custom-token bootstrap (app-controlled random UID + phone-less Auth record) is BUILT, dormant behind OTP_PROVIDER; this work seals phoneHash -> userId itself under the user's PIN. The red-team below found that "just encrypt the userId and ship it" hides four genuine forks. Decide these before any code:
Fork 1 - Where does decryption happen: client-side (recommended) or server-side? โ
The zero-knowledge axiom forces client-side. The sealing key must derive from material the server never holds in usable form. The client already derives entropy by decrypting encryptedSeed with phone+PIN (unlockEncryptionWithPIN, encryption.js:372). The blob key is HKDF(entropy, "lantern-userid-blob-v1"); the client decrypts encryptedUserIdBlob -> userId, then calls /token exactly as today. The server only ever sees the ciphertext blob at rest and the plaintext userId after the client has proven PIN knowledge (it already receives userId in the /token body today - phone.js:156). Server-side decryption is rejected: it would require the server to hold the entropy or the blob key, defeating the seal.
Fork 2 - Eager or lazy migration of existing rows? โ
Lazy, at next successful login, is the only option consistent with the operator axioms. The server cannot encrypt a userId under a PIN it does not have, so there is no batch backfill. Existing users/{userId} docs carry phoneHash in plaintext (the doc id is the userId). The transitional state is a dual-read: a phone resolves either via a sealed auth_lookup/{phoneHash} row (new/migrated) or via the legacy users.where(phoneHash) query (un-migrated). At next login the client computes the blob and the server writes the auth_lookup row, then drops phoneHash from the user doc. The genuine fork inside this fork is what to do with never-returning users (Fork 3).
Fork 3 - Never-returning users: leave forever, or force a cutover? โ
Under "data loss over leak," the conservative answer is leave them in legacy form indefinitely and accept that the legacy users.phoneHash index remains a phone->userId resolution path for exactly those rows. The alternative - a hard cutover that deletes users.phoneHash for all rows on a date - would brick login for anyone who hasn't returned (they can no longer be found by phone), which is data loss inflicted by us, not chosen by the user. Recommendation: keep the legacy path for un-migrated rows, publish the honest residual ("rows belonging to users who have not logged in since Stage B remain resolvable"), and only force-cutover under a ยง5/ยง11.5 market-entry trigger (UK IPA / Australia TOLA), where the residual is unacceptable and the data-loss is the lesser evil. DECIDED 2026-06-23 (operator): leave never-returners in legacy form indefinitely (NO forced cutover); revisit only if a UK IPA / Australia TOLA market-entry trigger later makes the residual unacceptable. Rationale: data-loss-over-leak (never brick a returning user), and dev currently has no real dormant long-tail to seal.
Fork 4 - Does signup-time "reuse the existing UID" survive sealing, and if not, what replaces it? โ
It does not survive in its current form. Today verify-otp (phoneOtp.js:214) does users.where(phoneHash) and silently reuses snap.docs[0].id as the UID for a returning user. That is a server-side phoneHash->userId resolution - exactly what Stage B forbids. Replacement: signup with an existing phoneHash becomes a PIN-gated recovery path - the server proves the phone via OTP, sees a sealed row exists, and returns "account exists; enter your PIN to recover," handing the client the sealed blob to decrypt. The server never resolves the UID; the user does, with their PIN. If they have neither PIN nor recovery phrase, they start over (fresh random UID, old sealed row tombstoned) - the ยง11.3 lose-don't-leak path. This is the biggest behavioral change in the plan and is detailed in ยง3.2.
My recommendation if you want a single default: client-side decryption (Fork 1), lazy migration (Fork 2), leave-never-returners (Fork 3) with a documented residual, PIN-gated recovery replacing silent reuse (Fork 4). Ship behind STAGE_B_SEALED_USERID_ENABLED, dev-first, dual-read throughout.
1. What is server-readable vs opaque after Stage B โ
Verified against the live resolution sites. There are exactly four server paths that resolve a phone (or phoneHash) to a userId today, all via users.where(...) + doc.id:
| Site | File:line | What it resolves | Stage B disposition |
|---|---|---|---|
| Login lookup | phone.js:112 | phoneHash/phone -> doc.id (returned to client as userId) | Sealed: return encryptedUserIdBlob, never userId |
| Signup uniqueness + reuse | phoneOtp.js:214, phoneCreateUser.js:195 | phoneHash -> existing doc.id | Membership-only (uniqueness) + PIN-gated recovery (reuse) |
| Phone reclaim | phoneRecycling.js:91 | phoneHash -> doc.id of the dormant account | Stays resolvable for legacy rows; sealed rows need a redesign - see ยง3.5 |
| Phone-admin lookup | phoneAdmin.js:81 | phoneHash -> admin doc | Out of scope - staff accounts are never sealed (they have a role; see ยง6) |
Stays server-readable (by design):
phoneHashmembership - "does a row exist for this phone?" (auth_lookup/{phoneHash}.get()is a yes/no). This is the ยง11.5 residual #5 existence oracle, accepted.- Ban status -
isPhoneBanned(phoneHash)is a pure membership lookup onbanned_accountsand never touches the userId resolution (verified:bannedAccounts.service.js:55-86queries onlybanned_accounts, neverusers). Survives unchanged. - Rate-limit keys -
checkKeyDurable('send-otp:'+phone)etc. key on the normalized phone, not the userId. Unchanged. - The lockout counter and
authProofHash- live onusers/{userId}(customToken.service.js:78,issueCustomToken). These are keyed by userId, which the client supplies post-decryption. Unchanged.
Becomes opaque:
- The
userIdfor a phone. After Stage B,auth_lookup/{phoneHash}containsencryptedUserIdBlob(AES-256-GCM ciphertext),phoneSalt,encryptedSeed,authProofHash, KDF params, and timestamps - but not the plaintext userId. Theusers/{userId}doc loses itsphoneHashfield once migrated, so there is no longer any document linking the phone-side index to the userId-side data in plaintext.
2. Schema โ
2.1 New collection: auth_lookup/{phoneHash} โ
Doc id = the v1:<hex> phoneHash (the same value computePhoneHash returns; deterministic, so the server can .doc(phoneHash).get() directly - no query, a single point read, which also tightens the existence oracle to one document).
auth_lookup/{phoneHash}:
encryptedUserIdBlob string // base64( iv(12) || AES-256-GCM(userId) ), key = HKDF(entropy, "lantern-userid-blob-v1")
blobKdf string // "hkdf-sha256-v1" - version tag so we can rotate the derivation
phoneSalt string // base64, copied from users doc (client needs it pre-userId to decrypt encryptedSeed)
encryptedSeed string // base64, copied - the wrapped entropy
authProofHash string // hex, copied - login proof verifier
lanternName string // copied - needed for the lookup response UX (see ยง3.6 red-team on length-correlation)
authMethod string // "phone_pin"
createdAt Timestamp
updatedAt Timestamp
schemaVersion number // 1Design notes:
encryptedSeed+phoneSaltMUST live here, not (only) on the user doc, because login needs them before it knows the userId (the client decryptsencryptedSeedto get the entropy that yields the userId). This is a deliberate duplication of two already-public-by-design fields.encryptedSeedis PIN-wrapped (we cannot decrypt it);phoneSaltis public-by-design (encryption.js:13). Neither weakens the seal.- The doc id is the phoneHash, not the userId, and the doc contains no plaintext userId - so a full DB dump of
auth_lookupyields {phoneHash, ciphertext, public salt, PIN-wrapped seed}. There is no row that maps phoneHash->userId. - Firestore security rules: server-only. No client reads or writes of
auth_lookup(all access via the auth service with Admin SDK, which bypasses rules). A client must never be able to read a foreign row (that would let an attacker harvestencryptedSeed/phoneSaltfor offline PIN-cracking - the same primitive already flagged inphone.js:84SECURITY TODO; do not regress it). [VERIFY] the Firestore rules file disallowsauth_lookupentirely - locatefirestore.rulesand add a deny rule.
2.2 Changes to users/{userId} โ
| Field | Before B | After B (migrated row) |
|---|---|---|
| doc id | userId (random UUID on bootstrap path, Firebase UID on legacy) | unchanged |
phoneHash | present (the resolution index) | removed once the auth_lookup row is written and confirmed |
phoneSalt, encryptedSeed, authProofHash | present | kept (the user doc is still read at /token time by issueCustomToken, keyed by userId) |
| everything else | - | unchanged |
Removing users.phoneHash is the act that severs the legacy resolution path for that row. Until it is removed, the row is dual-resolvable (legacy + sealed). See ยง4 for the dual-read state machine and the ordering that prevents a lockout window.
2.3 New collection (migration safety): auth_lookup_tombstones/{phoneHash} - optional โ
When a user "starts over" (Fork 4), the old sealed row is deleted and a fresh UID minted. To prevent a race where the old blob is resurrected, write a short-TTL tombstone. [Decide] whether this is needed or whether a transactional delete-then-create on auth_lookup/{phoneHash} (same doc id, so naturally serialized) suffices. I lean toward the latter - the doc id is the phoneHash, so create/delete on it is atomic per Firestore - making the tombstone collection unnecessary.
3. Per-path design โ
3.1 Client crypto additions (apps/web/src/lib/encryption.js) โ
Add a sibling to the existing two-layer model. The entropy is already the root secret; we derive a third, independent key from it for the blob.
deriveUserIdBlobKey(entropy): // HKDF-SHA256, info="lantern-userid-blob-v1", salt=phoneSalt OR fixed
-> CryptoKey (AES-256-GCM) // distinct key-space from the encryption key and wrapping key
encryptUserIdBlob(userId, entropy): // returns base64(iv||ciphertext)
decryptUserIdBlob(blobB64, entropy): // returns userId, throws on wrong entropy (GCM auth tag)Notes:
- Use HKDF, not PBKDF2, for the blob key. The entropy is already high-entropy random (128-bit BIP39); PBKDF2's iteration stretching is pointless and slow here. The existing
deriveAESKeyuses PBKDF2 because its inputs (phone:pin) are low-entropy; the blob key's input (entropy) is not. WebCrypto supports HKDF natively (deriveKeywithname:'HKDF'). TheblobKdf:"hkdf-sha256-v1"field records this so a future change is migratable. - Both the PIN path and the recovery-phrase path must be able to derive the blob key, because recovery (
ForgotPassphraseModal.jsx) also needs to resolve the userId. Both already land on the sameentropy(unlockEncryptionWithPINandunlockEncryptionWithRecoveryPhraseboth setlastDerivedEntropy,encryption.js:398/:437). SodecryptUserIdBlob(blob, entropy)works identically on both paths. This is the key reason recovery does not introduce a server backdoor (ยง3.4). - Wrong PIN -> GCM auth-tag failure -> throw. The client cannot get a wrong
userIdout; it gets nothing. NouserIdleakage on bad PIN (red-team B/spike ยงB5).
3.2 Signup / verify (verify-otp + createUser) - the reuse -> recovery change โ
The current verify-otp (phoneOtp.js:207-242) does three things on a phoneHash hit: refuses role accounts, then silently reuses the existing UID. Under Stage B, silent reuse is the forbidden resolution. New state machine:
verify-otp (code already confirmed, phoneHash computed, ban-checked):
read auth_lookup/{phoneHash}.get() // point read, membership
โโ NOT FOUND, and no legacy users.where(phoneHash) row:
โ -> brand-new signup. Mint fresh random UID (crypto.randomUUID()).
โ Write marker. Return { proof, customToken, expiresInSec, mode:'create' }.
โ
โโ FOUND (sealed row exists) OR legacy users.phoneHash row exists:
โ -> returning user. Do NOT mint a session for a new UID.
โ Return { mode:'recover', sealed:{ encryptedUserIdBlob, blobKdf,
โ phoneSalt, encryptedSeed, authProofHash, lanternName } }
โ (for a legacy row that has no auth_lookup yet, return the same
โ shape sourced from the users doc - see migration ยง4).
โ NO customToken, NO proof yet.On mode:'recover', the client flow diverts out of signup into a recovery-style PIN entry:
- Client has
{ encryptedSeed, phoneSalt }. User enters their PIN. unlockEncryptionWithPIN-> entropy.decryptUserIdBlob-> userId.computeAuthProofHash(entropy).- Client calls
/auth/phone/token { userId, proofHmac }exactly as login does. Server verifiesauthProofHash, mints the custom token. User is in.
What if a phoneHash row exists but the user enters a wrong/forgotten PIN?
- Wrong PIN:
decryptUserIdBlobthrows (GCM). Client shows "Incorrect PIN," increments the client attempt counter (pinAttempts.js). It cannot call/tokenbecause it has no userId, so the server lockout (which keys on userId,customToken.service.js:98) is never reached - meaning the server-side brute-force lockout does not protect the recovery-at-signup path. This is a real gap the current silent-reuse design didn't have. Mitigation: keep the existing durable per-phoneverify-otprate limit (phoneOtp.js:160, 10/10min) and add a durable per-phoneHash "recover" attempt counter that bounds blob-decrypt attempts independent of the userId. (Red-team Security #1, folded in.) - Forgotten PIN: the user clicks "Forgot PIN?" -> the existing
ForgotPassphraseModalrecovery-phrase flow (ยง3.4). If they have no recovery phrase either, they choose Start over (ยง3.3).
createUser under sealing. createUser keeps its proof path (phoneCreateUser.js:93) and its transactional phoneHash uniqueness check (:195), but in the brand-new-signup case it now also writes the auth_lookup/{phoneHash} row in the same transaction that writes users/{userId}. The client supplies encryptedUserIdBlob (it has the entropy from signup) in the createUser body. The uniqueness check moves to auth_lookup doc-existence (tx.get(authLookupRef) - a point read, cheaper and race-free vs the users.where(phoneHash).limit(5) scan) and keeps the legacy users.where(phoneHash) check during the dual-read window so a half-migrated duplicate can't slip through. The users doc is written without phoneHash on the sealed path (the link lives only in auth_lookup, and only as ciphertext).
Subtlety to get right: the
auth_lookuprow needsencryptedUserIdBlob, which the client must compute and send. The signup client already holds the entropy (getLastDerivedEntropy(), used atPhonePinSignup.jsx:757). AddencryptedUserIdBlob = await encryptUserIdBlob(user.uid, entropy)to thecreatePhoneUserbody alongside the existing fields. [VERIFY] theCreateUserBodyzod schema (phoneCreateUser.js:54) gainsencryptedUserIdBlob+blobKdfas required-on-sealed-path fields.
3.3 Start-over (lost PIN + no recovery phrase) - ยง11.3 lose-don't-leak made real โ
From mode:'recover', the client offers "I don't have my PIN or recovery phrase - start fresh." This:
- Re-runs the ban check (a lost PIN must not become ban-evasion; ยง11.3, ยง11.7 confirmed no-bypass).
- Server transactionally deletes
auth_lookup/{phoneHash}and tombstones/abandons the oldusers/{oldUserId}(do not hard-delete the old user doc here without the deletion cascade - route through the existing deletion path so userId-keyed data is cleaned; [VERIFY] the cascade-delete service exists per PRIVACY_HARDENING_ROADMAP Sprint D). - Mints a fresh random UID and proceeds as brand-new signup.
- UX states bluntly: "Your old connections, chats, and saved frens are gone." (Matches the ยง11.3 mandate and the existing signup terms copy at
PhonePinSignup.jsx:1133.)
The old encrypted data is now unreachable by anyone (the only keys were the user's). That is the intended outcome.
3.4 Login (/auth/phone/lookup + /auth/phone/token) โ
/auth/phone/lookup (phone.js:75) changes its response under the flag:
selectLookupResponse(phoneHash):
read auth_lookup/{phoneHash}.get()
โโ FOUND (sealed): return { exists:true, sealed:true,
โ encryptedUserIdBlob, blobKdf, phoneSalt,
โ encryptedSeed, authProofHash?, lanternName }
โ // NOTE: no userId
โโ NOT FOUND: fall back to legacy users.where(phoneHash) (dual-read window)
โโ FOUND (legacy): return today's shape { exists:true, userId, phoneSalt,
โ encryptedSeed, lanternName, authMethod } // userId still present
โโ empty: { exists:false }Client (PhonePinLogin.jsx:118) branches on sealed:
- Sealed: decrypt
encryptedSeedwith phone+PIN -> entropy ->decryptUserIdBlob-> userId ->computeAuthProofHash->/token { userId, proofHmac }. One extra client-side decrypt (~sub-ms; HKDF + one AES-GCM open). No extra round-trip - the blob ships in the same lookup response. - Legacy: today's path, unchanged. After a successful legacy login, the client lazy-migrates (ยง4): it computes
encryptedUserIdBloband POSTs it to a new/auth/phone/sealendpoint, which writes theauth_lookuprow and dropsusers.phoneHash.
/auth/phone/token (phone.js:154) is unchanged. It already takes { userId, proofHmac } and never needed the phone. It reads users/{userId} directly (keyed by the userId the client supplies). Add one thing the ยง11.7 residual flagged: an isPhoneBanned gate is currently absent here (residual #2, "one-line fix") - but note that the /token path no longer has the phone (only userId), so a phoneHash ban check would require the client to send the phone or the server to store the phoneHash on the user doc (which Stage B removes). Resolution: rely on the userId-level ban (users/{userId}.banned + disabled Firebase Auth account, moderation.js) at /token, and keep the phoneHash-level ban at the verify-otp/createUser/recover gates where the phone is present. Document that a phone-hash-only ban (no userId disable) does not block a returning login of an already-provisioned sealed account - but moderation policy should always disable the userId too (ยง6).
3.5 Phone reclaim (phoneRecycling.js) โ
Reclaim resolves phoneHash -> the dormant account's userId (:91) to check lastActiveAt and start a grace period. Under sealing this resolution is exactly what we removed. Options:
- (a) Legacy rows: reclaim keeps working (the
users.phoneHashrow still exists for un-migrated/dormant accounts - and dormant-by-definition means they haven't logged in, so they're never migrated; the legacy path is precisely the one that survives). This is convenient: the accounts eligible for reclaim are the ones that, by Fork 3, retain the legacy index. - (b) Sealed rows: a sealed account that then goes dormant has no server-resolvable userId. Reclaim of a sealed number can still detect existence (
auth_lookup/{phoneHash}exists) and read a non-sealedlastActiveAtif we mirrorlastActiveAtinto theauth_lookuprow (it's not sensitive - it's a coarse timestamp). The grace-period machinery can key on phoneHash without ever resolving the userId; the actual account disable at reclaim-completion would need a userId, which only the original owner can produce (and if they show up to produce it, the account isn't dormant). [Decide] whether sealed-row reclaim mirrorslastActiveAtintoauth_lookup, or whether reclaim is simply documented as "existence + grace only" for sealed rows with final disable deferred. I lean toward mirroringlastActiveAt(cheap, non-sensitive) so the dormancy check survives.
This is the messiest interaction and deserves its own follow-up slice; for the initial Stage B ship, legacy-row reclaim is unaffected (option a) and sealed-row reclaim is explicitly scoped out / documented as a known limitation.
3.6 Recovery-phrase flow (ForgotPassphraseModal.jsx) - no backdoor introduced โ
The recovery modal today: recovery phrase -> entropy -> /auth/phone/lookup (gets userId, phoneSalt) -> authProofHash -> /token -> then updateDoc(users/{userId}) directly via the client SDK to re-wrap the seed (:291).
Under sealing:
- The lookup returns
sealed:truewithencryptedUserIdBlobbut no userId. The recovery client has the entropy (from the phrase), so it runsdecryptUserIdBlob(blob, entropy)-> userId, exactly like login. The recovery phrase already yields the same entropy as the PIN (both are BIP39 encodings of the same 16 bytes - verifiedencryption.js:428), so it can open the blob. No server-side recovery key exists; the user's own phrase opens it. This is the crux: recovery stays user-custodied, no backdoor. - After deriving userId + new PIN, the client re-wraps
encryptedSeed(reWrapSeed). It must also re-write theauth_lookuprow'sencryptedSeed(the duplicated copy), not justusers/{userId}.encryptedSeed. Today the modal writesusers/{userId}directly via client Firestore SDK (:291). Under sealing, the client cannot writeauth_lookup(server-only rules, ยง2.1). So the seed-rewrite must move server-side to a new authenticated endpointPOST /auth/phone/rewrap-seedthat updates bothusers/{userId}andauth_lookup/{phoneHash}- but the server doesn't know the phoneHash from the userId anymore (that's the whole point). Resolution: the client sends{ encryptedSeed, authProofHash }plus the phone (the user just typed it for recovery), the server recomputes phoneHash from the phone, and updatesauth_lookup/{phoneHash}+users/{userId}in one transaction, gated byverifyFirebaseToken(the user is signed in by this point). The blob key is unchanged by a PIN rotation (it derives from entropy, which is invariant across PIN changes), soencryptedUserIdBlobdoes not need rewriting on PIN reset - onlyencryptedSeed. (Red-team Completeness #2.)
4. Migration - lazy, dual-read, with a lockout-safe ordering โ
4.1 States โ
A given phone is in exactly one of:
- Legacy -
users/{userId}.phoneHashpresent, noauth_lookup/{phoneHash}. (All pre-Stage-B accounts.) - Sealed -
auth_lookup/{phoneHash}present,users/{userId}.phoneHashremoved. (New signups post-flip; migrated returners.) - Transitional (must be brief) - both present. Exists only inside the migration transaction window.
4.2 The migration step (at next successful legacy login) โ
After a legacy login succeeds (PhonePinLogin.jsx, sealed:false branch), the client already holds entropy and userId. It calls POST /auth/phone/seal:
seal endpoint (verifyFirebaseToken; req.user.uid === userId):
body { encryptedUserIdBlob, blobKdf } // phone re-derived: client sends phone, server hashes
transaction:
1. read users/{userId}; confirm it has phoneHash, phoneSalt, encryptedSeed, authProofHash
2. recompute phoneHash from submitted phone; confirm it equals users.phoneHash (binding)
3. confirm auth_lookup/{phoneHash} does NOT already exist (idempotency)
4. WRITE auth_lookup/{phoneHash} (copy phoneSalt/encryptedSeed/authProofHash/lanternName + blob)
5. (do NOT yet delete users.phoneHash)
// separate, later step:
6. on the NEXT successful login (now sealed), or after a confirmation read,
delete users.phoneHash.Why split write (step 4) from delete (step 6)? If we deleted users.phoneHash in the same breath and the auth_lookup write later proved unreadable for any reason, the user would be unfindable by phone - a self-inflicted lockout (the exact failure Fork 3 warns about). By writing the sealed row first and only dropping the legacy index after the sealed row is confirmed serving reads, there is never a window where the phone resolves to nothing. This mirrors the Stage A phased "dual-write -> migrate -> switch reads -> stop writing -> drop field" discipline (spike ยงA6).
4.3 Dual-read window and cutover criteria โ
- Both reads supported for the entire Stage B rollout:
auth_lookupfirst (point read), legacyusers.where(phoneHash)fallback. Every resolution site in ยง1 follows this order. - How long: indefinitely for login (Fork 3). The legacy fallback is cheap and only fires for un-migrated rows.
- Cutover to sealed-only (dropping the legacy fallback and the
users.phoneHashfield globally) happens only when (a) a metrics threshold of migrated rows is hit AND (b) you accept force-logout of the long tail, OR (c) a ยง11.5 market-entry trigger forces it. Until then, un-migrated = still resolvable, stated honestly in ยง7.
4.4 Never-returning users โ
They stay in Legacy state forever (Fork 3 default). Their phoneHash->userId link remains resolvable via users.where(phoneHash). Honest framing for the subpoena playbook: "Stage B seals the phone->userId link for every account that has logged in since Stage B shipped; accounts dormant since before Stage B retain the Stage-A-only posture (hashed phone, but server-resolvable userId)." This is strictly better than pre-Stage-B and never worse.
5. Recovery - what is lost, plainly โ
| Situation | Outcome |
|---|---|
| Has PIN | Normal login; blob opens. |
| Forgot PIN, has recovery phrase | ForgotPassphraseModal: phrase -> entropy -> opens blob -> userId -> set new PIN. Account fully recovered. No server involvement in key recovery. |
| Forgot PIN, no recovery phrase | Account/link is unrecoverable by design. Start over (fresh UID, ban-checked). Old encrypted data is gone forever - nobody, including Lantern, can decrypt it. (ยง11.3.) |
| Has recovery phrase but wrong phone | Lookup fails (no row for that phoneHash); cannot recover. (Recovery is bound to the phone, since the blob lives at auth_lookup/{phoneHash}.) |
There is no admin recovery power (ยง11.3): an admin cannot decrypt the blob (no entropy), so an admin "reset" grants nothing the user's own PIN/phrase doesn't. This is a feature - it removes a leak surface.
6. Ban survival + abuse โ
- Ban enforcement survives (verified, ยง11.7):
isPhoneBanned(phoneHash)is membership onbanned_accountsand never resolves phoneHash->userId. It is checked atverify-otp(phoneOtp.js:197),createUser(phoneCreateUser.js:145), and the newrecover/start-overgates. Stage B does not touch it. - One-phone-one-account holds. Uniqueness becomes existence of
auth_lookup/{phoneHash}(a point read) plus, during the dual-read window, the legacyusers.where(phoneHash)check - both enforced transactionally increateUser. A second signup for the same phone hits "row exists" -> diverts to recover, never forks a second account. (Red-team Security #2.) - Staff accounts are never sealed.
verify-otpalready refuses to bootstrap a role account through the consumer path (phoneOtp.js:217,:236). Sealed rows are consumer-only; staff keepusers.phoneHashand thephoneAdmin.jsresolution path (out of scope). Document that staff phone->userId is intentionally not sealed (operationally required for the invite/provider-link flow). - Lost-PIN โ ban evasion: start-over re-checks the ban (ยง3.3). A permanent ban has
expiresAt:nullforever (bannedAccounts.service.js:72), so a fresh UID after start-over still gets blocked at the phoneHash gate.
7. Subpoena posture after Stage B โ
Precise answers, given (a) a phone number, (b) a userId, (c) a full DB dump without the pepper and without any PIN. "Sealed account" = has migrated/new auth_lookup row; "legacy account" = un-migrated (Fork 3 tail).
| Input the subpoena starts with | What the server CAN produce | What it CANNOT produce |
|---|---|---|
| (a) A phone number | Compute phoneHash (needs pepper from KMS). Answer "does an account exist?" (yes/no, the existence oracle). For a sealed account: the auth_lookup row = ciphertext blob + public salt + PIN-wrapped seed. | The userId for a sealed account - encryptedUserIdBlob needs HKDF(entropy); entropy needs the PIN, which we never hold. For a legacy account, we CAN still return the userId (the ยง4.4 residual). |
| (b) A userId | Everything keyed by that userId in users/{userId} and userId-indexed collections - minus the still-client-encrypted profile fields (encryptedBirthDate, etc.). | The phone number for that userId - there is no userId->phone index for sealed accounts (users.phoneHash is removed; the only phone-side row is keyed by phoneHash and contains ciphertext, not this userId). For legacy accounts, users.phoneHash exists but is HMAC'd - yields the hash, not the phone. |
| (c) Full DB dump, no pepper, no PIN | Nothing useful. auth_lookup rows are {phoneHash (HMAC, un-reversible without pepper), ciphertext blob, public salt, PIN-wrapped seed}. users docs have opaque UUID ids and no plaintext phone. | Any phoneโuserId mapping; any plaintext phone; any decryptable userId blob; any profile plaintext. The dump is inert. |
Honest ceiling (carry ยง11.5 verbatim into the playbook): Stage B seals the past for migrated accounts. It does NOT defeat (1) the Firebase Auth UserRecord for legacy Firebase-phone accounts created before the OTP bootstrap flip (residual #1 - out of scope here, deferred); (2) the in-use login moment (the server transiently holds phone+userId when a user logs in); (3) prospective targeted compulsion (a court can compel capture of a named target's next login); (4) third parties (OTP provider sees the phone; EKM partner holds the key); (5) the existence oracle. Accurate claim: "We cannot resolve a stored phone number to an account for sealed accounts, and we have discarded the ability to do so for them; a subpoena of those records yields a locked blob." NOT accurate: "impossible for us to ever determine this for a targeted user."
8. File-by-file change list โ
Add (server):
services/api/auth/src/services/authLookup.service.js-readAuthLookup(phoneHash),writeAuthLookup(tx, phoneHash, payload),deleteAuthLookup(tx, phoneHash),mirrorLastActive(...). Single owner of theauth_lookupcollection so rules/shape live in one place.services/api/auth/src/routes/phoneSeal.js(or fold intophoneCreateUser/phone) -POST /auth/phone/seal(lazy migration) andPOST /auth/phone/rewrap-seed(recovery seed rotation), bothverifyFirebaseToken.- Tests:
authLookup.service.test.js,phoneSeal.test.js, plus new cases inphone.test.js,phoneOtp.test.js,phoneCreateUser.test.js.
Edit (server):
services/api/auth/src/routes/phone.js-/lookupdual-read + sealed response shape (no userId);/tokenunchanged except the documented userId-level ban note.services/api/auth/src/routes/phoneOtp.js-verify-otpreuse->recover divert (ยง3.2); recover-attempt durable counter.services/api/auth/src/routes/phoneCreateUser.js- writeauth_lookuprow in the create transaction; uniqueness viaauth_lookuppoint read + legacy fallback; acceptencryptedUserIdBlob/blobKdfinCreateUserBody; writeusersdoc withoutphoneHashon sealed path.services/api/auth/src/routes/phoneRecycling.js- leave legacy reclaim; document sealed-row limitation (ยง3.5).services/api/auth/src/index.js- mount the new/auth/phone/seal+/auth/phone/rewrap-seedbefore the/auth/phonecatch-alls (mirror thecreateUsermount at:120).services/api/auth/openapi.json- document the new endpoints + the sealed lookup response.- Firestore rules file [VERIFY path] - deny all client access to
auth_lookup.
Edit (client):
apps/web/src/lib/encryption.js-deriveUserIdBlobKey/encryptUserIdBlob/decryptUserIdBlob(HKDF).apps/web/src/screens/auth/PhonePinLogin.jsx- sealed-branch decrypt; post-legacy-login lazysealcall.apps/web/src/screens/auth/PhonePinSignup.jsx- sendencryptedUserIdBlobin createUser body; handlemode:'recover'divert from verify-otp.apps/web/src/components/ForgotPassphraseModal.jsx- sealed-branch decrypt; move seed rewrite to/auth/phone/rewrap-seed.apps/web/src/lib/signupApi.js- threadencryptedUserIdBlob.- Config:
STAGE_B_SEALED_USERID_ENABLED(server) +VITE_STAGE_B_SEALED_USERID(client), default off. docs/privacy/SEALED_IDENTITY.md- flip the ยง11.7 table's Stage B row to BUILT/DORMANT when shipped; update ยง9 residuals.
9. Edge-case + test matrix โ
| # | Case | Expected |
|---|---|---|
| 1 | Brand-new signup, flag on | auth_lookup/{phoneHash} written; users doc has NO phoneHash; blob round-trips |
| 2 | Sealed login, correct PIN | lookup returns blob (no userId); client decrypts -> userId -> /token 200 |
| 3 | Sealed login, wrong PIN | decryptUserIdBlob throws; no userId; no /token call; recover-attempt counter +1 |
| 4 | Legacy login (un-migrated), correct PIN | today's path works; then seal writes auth_lookup; users.phoneHash dropped on next login |
| 5 | Migration idempotency | second seal for same phoneHash -> no-op / 409, no duplicate row |
| 6 | Migration crash between write and delete | row exists, users.phoneHash still present -> next login resolves via sealed path, delete retried; no lockout |
| 7 | Returning user re-runs signup, sealed row exists | mode:'recover'; NO new UID minted; NO session for a new UID |
| 8 | Returning user re-runs signup, legacy row exists | mode:'recover' sourced from users doc; no fork |
| 9 | Start-over (lost PIN + no phrase) | ban re-checked; old auth_lookup deleted; old user cascade-deleted; fresh UID |
| 10 | Recovery-phrase recovery, sealed account | phrase->entropy opens blob; userId recovered; new PIN; rewrap-seed updates both copies |
| 11 | Wrong recovery phrase | wrong entropy -> GCM fail -> no userId; clean error |
| 12 | Banned phone, sealed signup/recover | 403 BANNED at the phone gate (membership), even with a valid sealed row |
| 13 | Subpoena dump simulation | auth_lookup row reveals no userId; users doc reveals no phone |
| 14 | One-phone-two-signups race | transaction serializes on auth_lookup/{phoneHash} doc id; second -> recover, no second account |
| 15 | Lookup response has no length-correlatable userId leak | assert response omits userId and any field tightly correlated to it (ยง3.6) |
| 16 | Flag OFF | auth_lookup never read/written; all paths byte-for-byte today's behavior |
| 17 | Sealed account, dormant, reclaim attempted | legacy unaffected; sealed -> documented limitation / existence+grace only |
| 18 | blobKdf version mismatch (future) | server returns the stored tag; client refuses unknown tag with a clean error, not a 500 |
| 19 | Concurrent login + start-over on same phone | doc-id-serialized; one wins, the other gets a clean retriable error |
| 20 | Client sends phone whose hash โ user's stored phoneHash to seal/rewrap-seed | binding check (ยง4.2 step 2 / ยง3.6) rejects 403 |
10. Red-team ledger (security + completeness + scope lenses against this draft) โ
Security:
- S1 - Recovery-at-signup has no server lockout (the userId-keyed lockout is unreachable without a userId). Folded: durable per-phoneHash recover-attempt counter + the existing durable per-phone verify-otp limit (ยง3.2).
- S2 -
encryptedSeed/phoneSaltduplicated intoauth_lookupis an offline-crack primitive if a client can read foreign rows. Folded: server-only Firestore rules onauth_lookup; never returned for a phone the caller hasn't OTP-proven or isn't logging into. Same exposure class as the pre-existingphone.js:84TODO - do not regress it; ideally pair Stage B with that TODO's fix (deliver seed only post-OTP / post-proof). - S3 - Blob key reuse across rotations. Folded:
blobKdfversion tag; entropy-invariant key means PIN rotation doesn't touch the blob (so no re-encrypt-on-PIN-change footgun), but a future entropy rotation (not currently possible) would need a new tag. - S4 -
/tokenban gap. Folded: userId-level ban enforced at/token; phoneHash-level ban at the phone-present gates; documented that moderation must disable the userId too (ยง3.4, ยง6).
Completeness:
- C1 - Recovery modal writes
users/{userId}directly via client SDK today; under server-onlyauth_lookuprules the seed copy would drift. Folded:/auth/phone/rewrap-seedupdates both copies server-side (ยง3.6). - C2 - Phone reclaim resolves phoneHash->userId. Folded: legacy reclaim unaffected; sealed reclaim scoped out with a documented limitation + the
lastActiveAt-mirror option (ยง3.5). - C3 - Migration lockout window. Folded: write-sealed-then-delete-legacy ordering with a confirm gate (ยง4.2).
- C4 - Never-returning users. Surfaced as Fork 3, with a default and an explicit ask for your ruling.
- C5 -
authProofHashlocation. Verified it stays onusers/{userId}(used byissueCustomToken); duplicated intoauth_lookuponly so the lookup response can carry it if needed - confirm we don't need two sources of truth (preferusersas authoritative; theauth_lookupcopy is convenience-only and must be kept in sync byrewrap-seed).
Scope:
- Sc1 - Firebase Auth UserRecord residual #1 (legacy phone-credential records) is explicitly OUT of scope (deferred per ยง11.7) and does not block Stage B.
- Sc2 - Staff/admin sealing is OUT of scope; staff keep the resolvable index by design.
- Sc3 - Don't conflate with the OTP flip. Stage B can ship behind its own flag independent of
OTP_PROVIDER; but the cleanest end-state is sealed accounts created via the bootstrap path (random UID + phone-less Auth record). On the legacy Firebase path, the userId is a Firebase UID and the Auth record may carry the phone - sealing the Firestore link still helps, but residual #1 remains. Document this interaction.
11. Open questions (resolve at review) โ
- Fork 3 ruling - RESOLVED 2026-06-23: leave never-returners in legacy form indefinitely (no forced cutover); revisit only under a UK/Australia market-entry trigger. (See ยง0 Fork 3.)
- Firestore rules file path and current
auth_lookup/usersrules [VERIFY] - confirm server-only enforcement and the no-foreign-read guarantee. - Cascade-delete service for start-over [VERIFY exists] (PRIVACY_HARDENING_ROADMAP Sprint D claims it; confirm before relying on it in ยง3.3).
- Sealed-row reclaim (ยง3.5): mirror
lastActiveAtintoauth_lookup, or document existence+grace-only? - HKDF salt for the blob key - reuse
phoneSalt, or a fixed context salt? (Either is fine;phoneSaltties the blob key to the per-user salt for a touch more domain separation.) - Should Stage B ship gated on the
OTP_PROVIDER=preludeflip (so all sealed accounts are also phone-less in Auth), or independently? (Sc3.) blobKdfandschemaVersioninitial values and the rotation runbook.
12. SEALED_IDENTITY ยง7 non-negotiable-constraint check โ
- Immutable Right #6 (no data sales): Stage B is a privacy strengthening; no data leaves, nothing sold. OK.
- ยง3.2 k-anonymity: untouched; no merchant-surfaced metric changes. OK.
- No per-user behavioral profiles / no fingerprinting: the blob is a sealed userId, not a profile; no new signals. OK.
- Cannot weaken existing privacy commitments to gain operational capability: Stage B removes the operational capability (server-side phone->userId resolution) in exchange for privacy - the correct direction. The one capability reduction (CS lookup-by-phone, sealed-row reclaim) is accepted per ยง4/ยง5 of the brief. OK.
- No server-recoverable backdoor: the sealing key is
HKDF(entropy); entropy comes only from the PIN or the user-custodied recovery phrase. No server-held key, no admin recovery. OK - this is the central design property. - Existing privacy properties survive: phone-level banning (membership, UID-independent - verified), Stage A phoneHash, the phone-less Auth record (for bootstrap-path accounts) all survive. OK.
- Phase 1 capital posture: founder-time, no new infra spend (one Firestore collection + client crypto; no new services). OK.
13. Follow-up task: phone-NUMBER migration / number change (scoped OUT of initial Stage B) โ
"Phone" throughout this plan means the phone NUMBER (E.164), hashed to phoneHash, never a device. OTP proves control of the NUMBER (an SMS lands), not a handset; biometric unlock is a separate, optional, device-local convenience and is not the identity anchor. So losing the device but keeping the number is a non-issue (re-OTP to the same number on the new device). The genuine gap is changing or losing the NUMBER, for which there is no flow today (the account is bound to phoneHash). Tracked here as a deliberate follow-up.
Helpful property from Stage B: the blob key is HKDF(entropy), which is phone-independent, so encryptedUserIdBlob does NOT change when the number changes. Migration is a re-index of auth_lookup, not a re-encrypt. The userId is stable; only the phone-side row moves.
Two flavors (do A first; B is a separate, higher-risk feature):
(A) Number change, user controls BOTH old and new number. OTP-prove old, OTP-prove new, then in one transaction: write
auth_lookup/{newHash}(copy blob/seed/salt/authProofHash/lanternName), deleteauth_lookup/{oldHash}, re-check ban on the new number, leaveusers/{userId}untouched. Legacy (un-migrated) rows: updateusers.phoneHashinstead. Low abuse risk (both numbers proven). Recommended first cut.(B) Lost-number recovery, has new number + PIN or recovery phrase but lost the old number. Requires finding the account WITHOUT the old phone, i.e. a phrase/entropy-derived lookup index (e.g.
recovery_lookup/{HKDF(entropy, "recovery-lookup-v1")}). This turns the recovery phrase into a phone-independent credential (leak = takeover with no number needed; today an attacker also needs the number to locate the row), so it is a real security-posture change. Must re-OTP the NEW number and re-check bans before rebinding. Higher abuse surface; design and gate deliberately. Optional/later.
Abuse + interaction checklist (both flavors): re-check isPhoneBanned on the new number (a banned user must not escape by migrating); one-phone-one-account (the new number must not already have an auth_lookup row); phone-recycling (the old number becomes reclaimable, so tombstone/abandon the old row); staff accounts out of scope; audit-log the rebind. Ban-evasion is the load-bearing gate.
Recommendation: ship Stage B phone-bound (no regression); implement flavor (A) as a small follow-up slice; treat flavor (B) as a separate, carefully-gated feature with its own review.
14. Manual dev test runbook (lantern-app-dev only) โ
Hands-on procedure for exercising the build before flipping the flag for real. The ยง9 matrix is the conceptual grid; this maps the cases you can drive through the UI/tooling and records how each run went in ยง15.
14.0 Mental model: what runs where, and the two flag sets โ
Two independent things are flag-gated here, and they stack:
- Prelude = who sends the OTP, and that the phone never touches the Firebase Auth record. Knobs:
OTP_PROVIDER(server) +VITE_OTP_PROVIDER(client) =prelude. - Stage B = sealing the phone->userId link (the
auth_lookupblob). Knobs:STAGE_B_SEALED_USERID_ENABLED(server) +VITE_STAGE_B_SEALED_USERID(client) =true.
Dependency: Stage B sealing only fires on the Prelude path. The server seals only when it receives a proof (minted by the Prelude verify-otp bootstrap) AND the client's blob, so with OTP_PROVIDER=firebase the Stage B flag is inert. To exercise sealing you need BOTH flag sets on (all four values).
Three environments, and the distinction that trips people up:
| Env | Code runs on | Flags come from | Data lives in |
|---|---|---|---|
| Local | your laptop (web :5173, auth-api :8084) | .env.local (symlinked, shared with the main checkout) | lantern-app-dev Firestore + Auth |
| Dev-live | dev Cloud Run + the dev web build | the Cloud Run service env + the web deploy workflow | lantern-app-dev Firestore + Auth |
| Prod | prod Cloud Run + the prod web build | prod service env + prod deploy | lantern-app-prod Firestore + Auth |
Local and dev-live are the SAME database (lantern-app-dev). The only differences are whose CPU runs the code and where the flags come from. That is why the otp:test:* helper (which always talks to live lantern-app-dev via the Admin SDK) sees and cleans accounts your LOCAL app created. The real "separate database" boundary is dev vs prod (two distinct Firebase projects). No emulator is involved unless VITE_USE_EMULATORS is set.
Run the local auth-api FROM THE WORKTREE that has this branch (the :8084 server is whatever checkout you launched), or the Stage B code is not in the path even with the flags on.
14.1 Prerequisites โ
Pick the track. Either way, ALL FOUR flags must be on (ยง14.0), and you use a Prelude TEST number only.
Local track (code on your laptop, data in lantern-app-dev): in .env.local (see .env.local.example) set OTP_PROVIDER=prelude, VITE_OTP_PROVIDER=prelude, STAGE_B_SEALED_USERID_ENABLED=true, VITE_STAGE_B_SEALED_USERID=true; set your own OTP_TEST_PHONE via Lantern Control's Inputs panel. Then start the auth-api from the worktree (npm run auth-api:dev, port :8084) and the web dev server (Vite inlines VITE_ flags at startup, so restart it after a flag change). firestore.rules are NOT enforced for the local auth-api (Admin SDK bypasses rules), so the deny rule is not a local blocker.
Dev-live track (code on dev Cloud Run): branch rules/indexes/env vars do NOT exist on lantern-app-dev until deployed, even with the branch merged (Admin-SDK writes still succeed, client reads silently return empty). So: (1) auth-api redeployed with the two server flags on; (2) web app rebuilt with the two VITE_ flags on; (3) firestore.rules deployed (the auth_lookup deny rule) - diff origin/dev rules first, full page-refresh after. Server-first is safe (flag-on just means "accept a blob if one is sent"; with the client off, no blobs arrive).
14.2 The test-account loop (otp-local-test helper, dev only) โ
npm run otp:test:status # see legacy account(s) + sealed auth_lookup row + markers
npm run otp:test:reset # clean slate: deletes account + sealed auth_lookup row + limits, mints a fresh invite
# ... open the printed signup URL, complete signup/login in the browser ...
npm run otp:test:purge-orphans # end-of-session: delete ALL accounts this helper created + clear those invitesreset now clears the sealed auth_lookup row (without it, the next signup diverts to recovery instead of starting fresh). A sealed account cannot be found by phone after its row is gone, so each cycle leaves an inert orphaned users doc; purge-orphans cleans them up via the invite ledger (createdBy:'otp-local-test', usedBy:<uid> stamped on consume), which records every test UID without a phoneHash->uid map.
14.3 Cases to drive (maps to ยง9) โ
| Run | Steps | Pass = | ยง9 |
|---|---|---|---|
| New sealed signup | reset -> signup with PIN | Firestore: auth_lookup/{hash} present; users/{uid} has NO phoneHash | 1 |
| Sealed login | log out -> log in, correct PIN | resolves via blob; lands in app | 2 |
| Wrong PIN | log in, wrong PIN | clean failure, no lockout weirdness | 3 |
| Legacy lazy-seal | account made flag-off, then log in flag-on | login works AND phoneHash disappears from users doc | 4 |
| Returning re-signup | reset withOUT deleting, re-run signup | diverted to recovery; no new UID | 7,8 |
| Recovery phrase | sealed account, run forgot-passphrase | recovers + sets new PIN | 10 |
| Banned number | ban the test number, try signup | 403 BANNED at the gate | 12 |
| Flag OFF regression | set both flags false, repeat new signup + login | byte-for-byte today's behavior | 16 |
Heads-up: there is NO client start-over UI yet (deferred), so the "lost PIN AND phrase" path (ยง9 case 9) can only be driven by calling verify-otp with startOver:true directly, not through the app.
14.4 Observability to watch (slice 8) โ
In the auth-api Cloud Run logs, filter for event::
stageb.seal {outcome}- migration progress (signup-bornoutcome:signup; lazyfirst_seal/finalize).stageb.recover_divert {sealed}- returning accounts sent to recovery.stageb.start_over {authLookupDeleted, legacyDocs}(warn) - the destructive path; delete failures now log instead of being swallowed.stageb.rewrap_seed {sealed}- recovery-phrase resets.
Plus the central errorHandler logs any 500 with a stack. If a run misbehaves, grep these first.
14.5 Confirm the data in Firestore โ
In the Firebase console for the lantern-app-dev project (Firestore Database). The doc id you need is the phoneHash, which npm run otp:test:status prints for your test number. (Local and dev-live write the SAME lantern-app-dev Firestore, so this is the same place regardless of track, per ยง14.0.)
auth_lookup/{phoneHash}(sealed accounts): present after a sealed signup. Confirm it holdsencryptedUserIdBlob,blobKdf,phoneSalt,encryptedSeed,authProofHash,lanternName,schemaVersion- and NO plaintextuserId, NO plaintextphone. The row finds the account by phone hash but reveals only ciphertext.users/{uid}: for a SEALED account, confirm there is NOphoneHashfield and no plaintextphone(justphoneSalt,encryptedSeed,lanternName, etc.). For a LEGACY (flag-off) account,phoneHashis present - that is the un-sealed state.userInvites(filtercreatedBy == otp-local-test):usedByis the created uid - the cleanup paper trail.otpVerifications/{markerId}: single-use signup markers; holduid+purpose, never a phone. Should be empty/expired between tests.
CLI cross-check without the console: npm run otp:test:status reports the legacy account (by phoneHash), the sealed auth_lookup row, and marker count in one shot.
14.6 Data-leakage check: is the phone->userId link sealed everywhere? โ
Stage B's guarantee is that NO at-rest store maps a phone (or phoneHash) to a userId. Spot-check the places identity data could land:
- Firestore
auth_lookup/users- as in ยง14.5: the blob carries nouserId; the sealedusersdoc carries nophoneHash. PASS = neither reveals the link. - BigQuery
lantern-app-dev.analytics.events- carries a rawuser_idfor behavioral events but NO phone/phoneHash. Confirm:SELECT * FROM analytics.events WHERE user_id = '<uid>' LIMIT 5returns event rows with no phone field. uid->behavior is fine; phone->uid is what must be absent. - No Firestore->BigQuery export of identity collections - the Dataform pipeline only sources
github_raw(billing);auth_lookup/users/userInvitesare never streamed to BQ. (If a Firestore->BQ extension is ever added, exclude these collections.) - Cloud Run logs - the
stageb.*events (ยง14.4) log outcomes and booleans only, never a phone,phoneHash, or a phone->uid pairing; the central errorHandler logs path/method/stack, not the phone. _rateLimits- thesend-otp:/verify-otp:keys contain the PLAINTEXT phone (ephemeral, TTL-expired, a count only, NOT tied to a uid); therecover:key uses the hash. This is the one spot a plaintext phone touches at rest: pre-existing, uid-free, short-lived.
Known residuals (flag these, do NOT report a clean sweep):
phoneReclaimsstoresphoneHashtogether withrequestedBy: <uid>(the reclaim requester). For users who go through phone-recycling reclaim, this IS aphoneHash -> uidassociation at rest - a narrow, deliberate dedup/audit record, but a real residual that the sealedauth_lookupdoes not have. Consistent with reclaim being scoped OUT of Stage B (ยง3.5, slice 7). Revisit if/when reclaim is sealed.- Admin / staff accounts are out of Stage B scope (never sealed) and handle phones separately (the admin portal links by phone by design). The consumer sealed-identity guarantee does not cover staff records.
15. Test log (record of how it went) โ
Append a row per real test session (newest last). Keep it factual: what was run, result, anything surprising.
Testing Round 1 ( Local Dev ) โ
Full log can be found at: docs/planning/logs/sealed-identity-stage-b-console-logs.md
[ IMPORTANT ] Note that any "No issues" means I didn't see anything observable, but we still want to check the console logs.
- New sealed signup: No issues
- Sealed login: No issues
- Wrong PIN: I got a "Pin is too easy to guess" during a login, this should only happen during signup.
- Legacy lazy-seal: Didn't see any issues but will want to confirm that the
phoneHashis removed from theusersdoc. - Returning re-signup: Sign up isn't available without a testing link. Defer for now.
- Recovery phrase: No issues
- Banner number:
- Flag OFF regression: When turned the flags off before making the account, I got a "No account found with this phone number" which I guess is the intended behavior? Then I reset the account after turning off the flags. I was able to sign up but when I logged out and tried to log back in, I got a "No account found with this phone number".
| Date | Env | Build flag(s) | Cases run | Result | Notes |
|---|---|---|---|---|---|
| (template) | dev | server+client on | e.g. 1,2,3,4 | pass/fail | observations, stageb.* events seen, follow-ups |
Nothing recorded yet. First entry goes in when the flag is flipped on dev and the ยง14.3 cases are driven.
| Run | Steps | Pass = | ยง9 |
|---|---|---|---|
| New sealed signup | reset -> signup with PIN | Firestore: auth_lookup/{hash} present; users/{uid} has NO phoneHash | 1 |
| Sealed login | log out -> log in, correct PIN | resolves via blob; lands in app | 2 |
| Wrong PIN | log in, wrong PIN | clean failure, no lockout weirdness | 3 |
| Legacy lazy-seal | account made flag-off, then log in flag-on | login works AND phoneHash disappears from users doc | 4 |
| Returning re-signup | reset withOUT deleting, re-run signup | diverted to recovery; no new UID | 7,8 |
| Recovery phrase | sealed account, run forgot-passphrase | recovers + sets new PIN | 10 |
| Banned number | ban the test number, try signup | 403 BANNED at the gate | 12 |
| Flag OFF regression | set both flags false, repeat new signup + login | byte-for-byte today's behavior | 16 |
16. Status, remaining work, and Stage C candidates (2026-06-28) โ
Live handoff: where the sealed-identity + ban/moderation work actually stands, what is left to close Stage B, and the candidate directions for a "Stage C." (Note: the parallel Explore sweep that fed this can mislead if it reads the MAIN checkout instead of this worktree branch; the items below are verified against the branch code.)
16.1 Built and dormant (ready for a dev-first flip) โ
- Stage A (phone -> phoneHash): shipped on dev (phases 1-2; the phase-4 plaintext drop stays deferred under Fork 3 / indefinite dual-read).
- Custom-token OTP bootstrap (Prelude; app-controlled random UID; phone-less Auth record): BUILT, dormant behind
OTP_PROVIDER(defaultfirebase). - Stage B sealing (this plan): BUILT, dormant behind
STAGE_B_SEALED_USERID_ENABLED+VITE_STAGE_B_SEALED_USERID. Present + unit-tested:auth_lookup/{phoneHash},phoneSeal.js(/seal,/rewrap-seed),authLookup.service.js,stageBFlag.js, client blob crypto (encryption.js), dual-read, reuse->recover divert, start-over backend. Testing Round 1 (local dev) is recorded in ยง15. - Ban backend (UID + phone + email):
/ban,/unban,/ban-phone,/ban-email,/unban-phone,/overturn-ban; enforced at verify-otp / createUser / the/tokenuserId gate. Residual #2 (the/tokenuserId-axis ban) is closed. - Admin moderation UI: the Manual ban / Manual unban slide-in drawer is REAL (drives the ban backend); the Users-page Ban is repointed to the Express API; the Cases list + case detail are still a MOCK prototype (
mockCases.js).
16.2 Round-1 follow-ups to chase (from ยง15) โ
Wrong-PIN on LOGIN shows "Pin is too easy to guess" (case 3): that validator should only fire on signup.RESOLVED (commited8a5484, predates this status note):validatePINnow takes{ checkWeak }, andPhonePinLogin.jsx+LockScreen.jsxpasscheckWeak: falseso the weak-PIN strength check only runs at PIN creation (signup). Covered byencryption.test.js.Flag-OFF regression (case 8): after a flag-off signup, logout->login returned "No account found with this phone number."RESOLVED. Root cause was not a Stage B regression:createUserpermanently writesphoneHashwith no plaintextphone, while the plaintext-phone lookup fallback is gated onSTAGE_A_PHONE_HASH_LOOKUP_ENABLED. So the miss only appears if Stage A's lookup flag is turned OFF after accounts exist (a config that never runs in dev/prod, where Stage A stays on).STAGE_A_PHONE_HASH_LOOKUP_ENABLEDis effectively a one-way door. Fix:lookupHandlernow falls back to ausers.where('phoneHash')query when the plaintext-phone query misses and a pepper is mounted, so a flag misconfiguration cannot strand a real account at login. Covered by two new cases inphone.test.js.- Confirm
phoneHashis actually removed from theusersdoc after lazy-seal (case 4 looked clean but was not confirmed in Firestore). - Not yet run in Round 1: Banned number (case 7, blank) and Returning re-signup (case 5, blocked by signup needing a test link).
16.3 Remaining to call Stage B "done" โ
- Drive Testing Rounds 2+ on BOTH local and dev-live (Round 1 was local only); cover the ยง14.3 grid + the ยง9 cases not yet exercised (start-over needs the deferred client UI or a direct
verify-otp startOver:truecall; migration crash/idempotency; recovery edge; race). - Resolve the ยง11 open questions still marked [VERIFY]/[Decide]: the
auth_lookupFirestore deny rule, the cascade-delete service for start-over,blobKdf/schemaVersionvalues + rotation runbook, and whether Stage B ships gated on theOTP_PROVIDER=preludeflip or independently. - Add the missing automated tests (16.5).
16.4 Operationalize (dev -> prod), operator-gated โ
- OTP prod gates (hard prereqs): the Layer-2 provider/GCP billing ceiling, and the three Prelude pre-commit checks (DPA retention/subprocessors + EU hosting; real US deliverability; vendor-longevity).
- Flip all four flags on dev, run the full ยง14.3 grid, then (only when the operator calls it) prod. Per the operator preference, do not propose prod provisioning until she says so.
16.5 What else to test (gaps with NO automated coverage today) โ
- Moderation routes have only helper-level tests (
gatherBanHashes,severityForDuration). Add route-level tests for/ban,/unban,/ban-phone,/unban-phone,/ban-email,/overturn-ban(mock Auth + Firestore). - PhonePinLogin banned/disabled message (#8): no test that a banned user sees the appeal message and is NOT counted as a failed PIN attempt.
- Admin
moderationApiclient + the Manual ban/unban drawer: no tests. - Re-run the manual plan
docs/engineering/testing/runs/2026-06-28-admin-ban-wiring/README.mdagainst the NEW drawer flow (it was written for the inline form) and add an unban-by-number scenario (the live unban was confirmed once by hand, not in the plan).
16.6 Stage C candidates (pick a direction) โ
The foundation (sealed identity + ban backend + admin moderation UI) is in place. Three coherent directions:
- C1 - Operationalize Stage B (finish the rollout). Close 16.2 / 16.3 / 16.5, configure the OTP prod gates, flip dev, then prod. This is "ship what's built," not a new feature stage, and it is the prerequisite to everything else.
- C2 - Phone-number lifecycle + recovery completeness. The follow-ups this plan scoped out: number change (ยง13 flavor A), lost-number recovery (ยง13 flavor B), the client start-over UI (ยง3.3), and sealed-row reclaim (ยง3.5). Closes the user-lifecycle gaps in the identity epic.
- C3 - The safety/moderation layer made real. Build the report -> case pipeline on top of the (already real) ban backend:
userReportsintake + the user-facing Report flow (Storybook-first), wire the admin Cases list to real reports, point the CaseDetailPanel Ban/Reinstate at the realmoderationApi, plus harvest-at-login (capture a sealed account's number for a number-ban), moderator notices, and escalation. Seedocs/features/safety/MODERATION_CASES.md+docs/privacy/SEALED_ACCOUNT_BAN_FLOW.md.
Recommendation: do C1 first regardless (Stage B is not "done" until tested + flipped). Then choose between C2 (hardens the identity epic) and C3 (the most direct use of what was just built: the ban + admin foundation).
17. Reconciled audit (2026-07-21) โ
A four-lens code audit (server / client / open-questions / tests) against the current dev branch, reconciling ยง16 (dated 2026-06-28). Since June 28, the C3 safety/moderation layer largely shipped (moderation cases, in-portal appeals, sealed-ban reconciliation, the path-safe evidence viewer, and the sealed-account PIN brute-force lockout #663). So ยง16.1's "Cases list + case detail are still a MOCK prototype" is now STALE (the cases pipeline is real). The sealing machinery itself is further along than ยง16 implied.
17.1 DONE (built, wired, unit-tested; dormant behind STAGE_B_SEALED_USERID_ENABLED + VITE_STAGE_B_SEALED_USERID) โ
- Server:
authLookup.service.js(full CRUD; writesencryptedUserIdBlob,blobKdf="hkdf-sha256-v1",phoneSalt,encryptedSeed,authProofHash,schemaVersion=1);phoneSeal.js/seal+/rewrap-seed(complete transactional handlers, stripphoneHash+ lingering plaintextphone);stageBFlag.jswired in 5 route files; dual-read inphone.jslookupHandler (returnsencryptedUserIdBlob, neveruserId, on a sealed hit); reuseโrecover divert +startOverbackend inphoneOtp.js; sealedcreateUserwith uniqueness via both theauth_lookuppoint-read and the legacy dup scan. - Client:
encryption.jsblob crypto (deriveUserIdBlobKey/encryptUserIdBlob/decryptUserIdBlob, HKDFinfo="lantern-userid-blob-v1", +blobKdfallow-list gating); sealed login decryptโ/token(PhonePinLogin.jsx); recover-divert (PhonePinSignup.jsx);ForgotPassphraseModal.jsxsealed recovery via/rewrap-seed. Client consumes a sealed response regardless of its local flag (correct: server-first rollout is safe); the flag gates only writing new sealed data. - Security open-Qs RESOLVED:
auth_lookup(+ any tombstones) denied infirestore.rules:133-136plus the catch-all;blobKdf/schemaVersionliterals pinned in@lantern/shared/encryption. - Tests COVERED: sealed create, dual-read (all four shapes), reuseโrecover divert, start-over (sealed + legacy + honors-ban + no-mint-on-failed-delete), migration idempotency, migration-crash retry/finalize, recovery edges (wrong-entropy, salt-binding, unknown-kdf), ban gates.
17.2 REMAINING to call Stage B "done" โ
- Dev-live testing rounds 2+. Round 1 (ยง15) was local only. Drive the ยง14.3 grid on
lantern-app-dev. Test gaps still open (from ยง16.5)CLOSED 2026-07-21 (#684, second test-hardening slice): phone/email-axis ban route tests (banPhoneHandler/unbanPhoneHandler/banEmailHandler/overturnBanHandlerextracted + 18 tests inmoderationBanAxes.route.test.js); thePhonePinLoginbanned-message integration harness (18 tests: all three blocked paths show the appeal copy and burn no PIN attempt, wrong-PIN and 5xx contrast cases, input validation, lockouts, and the sealed login path); the explicit concurrency/race test (phoneCreateUser.race.test.js: two genuinely concurrent createUser calls against a serializing in-memory Firestore, sealed + legacy + start-over shapes, one wins / one 409s); and adminmoderationApi(15 tests) +ManualBandrawer (9 tests). ยง16.5's automated-coverage list is now fully closed.- Rotation runbook for
blobKdf(only v1 exists; the client allow-list enforces, but the rotate procedure is unwritten).
17.3 Two design forks (DECIDED 2026-07-21, operator delegated the call) โ
- Fork A: cascade-delete on start-over for a SEALED account. The cascade Cloud Function exists (
userDeletion.js) but start-over does NOT call it, and architecturally cannot for a sealed account: start-over happens because the user lost their PIN, so neither client nor server can resolve the opaqueuserIdto cascade its data. The oldauth_lookuprow + legacyusersdoc are deleted; userId-keyed data (lanterns/waves/connections/messages/frens) is left orphaned but unreachable. Options: (a) accept it (unreachable โ sealed; storage cost only), (b) a periodic sweep GC'ingusersdocs with no resolvableauth_lookup, (c) tombstone-and-reap. DECISION: DEFER, tracked as a follow-up issue. Orphaned sealed data is unreachable (nobody can resolve the userId), so it is NOT a leak, only a storage/minimization cost; it is not a flip blocker. The on-axiom fix (option b, a periodic sweep GC) lands post-flip as its own scheduled-task slice. Not a quick fix and not urgent. - Fork B: ship-gating (ยง11 Q6). The flags are architecturally independent (no code coupling), but new sealed-account creation only fires on the Prelude OTP path (
verify-otpis dormant underOTP_PROVIDER=firebase). DECISION: flip Stage B and Prelude TOGETHER on dev. Flipping Stage B alone is a no-op (nothing mints sealed accounts until the Prelude OTP path is on), so there is no independent-flip value to capture; sequencing them together on dev is the practical path. This is a dev sequencing detail, not a code coupling.
17.5 Decisions + work taken 2026-07-21 (operator delegated executive calls, "document everything") โ
- Forks A + B decided above.
- Test-hardening slice shipped (this is the actual flip-blocker): route-level tests for the ban lifecycle. This PR extracts + tests the userId axis (
/ban,/unban) as exported handlers (the repo's blessed testability pattern, mirroringreportEvidenceHandler): 12 tests covering the self-ban / admin-ban / not-found guards, invalid-duration reject, the H-BANbanned_accountswrite +banRecordIdscross-link + audit, the sealed-identity privacy guard (no userId in the evidence note), the timed-ban expiry, the sealed-target skip, and the non-fatal best-effort paths (revoke + createBan). Scoped to the userId axis to keep the extraction of security-critical routes bounded and reviewable. ThePhonePinLoginbanned-message test (ยง16.5 gap #2) is deferred to #684: it needs a from-scratch integration harness for a 400-line component (no existing test harness), disproportionate to bundle here. - Tracked as follow-ups (issues filed): (1) Fork A GC sweep = #683; (2) the remaining ban-route tests (
/ban-phone,/unban-phone,/ban-email,/overturn-ban) + the explicit concurrency/race test (ยง9 #14/#19) + adminmoderationApi/drawer tests = #684; (3) theblobKdfrotation runbook (still to file when the rotation is actually scoped). - Still operator-gated (unchanged): dev-live testing rounds 2+, and the eventual dev flag flip (per the operator preference, no prod proposals until she says so).
17.4 DEFERRED by design (NOT Stage B blockers) โ
- Client start-over UI (ยง3.3): MISSING but deferred; the "lost PIN AND phrase" path is driveable via
verify-otp startOver:truedirectly. A C2 (lifecycle) item. - Sealed-row phone reclaim (ยง3.5): legacy-only today; scoped out (C2). No
mirrorLastActive. auth_lookup_tombstones: intentionally dropped (phoneHash-as-doc-id serializes deletes).