Skip to content

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:

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:

SiteFile:lineWhat it resolvesStage B disposition
Login lookupphone.js:112phoneHash/phone -> doc.id (returned to client as userId)Sealed: return encryptedUserIdBlob, never userId
Signup uniqueness + reusephoneOtp.js:214, phoneCreateUser.js:195phoneHash -> existing doc.idMembership-only (uniqueness) + PIN-gated recovery (reuse)
Phone reclaimphoneRecycling.js:91phoneHash -> doc.id of the dormant accountStays resolvable for legacy rows; sealed rows need a redesign - see ยง3.5
Phone-admin lookupphoneAdmin.js:81phoneHash -> admin docOut of scope - staff accounts are never sealed (they have a role; see ยง6)

Stays server-readable (by design):

  • phoneHash membership - "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 on banned_accounts and never touches the userId resolution (verified: bannedAccounts.service.js:55-86 queries only banned_accounts, never users). 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 on users/{userId} (customToken.service.js:78, issueCustomToken). These are keyed by userId, which the client supplies post-decryption. Unchanged.

Becomes opaque:

  • The userId for a phone. After Stage B, auth_lookup/{phoneHash} contains encryptedUserIdBlob (AES-256-GCM ciphertext), phoneSalt, encryptedSeed, authProofHash, KDF params, and timestamps - but not the plaintext userId. The users/{userId} doc loses its phoneHash field 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   // 1

Design notes:

  • encryptedSeed + phoneSalt MUST live here, not (only) on the user doc, because login needs them before it knows the userId (the client decrypts encryptedSeed to get the entropy that yields the userId). This is a deliberate duplication of two already-public-by-design fields. encryptedSeed is PIN-wrapped (we cannot decrypt it); phoneSalt is 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_lookup yields {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 harvest encryptedSeed/phoneSalt for offline PIN-cracking - the same primitive already flagged in phone.js:84 SECURITY TODO; do not regress it). [VERIFY] the Firestore rules file disallows auth_lookup entirely - locate firestore.rules and add a deny rule.

2.2 Changes to users/{userId} โ€‹

