Sealed-account identity, ban + login flow โ
Companion to SEALED_IDENTITY.md (esp. section 9 "subpoena flow", section 11.5 "honest ceiling", section 11.7 "login ban gate", section 14.6 "leakage audit"). Tracks issue #621.
Once an account is sealed (Stage B), the server can no longer answer "what phone owns this userId?" This doc shows the infrastructure that makes that true, then how banning works on top of it, why a banned user could still script their way back in, what we fixed, and the one residual we accept on purpose.
How phone and userId stay unlinkable (the infrastructure) โ
There are two Firestore collections on the login path, and they are keyed on two different things:
users/{userId}is keyed by the userId (the Firebase Auth UID).auth_lookup/{phoneHash}is keyed by the phoneHash (the doc id literally is the hash).
The only thing that ties a phoneHash row to a userId is the encryptedUserIdBlob field, and that is ciphertext whose key never exists on the server. So a database dump is two tables that share no plaintext join column.
flowchart LR
Phone["plaintext phone, E.164<br/>exists only on the client"]
Phone -->|"HMAC-SHA-256 with KMS pepper"| PH["phoneHash<br/>shape v1:hexdigest<br/>one-way, not reversible"]
subgraph AL["auth_lookup collection : phone side"]
ALrow["doc id IS the phoneHash<br/>encryptedUserIdBlob = ciphertext of userId<br/>phoneSalt, encryptedSeed, authProofHash<br/>NO plaintext userId"]
end
subgraph US["users collection : userId side"]
USrow["doc id IS the userId<br/>phoneSalt, encryptedSeed, authProofHash<br/>lanternName, banned, profile<br/>NO phoneHash, NO plaintext phone"]
end
PH -->|"point-read by doc id"| ALrow
ALrow -.->|"opens only with PIN-derived key"| USrowThe three cryptographic facts that hold it together โ
phoneHash = HMAC-SHA-256(KMS pepper, E.164 phone). One-way (you cannot get the phone back from the hash) and un-computable without the pepper, which lives in KMS. The same pepper covers bothusersandbanned_accounts, so one rotation re-keys every surface at once.encryptedUserIdBlob = AES-256-GCM(userId), key =HKDF(entropy, "lantern-userid-blob-v1"). Theentropyis 16 bytes that only exist after the client decryptsencryptedSeedwith the user's phone + PIN. The server never holds this key. It can read the blob; it cannot open it.authProofHash = HMAC-SHA-256(entropy, "lantern-auth-proof-v1"). Lets the server verify "this caller knows the PIN for this userId" without the server ever learning the PIN or the entropy. This is how/tokenauthenticates without needing the phone.
Why the link only resolves in one direction โ
flowchart TD
subgraph FWD["FORWARD: phone to userId, what login does"]
F1["client holds phone and PIN"] --> F2["server computes phoneHash,<br/>point-reads auth_lookup"]
F2 --> F3["server returns encryptedUserIdBlob,<br/>which is ciphertext"]
F3 --> F4["CLIENT decrypts the blob<br/>with its PIN-derived entropy key"]
F4 --> F5["client now holds its own userId<br/>the server never saw it in plaintext"]
end
subgraph REV["REVERSE: userId to phone, what a leak would need"]
R1["start from a userId"] --> R2["users.userId has<br/>no phoneHash and no phone"]
R2 --> R3["auth_lookup is keyed by phoneHash,<br/>there is no userId index"]
R3 --> R4["the userId in each row is ciphertext<br/>with no server-held key"]
R4 --> R5["dead end: cannot resolve without<br/>brute-forcing every row"]
endThe forward path resolves phone to userId, but the resolution happens on the client, after the PIN unlock. The server only ever shuffles ciphertext. The reverse path has no index and no key, so it terminates.
Sealed login, step by step (who computes what) โ
sequenceDiagram
participant C as Client
participant S as Auth server
participant DB as Firestore
C->>S: POST /lookup with the phone
S->>S: compute phoneHash from phone and pepper
S->>DB: read auth_lookup by phoneHash
DB-->>S: encryptedUserIdBlob, salt, seed
S-->>C: blob, phoneSalt, encryptedSeed
Note over C: decrypt seed with PIN to get entropy
Note over C: decrypt blob with entropy to recover userId
C->>S: POST /token with userId and proof
S->>DB: read users by userId, verify proof
S-->>C: Firebase custom token
Note over S,DB: the server never held phone and userId togetherThe punchline is the last note: at no point does a single server-side step hold both the phone (or phoneHash) and the userId in plaintext. The phoneHash step happens before the userId is known; the userId step happens with no phone in scope.
What each store holds, and what it must never hold โ
| Store | Keyed by | Holds | Must never hold |
|---|---|---|---|
users/{userId} | userId | phoneSalt, encryptedSeed, authProofHash, lanternName, banned, profile | phoneHash, plaintext phone |
auth_lookup/{phoneHash} | phoneHash | encryptedUserIdBlob (ciphertext), salt, seed, authProofHash | plaintext userId |
banned_accounts/{banId} | random banId | phoneHash and/or emailHash, reason, severity | userId (was scrubbed from evidence; see below) |
Known, documented exceptions (tracked in SEALED_IDENTITY.md section 11.5 / 14.6, not introduced here): the phoneReclaims collection deliberately stores phoneHash with requestedBy: <uid> for the number-recycling audit, and pre-migration legacy (non-custom-token) Firebase Auth records still carry a phone on the UserRecord until they seal. Both are why we say "no at-rest store maps phone to userId" only for fully-sealed accounts.
The subpoena test โ
This is the sharpest way to see non-collision: hand the server a phone number and ask for the userId. It computes the phoneHash (it has the pepper), point-reads auth_lookup/{phoneHash}, and gets back encryptedUserIdBlob -- ciphertext it cannot decrypt, because the key is the user's PIN-derived entropy. So even with the phone in hand, the server cannot produce the userId. That is the whole point of Stage B.
The ban model: two controls, kept separate โ
Banning rides on top of that model as two independent controls that are never written into one record:
| Ban path | Number ban (phoneHash row) | userId disable (Auth + flag) |
|---|---|---|
POST /auth/moderation/ban (by userId, from a report) | yes, if a hash is derivable | yes |
POST /auth/moderation/ban-phone (by number) | yes | no |
npm run otp:test:ban (dev tool, by number) | yes | only for legacy accounts it can resolve; not sealed |
- The number ban is a
banned_accountsrow keyed by phoneHash.isPhoneBanned(phoneHash)is a pure set-membership check that never produces a userId. It blocks re-registration and the/lookuplogin. - The userId disable sets the Firebase Auth user to
disabledand writesusers.banned = true. It blocks the/tokenmint and token redemption. It never touches a phone.
The dev tool failing to disable a sealed account's Auth user is the seal working: it starts from a phone, and a sealed phone no longer resolves to a userId.
The login path and its gates โ
The /token request body is { userId, proofHmac } -- there is no phone in it. So nothing in the /token path can create a phone-to-userId link.
flowchart TD
Start(["Normal login"]) --> Lookup["POST /auth/phone/lookup<br/>client sends the phone"]
Script(["Scripted attacker<br/>skips /lookup<br/>reuses a scraped userId"]) --> Token
Lookup --> Hash["server hashes the phone<br/>into phoneHash"]
Hash --> G1{"phoneHash on<br/>banned_accounts?"}
G1 -- yes --> B1["403 BANNED<br/>number-ban gate"]
G1 -- no --> Res["return blob and userId"]
Res --> Token["POST /auth/phone/token<br/>body is userId plus PIN proof<br/>NO phone here"]
Token --> G2{"users.banned<br/>flag set?"}
G2 -- yes --> B2["403 BANNED<br/>userId-axis gate, NEW"]
G2 -- no --> G3{"Firebase Auth user<br/>disabled?"}
G3 -- yes --> B2
G3 -- no --> G4{"doc still has phoneHash?<br/>legacy only"}
G4 -- "yes and banned" --> B3["403 BANNED<br/>legacy phoneHash re-check"]
G4 -- "no, so sealed" --> Mint["mint custom token"]
Mint --> Redeem{"signInWithCustomToken"}
Redeem -- "Auth disabled" --> RJ["rejected by Firebase<br/>auth user-disabled"]
Redeem -- "Auth enabled" --> Done(["logged in"])The bug in #621 residual #2: the attacker arrow skips /lookup (the only phoneHash gate) and posts straight to /token. Before the fix, the only /token ban check was the legacy phoneHash re-check (G4), which no-ops for a sealed doc because sealing deletes phoneHash. So a banned sealed user with their own userId + correct PIN sailed through.
The fix adds G2 and G3 -- two userId-keyed checks (users.banned, Auth disabled) that both survive sealing (sealing strips only phoneHash + plaintext phone). They need no phone, so they close the bypass without un-sealing anything.
What is closed vs. what remains โ
flowchart TD
Q1{"Account sealed?"}
Q1 -- "no, legacy" --> L["phoneHash still on the user doc,<br/>so every ban path can resolve the userId,<br/>so login is blocked"]
Q1 -- "yes" --> Q2{"Banned how?"}
Q2 -- "by userId, from a report" --> S1["users.banned plus Auth disabled,<br/>so the /token userId-gate blocks it<br/>Case 1, FIXED"]
Q2 -- "by NUMBER only" --> S2["no userId-side signal exists,<br/>scripted /token still mints<br/>Case 2, accepted residual"]
S2 --> S3["bounded by the /lookup gate for normal flow,<br/>the 1h token TTL for a live session,<br/>the durable re-registration block,<br/>and closes the moment any moderation<br/>action ever touches the userId"]Case 2 cannot be closed by a userId-side check at /token, by construction -- /token would have to resolve userId-to-phone, the exact link Stage B destroyed. It is closed instead by getting the banned account's phoneHash onto the banned_accounts list, which both stops re-registration (signup checks it) and stops the normal login (the /lookup gate checks it). The number reaches that list two seal-safe ways, neither of which resolves userId-to-phone -- see the next section.
Capturing the number for a sealed account (two seal-safe inflows) โ
The durable control against a banned person returning is the phoneHash row in banned_accounts: signup checks it, and the /lookup login gate checks it. For a sealed account the only question is how that row gets written, since the server cannot derive the number from the userId. There are two ways in, and both store only the phoneHash, never the userId:
flowchart LR
A["a moderator already has<br/>the number, out-of-band"] -->|"ban-phone endpoint"| H["server hashes it<br/>to phoneHash"]
B["the banned user shows up<br/>at login and presents<br/>their own phone"] -->|"signed phone token"| H
H --> R["banned_accounts row:<br/>phoneHash only, NO userId"]
R --> X["blocks re-registration at signup<br/>and login at the /lookup gate"]Inflow 1, admin-supplied number (backend built). POST /auth/moderation/ban-phone takes a plaintext number the moderator already holds, hashes it with the pepper, and writes the row. No userId is ever in scope. The backend exists today; the portal affordance (an "also ban this number" option on the ban modal) is prototyped in the admin Moderation page (see "Admin Moderation prototype" below).
Inflow 2, harvested at login (proposed). Now specced concretely in SEALED_BAN_RECONCILIATION_SPEC.md (adds the authProofHash pairing check this sketch was missing, plus the optional-then-required phone-token rollout). The server cannot ask "what is this userId's number?", but the banned user hands it over at login. /lookup already holds the phone for that attempt, so it signs a short-lived token carrying the phoneHash and returns it. /token already knows the userId and whether it is banned. When a banned userId arrives carrying a valid phone token, /token writes that phoneHash to banned_accounts and returns 403. The number is captured the moment the banned user knocks.
sequenceDiagram
participant C as Client
participant S as Auth server
participant DB as Firestore
Note over DB: moderator already set users.banned by userId
C->>S: POST /lookup with the phone
S->>S: hash phone to phoneHash, sign a short-lived phone token
S-->>C: blob, salt, seed, phoneToken
C->>S: POST /token with userId, proof, phoneToken
S->>S: userId is banned and the phoneToken is valid
S->>DB: write banned_accounts row, phoneHash only
S-->>C: 403 BANNED
Note over DB: number now blocks re-registration, no userId storedWhy this keeps the seal. Both inflows write the same phoneHash-only row that every phone ban already writes. The phone and the userId only co-exist for the milliseconds of one live login request (Inflow 2), where the user is actively authenticating with both; nothing persists or logs the pair. The at-rest "no phone-to-userId map" guarantee is untouched.
Coverage and the airtight variant. Inflow 2 captures the number on the banned user's first return through the normal app. To close it even against a scripted client that skips /lookup, make the phone token required at /token: then no login can proceed without presenting the phone, so every returning banned user is captured. The cost is a coordinated client + server rollout, which both current clients already satisfy (they always call /lookup before /token).
Admin Moderation prototype (case to ban) โ
A visual prototype of the moderator side lives at apps/admin/src/admin/moderation/ (mock data, no backend). It is the UI for the two-control model above: a case list correlated to a reported userId, a slide-in case drawer (Reports / Activity / Notes), and a ban action whose modal carries the "also ban this number" option (Inflow 1) plus the sealed-account explanation.
Case lifecycle: open (new, untriaged) to reviewing (a moderator opened or picked it up) to actioned (banned) or dismissed (no violation). Reinstating a banned account returns it to reviewing.
It assumes a future userReports collection keyed by reportedUserId (the correlation field), modeled on the existing venueClosureReports pattern. Wiring targets, when built: Ban to POST /auth/moderation/ban (plus ban-phone when a number is supplied), Reinstate to POST /auth/moderation/unban. Everyone is shown by Lantern name + userId only (no PII).
Guards that keep bans from re-linking phone and userId โ
The one place this could regress is the /auth/moderation/ban write path: a banned_accounts row can hold a phoneHash, and /ban cross-links that row back to the userId (via users.banRecordIds and the adminActions audit). If a sealed account's ban ever wrote both a phoneHash and a userId reference into that graph, the pair would re-create the sealed link. Guards in place:
- The auto-generated
evidencenote no longer embeds the plaintext userId (it wasban userId=<id>; now a non-identifyingsource:moderation-ban). The userId-to-banId trail lives only inadminActions, on the userId side, where it reveals nothing about the phone. - For a sealed target,
gatherBanHashesreturnsphoneHash=null, so no phoneHash enters this userId-linked row today. A code comment inmoderation.jsrequires any future sealed-account number-ban to be written as a standalone phone-ban (no userId in evidence, nobanRecordIdslink), never through the userId-keyed path.
Code map โ
| Concern | Location |
|---|---|
/token userId-axis ban gate (the fix) | phone.js tokenHandler |
/lookup phoneHash login gate + sealed dual-read | phone.js lookupHandler |
| Sealed lookup row (ciphertext userId, the bridge) | authLookup.service.js |
| What sealing strips from the user doc | phoneSeal.js (phoneHash + phone only) |
| phoneHash + proof crypto | customToken.service.js, @lantern/shared/encryption/phoneHash |
| Ban by userId (+ disable + revoke) | moderation.js POST /ban |
| Ban by number (standalone) | moderation.js POST /ban-phone |
| phoneHash membership check | bannedAccounts.service.js isPhoneBanned |