FieldBefore BAfter B (migrated row)
doc iduserId (random UUID on bootstrap path, Firebase UID on legacy)unchanged
phoneHashpresent (the resolution index)removed once the auth_lookup row is written and confirmed
phoneSalt, encryptedSeed, authProofHashpresentkept (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 deriveAESKey uses PBKDF2 because its inputs (phone:pin) are low-entropy; the blob key's input (entropy) is not. WebCrypto supports HKDF natively (deriveKey with name:'HKDF'). The blobKdf:"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 same entropy (unlockEncryptionWithPIN and unlockEncryptionWithRecoveryPhrase both set lastDerivedEntropy, encryption.js:398/:437). So decryptUserIdBlob(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 userId out; it gets nothing. No userId leakage 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:

  1. Client has { encryptedSeed, phoneSalt }. User enters their PIN.
  2. unlockEncryptionWithPIN -> entropy. decryptUserIdBlob -> userId. computeAuthProofHash(entropy).
  3. Client calls /auth/phone/token { userId, proofHmac } exactly as login does. Server verifies authProofHash, mints the custom token. User is in.

What if a phoneHash row exists but the user enters a wrong/forgotten PIN?

  • Wrong PIN: decryptUserIdBlob throws (GCM). Client shows "Incorrect PIN," increments the client attempt counter (pinAttempts.js). It cannot call /token because 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-phone verify-otp rate 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 ForgotPassphraseModal recovery-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_lookup row needs encryptedUserIdBlob, which the client must compute and send. The signup client already holds the entropy (getLastDerivedEntropy(), used at PhonePinSignup.jsx:757). Add encryptedUserIdBlob = await encryptUserIdBlob(user.uid, entropy) to the createPhoneUser body alongside the existing fields. [VERIFY] the CreateUserBody zod schema (phoneCreateUser.js:54) gains encryptedUserIdBlob + blobKdf as 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:

  1. Re-runs the ban check (a lost PIN must not become ban-evasion; ยง11.3, ยง11.7 confirmed no-bypass).
  2. Server transactionally deletes auth_lookup/{phoneHash} and tombstones/abandons the old users/{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).
  3. Mints a fresh random UID and proceeds as brand-new signup.
  4. 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 encryptedSeed with 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 encryptedUserIdBlob and POSTs it to a new /auth/phone/seal endpoint, which writes the auth_lookup row and drops users.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.phoneHash row 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-sealed lastActiveAt if we mirror lastActiveAt into the auth_lookup row (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 mirrors lastActiveAt into auth_lookup, or whether reclaim is simply documented as "existence + grace only" for sealed rows with final disable deferred. I lean toward mirroring lastActiveAt (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:true with encryptedUserIdBlob but no userId. The recovery client has the entropy (from the phrase), so it runs decryptUserIdBlob(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 - verified encryption.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 the auth_lookup row's encryptedSeed (the duplicated copy), not just users/{userId}.encryptedSeed. Today the modal writes users/{userId} directly via client Firestore SDK (:291). Under sealing, the client cannot write auth_lookup (server-only rules, ยง2.1). So the seed-rewrite must move server-side to a new authenticated endpoint POST /auth/phone/rewrap-seed that updates both users/{userId} and auth_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 updates auth_lookup/{phoneHash} + users/{userId} in one transaction, gated by verifyFirebaseToken (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), so encryptedUserIdBlob does not need rewriting on PIN reset - only encryptedSeed. (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:

  1. Legacy - users/{userId}.phoneHash present, no auth_lookup/{phoneHash}. (All pre-Stage-B accounts.)
  2. Sealed - auth_lookup/{phoneHash} present, users/{userId}.phoneHash removed. (New signups post-flip; migrated returners.)
  3. 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_lookup first (point read), legacy users.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.phoneHash field 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 โ€‹

SituationOutcome
Has PINNormal login; blob opens.
Forgot PIN, has recovery phraseForgotPassphraseModal: phrase -> entropy -> opens blob -> userId -> set new PIN. Account fully recovered. No server involvement in key recovery.
Forgot PIN, no recovery phraseAccount/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 phoneLookup 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 on banned_accounts and never resolves phoneHash->userId. It is checked at verify-otp (phoneOtp.js:197), createUser (phoneCreateUser.js:145), and the new recover/start-over gates. 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 legacy users.where(phoneHash) check - both enforced transactionally in createUser. 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-otp already refuses to bootstrap a role account through the consumer path (phoneOtp.js:217, :236). Sealed rows are consumer-only; staff keep users.phoneHash and the phoneAdmin.js resolution 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:null forever (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 withWhat the server CAN produceWhat it CANNOT produce
(a) A phone numberCompute 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 userIdEverything 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 PINNothing 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 the auth_lookup collection so rules/shape live in one place.
  • services/api/auth/src/routes/phoneSeal.js (or fold into phoneCreateUser/phone) - POST /auth/phone/seal (lazy migration) and POST /auth/phone/rewrap-seed (recovery seed rotation), both verifyFirebaseToken.
  • Tests: authLookup.service.test.js, phoneSeal.test.js, plus new cases in phone.test.js, phoneOtp.test.js, phoneCreateUser.test.js.

Edit (server):

  • services/api/auth/src/routes/phone.js - /lookup dual-read + sealed response shape (no userId); /token unchanged except the documented userId-level ban note.
  • services/api/auth/src/routes/phoneOtp.js - verify-otp reuse->recover divert (ยง3.2); recover-attempt durable counter.
  • services/api/auth/src/routes/phoneCreateUser.js - write auth_lookup row in the create transaction; uniqueness via auth_lookup point read + legacy fallback; accept encryptedUserIdBlob/blobKdf in CreateUserBody; write users doc without phoneHash on 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-seed before the /auth/phone catch-alls (mirror the createUser mount 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 lazy seal call.
  • apps/web/src/screens/auth/PhonePinSignup.jsx - send encryptedUserIdBlob in createUser body; handle mode:'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 - thread encryptedUserIdBlob.
  • 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 โ€‹

#CaseExpected
1Brand-new signup, flag onauth_lookup/{phoneHash} written; users doc has NO phoneHash; blob round-trips
2Sealed login, correct PINlookup returns blob (no userId); client decrypts -> userId -> /token 200
3Sealed login, wrong PINdecryptUserIdBlob throws; no userId; no /token call; recover-attempt counter +1
4Legacy login (un-migrated), correct PINtoday's path works; then seal writes auth_lookup; users.phoneHash dropped on next login
5Migration idempotencysecond seal for same phoneHash -> no-op / 409, no duplicate row
6Migration crash between write and deleterow exists, users.phoneHash still present -> next login resolves via sealed path, delete retried; no lockout
7Returning user re-runs signup, sealed row existsmode:'recover'; NO new UID minted; NO session for a new UID
8Returning user re-runs signup, legacy row existsmode:'recover' sourced from users doc; no fork
9Start-over (lost PIN + no phrase)ban re-checked; old auth_lookup deleted; old user cascade-deleted; fresh UID
10Recovery-phrase recovery, sealed accountphrase->entropy opens blob; userId recovered; new PIN; rewrap-seed updates both copies
11Wrong recovery phrasewrong entropy -> GCM fail -> no userId; clean error
12Banned phone, sealed signup/recover403 BANNED at the phone gate (membership), even with a valid sealed row
13Subpoena dump simulationauth_lookup row reveals no userId; users doc reveals no phone
14One-phone-two-signups racetransaction serializes on auth_lookup/{phoneHash} doc id; second -> recover, no second account
15Lookup response has no length-correlatable userId leakassert response omits userId and any field tightly correlated to it (ยง3.6)
16Flag OFFauth_lookup never read/written; all paths byte-for-byte today's behavior
17Sealed account, dormant, reclaim attemptedlegacy unaffected; sealed -> documented limitation / existence+grace only
18blobKdf version mismatch (future)server returns the stored tag; client refuses unknown tag with a clean error, not a 500
19Concurrent login + start-over on same phonedoc-id-serialized; one wins, the other gets a clean retriable error
20Client sends phone whose hash โ‰  user's stored phoneHash to seal/rewrap-seedbinding 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/phoneSalt duplicated into auth_lookup is an offline-crack primitive if a client can read foreign rows. Folded: server-only Firestore rules on auth_lookup; never returned for a phone the caller hasn't OTP-proven or isn't logging into. Same exposure class as the pre-existing phone.js:84 TODO - 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: blobKdf version 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 - /token ban 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-only auth_lookup rules the seed copy would drift. Folded: /auth/phone/rewrap-seed updates 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 - authProofHash location. Verified it stays on users/{userId} (used by issueCustomToken); duplicated into auth_lookup only so the lookup response can carry it if needed - confirm we don't need two sources of truth (prefer users as authoritative; the auth_lookup copy is convenience-only and must be kept in sync by rewrap-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) โ€‹

  1. 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.)
  2. Firestore rules file path and current auth_lookup/users rules [VERIFY] - confirm server-only enforcement and the no-foreign-read guarantee.
  3. Cascade-delete service for start-over [VERIFY exists] (PRIVACY_HARDENING_ROADMAP Sprint D claims it; confirm before relying on it in ยง3.3).
  4. Sealed-row reclaim (ยง3.5): mirror lastActiveAt into auth_lookup, or document existence+grace-only?
  5. HKDF salt for the blob key - reuse phoneSalt, or a fixed context salt? (Either is fine; phoneSalt ties the blob key to the per-user salt for a touch more domain separation.)
  6. Should Stage B ship gated on the OTP_PROVIDER=prelude flip (so all sealed accounts are also phone-less in Auth), or independently? (Sc3.)
  7. blobKdf and schemaVersion initial 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), delete auth_lookup/{oldHash}, re-check ban on the new number, leave users/{userId} untouched. Legacy (un-migrated) rows: update users.phoneHash instead. 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_lookup blob). 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:

EnvCode runs onFlags come fromData lives in
Localyour laptop (web :5173, auth-api :8084).env.local (symlinked, shared with the main checkout)lantern-app-dev Firestore + Auth
Dev-livedev Cloud Run + the dev web buildthe Cloud Run service env + the web deploy workflowlantern-app-dev Firestore + Auth
Prodprod Cloud Run + the prod web buildprod service env + prod deploylantern-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 invites

reset 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) โ€‹

RunStepsPass =ยง9
New sealed signupreset -> signup with PINFirestore: auth_lookup/{hash} present; users/{uid} has NO phoneHash1
Sealed loginlog out -> log in, correct PINresolves via blob; lands in app2
Wrong PINlog in, wrong PINclean failure, no lockout weirdness3
Legacy lazy-sealaccount made flag-off, then log in flag-onlogin works AND phoneHash disappears from users doc4
Returning re-signupreset withOUT deleting, re-run signupdiverted to recovery; no new UID7,8
Recovery phrasesealed account, run forgot-passphraserecovers + sets new PIN10
Banned numberban the test number, try signup403 BANNED at the gate12
Flag OFF regressionset both flags false, repeat new signup + loginbyte-for-byte today's behavior16

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-born outcome:signup; lazy first_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 holds encryptedUserIdBlob, blobKdf, phoneSalt, encryptedSeed, authProofHash, lanternName, schemaVersion - and NO plaintext userId, NO plaintext phone. The row finds the account by phone hash but reveals only ciphertext.
  • users/{uid}: for a SEALED account, confirm there is NO phoneHash field and no plaintext phone (just phoneSalt, encryptedSeed, lanternName, etc.). For a LEGACY (flag-off) account, phoneHash is present - that is the un-sealed state.
  • userInvites (filter createdBy == otp-local-test): usedBy is the created uid - the cleanup paper trail.
  • otpVerifications/{markerId}: single-use signup markers; hold uid + 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.

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 no userId; the sealed users doc carries no phoneHash. PASS = neither reveals the link.
  • BigQuery lantern-app-dev.analytics.events - carries a raw user_id for behavioral events but NO phone/phoneHash. Confirm: SELECT * FROM analytics.events WHERE user_id = '<uid>' LIMIT 5 returns 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/userInvites are 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 - the send-otp: / verify-otp: keys contain the PLAINTEXT phone (ephemeral, TTL-expired, a count only, NOT tied to a uid); the recover: 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):

  • phoneReclaims stores phoneHash together with requestedBy: <uid> (the reclaim requester). For users who go through phone-recycling reclaim, this IS a phoneHash -> uid association at rest - a narrow, deliberate dedup/audit record, but a real residual that the sealed auth_lookup does 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.

  1. New sealed signup: No issues
  2. Sealed login: No issues
  3. Wrong PIN: I got a "Pin is too easy to guess" during a login, this should only happen during signup.
  4. Legacy lazy-seal: Didn't see any issues but will want to confirm that the phoneHash is removed from the users doc.
  5. Returning re-signup: Sign up isn't available without a testing link. Defer for now.
  6. Recovery phrase: No issues
  7. Banner number:
  8. 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".
DateEnvBuild flag(s)Cases runResultNotes
(template)devserver+client one.g. 1,2,3,4pass/failobservations, 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.

RunStepsPass =ยง9
New sealed signupreset -> signup with PINFirestore: auth_lookup/{hash} present; users/{uid} has NO phoneHash1
Sealed loginlog out -> log in, correct PINresolves via blob; lands in app2
Wrong PINlog in, wrong PINclean failure, no lockout weirdness3
Legacy lazy-sealaccount made flag-off, then log in flag-onlogin works AND phoneHash disappears from users doc4
Returning re-signupreset withOUT deleting, re-run signupdiverted to recovery; no new UID7,8
Recovery phrasesealed account, run forgot-passphraserecovers + sets new PIN10
Banned numberban the test number, try signup403 BANNED at the gate12
Flag OFF regressionset both flags false, repeat new signup + loginbyte-for-byte today's behavior16

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 (default firebase).
  • 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 /token userId gate. Residual #2 (the /token userId-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) โ€‹

  1. Wrong-PIN on LOGIN shows "Pin is too easy to guess" (case 3): that validator should only fire on signup. RESOLVED (commit ed8a5484, predates this status note): validatePIN now takes { checkWeak }, and PhonePinLogin.jsx + LockScreen.jsx pass checkWeak: false so the weak-PIN strength check only runs at PIN creation (signup). Covered by encryption.test.js.
  2. 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: createUser permanently writes phoneHash with no plaintext phone, while the plaintext-phone lookup fallback is gated on STAGE_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_ENABLED is effectively a one-way door. Fix: lookupHandler now falls back to a users.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 in phone.test.js.
  3. Confirm phoneHash is actually removed from the users doc after lazy-seal (case 4 looked clean but was not confirmed in Firestore).
  4. 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:true call; migration crash/idempotency; recovery edge; race).
  • Resolve the ยง11 open questions still marked [VERIFY]/[Decide]: the auth_lookup Firestore deny rule, the cascade-delete service for start-over, blobKdf/schemaVersion values + rotation runbook, and whether Stage B ships gated on the OTP_PROVIDER=prelude flip 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 moderationApi client + the Manual ban/unban drawer: no tests.
  • Re-run the manual plan docs/engineering/testing/runs/2026-06-28-admin-ban-wiring/README.md against 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: userReports intake + the user-facing Report flow (Storybook-first), wire the admin Cases list to real reports, point the CaseDetailPanel Ban/Reinstate at the real moderationApi, plus harvest-at-login (capture a sealed account's number for a number-ban), moderator notices, and escalation. See docs/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; writes encryptedUserIdBlob, blobKdf="hkdf-sha256-v1", phoneSalt, encryptedSeed, authProofHash, schemaVersion=1); phoneSeal.js /seal + /rewrap-seed (complete transactional handlers, strip phoneHash + lingering plaintext phone); stageBFlag.js wired in 5 route files; dual-read in phone.js lookupHandler (returns encryptedUserIdBlob, never userId, on a sealed hit); reuseโ†’recover divert + startOver backend in phoneOtp.js; sealed createUser with uniqueness via both the auth_lookup point-read and the legacy dup scan.
  • Client: encryption.js blob crypto (deriveUserIdBlobKey / encryptUserIdBlob / decryptUserIdBlob, HKDF info="lantern-userid-blob-v1", + blobKdf allow-list gating); sealed login decryptโ†’/token (PhonePinLogin.jsx); recover-divert (PhonePinSignup.jsx); ForgotPassphraseModal.jsx sealed 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 in firestore.rules:133-136 plus the catch-all; blobKdf/schemaVersion literals 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/overturnBanHandler extracted + 18 tests in moderationBanAxes.route.test.js); the PhonePinLogin banned-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 admin moderationApi (15 tests) + ManualBan drawer (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 opaque userId to cascade its data. The old auth_lookup row + legacy users doc 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'ing users docs with no resolvable auth_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-otp is dormant under OTP_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, mirroring reportEvidenceHandler): 12 tests covering the self-ban / admin-ban / not-found guards, invalid-duration reject, the H-BAN banned_accounts write + banRecordIds cross-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. The PhonePinLogin banned-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) + admin moderationApi/drawer tests = #684; (3) the blobKdf rotation 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:true directly. 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).

Built with VitePress