Skip to content

Sealed-account ban reconciliation: implementation spec โ€‹

Status: Draft for review (2026-07-01). Owner: Mechelle. Companion to SEALED_ACCOUNT_BAN_FLOW.md (the model) and SEALED_IDENTITY.md (the invariants). Tracks issue #621. Framing: This spec is written sealed-only. Legacy (un-sealed) accounts auto-seal on their next login via the existing lazy seal (POST /auth/phone/seal) and are treated only as a transient migration state, never as a parallel design branch. Where a rule differs for a not-yet-sealed row, it is called out as "legacy transitional," not designed around.

This makes concrete the "Inflow 2, harvested at login (proposed)" section of the ban-flow doc, adds the security piece that proposal was missing (the authProofHash pairing check), and settles the reconciliation semantics in both directions. Phase 2 (phone reclamation) builds on the same primitives and is previewed at the end.

How to read this doc: every section starts with an ELI5 line (plain-language summary). Skim the ELI5 lines plus the Flow at a glance diagram for the whole story in two minutes; drop into the numbered detail when you implement.


1. Goals and non-goals โ€‹

ELI5: What we're fixing (a banned person on a sealed account can quietly come back) and what we're saving for later.

Goals

  1. Arm a durable re-registration block for a sealed account banned by userId. Today a moderator /ban on a sealed account writes only users.banned + Auth disabled; it cannot reach the phoneHash, so no banned_accounts row is written and the person can re-register on the same number. Close that.
  2. Close the #621 residual #2: a sealed account banned by number only, scripted straight to /token (skipping the /lookup phoneHash gate), still mints.
  3. Do both without ever persisting a phone-to-userId link at rest, and without either identifier reaching the logs.
  4. Keep every existing fail-open and uniform-error property intact (a config or infra blip must never lock out all logins, and the 403 must never reveal which axis tripped).

Non-goals (this spec)

  • Phone-number change (C2 lifecycle). Out of scope; see Stage B plan ยง13.
  • Reclamation false-positive handling and severity-aware gating. Phase 2, previewed in ยง12.
  • The offline-PIN-crack remediation (moving encryptedSeed delivery from /lookup to post-proof /token). The phone token is a natural vehicle for it, but that migration stays a separate slice; see ยง11.

2. The model in one paragraph (sealed-only) โ€‹

ELI5: A ban has two separate switches: one on the person's account, one on the phone number. In a sealed world the moderator can only flip the account switch, because the server can't get from a userId to a phone. The phone switch has to be flipped later, at the one moment the phone and the account legitimately meet again: the person's own login.

Two independent controls, never written into one record:

  • userId axis = users/{userId}.banned + Firebase Auth disabled. The moderator's control surface. Set by POST /auth/moderation/ban. Survives sealing. Enforced at /token.
  • phone axis = a durable banned_accounts row keyed by phoneHash, with no banRecordIds back-link and no userId in evidence. Blocks re-registration (createUser / verify-otp check it) and normal login (the /lookup gate checks it). Set by POST /auth/moderation/ban-phone, or armed at login by this spec.

The whole problem is that in a sealed world the moderator holds only the userId, and the server cannot derive the phoneHash from it. So the phone-axis artifact for a userId ban can only be created at a moment where the phoneHash and the userId legitimately coexist: the account holder's own login. This spec makes that moment safe and durable.


Flow at a glance โ€‹

ELI5: The picture below. A moderator bans the account; the phone number isn't blocked yet. The next time the banned person logs in, the server proves the phone and account are the same person, stamps the phone number, and slams the door, which also blocks any future re-registration.

mermaid
flowchart TD
    Mod["Moderator bans by userId<br/>(sealed account)"] --> Set["Auth disabled + users.banned=true<br/>NO phone row yet"]

    Login(["Banned user tries to log in"]) --> Lookup["POST /lookup<br/>server hashes phone -> phoneHash"]
    Lookup --> G1{"phoneHash already on<br/>banned_accounts (active)?"}
    G1 -- yes --> B1["403 BANNED<br/>number already armed"]
    G1 -- no --> Mint["return sealed blob<br/>+ signed phoneToken (120s, phoneHash only)"]
    Mint --> Dec["client decrypts its userId with the PIN"]
    Dec --> Token["POST /token<br/>userId + proof + phoneToken"]

    Token --> Pair{"pairing check:<br/>proof matches BOTH users AND<br/>auth_lookup authProofHash?"}
    Pair -- "no (mismatch / griefer)" --> Refuse["uniform 403<br/>no arm, no mint"]
    Pair -- yes --> Axis{"which axis is set?"}
    Axis -- "users.banned = true<br/>(userId ban)" --> Arm["ARM: write banned_accounts row<br/>phoneHash only, no userId link<br/>then 403"]
    Axis -- "only phoneHash banned<br/>(number ban)" --> Block["block this login (403)<br/>do NOT stamp users.banned"]
    Axis -- "not banned" --> OK(["mint custom token, log in"])

    Arm --> Durable["number now blocks<br/>re-registration (signup) AND<br/>next /lookup login"]

Reading it: the left column is the moderator action. The middle is a normal banned login that gets refused and arms the durable phone block on the way out. The pairing check is the guard that stops a griefer from getting a stranger's number banned. The not-banned path is untouched: ordinary logins never notice any of this.


3. The three new primitives โ€‹

ELI5: Three small building blocks. (1) A tamper-proof note the login step writes saying "the phone that just knocked hashes to X." (2) A proof that the phone and the account are the same person. (3) The rule for what to do when a banned person logs in.

3.1 Phone-context token (phoneContextToken.js) โ€‹

ELI5: A sealed note the /lookup step writes and hands to the client, which passes it to /token. Only our server can read it, and it self-destructs after 2 minutes. It lets /token learn the phoneHash without the client ever seeing it and without the server doing a forbidden phone-to-userId lookup.

A short-lived, server-signed token minted by /lookup (which already computes the phoneHash) and presented back by /token. It is the carrier that lets /token see the phoneHash for a sealed account without the client ever learning it (the client has no pepper) and without the server resolving userId-to-phone.

  • Shape: copy the existing otpProof.js idiom exactly (base64url(payload).hexMac, mac = HMAC-SHA256(secret, payloadB64), constant-time verify). Do not add jsonwebtoken/jose; there is no JWT lib in the repo and the house pattern is hand-rolled HMAC over Node crypto.
  • Payload: { phoneHash, purpose: 'phone_context', iat, exp: iat + 120, nonce }. Never carries plaintext phone or userId. TTL 120s.
  • Signing key: a new dedicated secret PHONE_CONTEXT_SECRET, wired exactly like OTP_PROOF_SECRET (Secret Manager, --update-secrets in both deploy-dev.yml and deploy-prod.yml, .env.local.example, bootstrap-env.mjs). A dedicated key (rather than reusing OTP_PROOF_SECRET) avoids cross-purpose replay coupling; the purpose claim is a second guard. Do not key it on PHONE_HASH_PEPPER (the pepper is a one-way hash key, not a signing key, and its rotation semantics differ: rotating the pepper rescans Firestore, rotating a signing key only invalidates ~120s of in-flight tokens).
  • Replay: security rests on the HMAC + 120s exp + nonce. Within the 120s window the token is replayable; that is acceptable because the token only asserts which phoneHash was looked up for this login, and the real authorization is the pairing check (ยง3.2) plus the PIN proof. If we later make it a hard ban gate we can add a one-shot consumed-nonce cache.

3.2 The authProofHash pairing check (the security lynchpin) โ€‹

ELI5: Before the server acts on the phone note, it makes the user prove (via the PIN they just used) that the phone and the account are the same person. A troll who grabs a stranger's note can't fake that proof, so they can't get the stranger's number banned.

The proposal in the ban-flow doc had a hole: an attacker could call /lookup with a victim's number, receive a phone-context token for the victim's phoneHash, then present it at /token with the attacker's own userId + proof, causing the server to arm a ban on (or otherwise act against) the victim's number. The fix binds the token's phoneHash to the userId the client is authenticating as:

At /token, the client's proofHmac is verified against users/{userId}.authProofHash as today, which gates the mint (the authoritative login check). The token's phoneHash is then trusted for the phone axis + arming only if it PAIRS with that userId:

  • Legacy (un-sealed) account: paired iff the token's phoneHash equals users/{userId}.phoneHash (the account still carries it). A direct compare, no extra read.
  • Sealed account: paired iff verifyProof(proofHmac, auth_lookup/{tokenPhoneHash}.authProofHash) succeeds. Both authProofHash copies are HMAC(entropy, "lantern-auth-proof-v1"), so this matches iff the caller knows the single PIN behind both records, proving the userId and the phoneHash are the same account.

This is a forward check (compare stored verifiers against one client proof), never a reverse lookup. An attacker cannot produce a proof that matches a victim's authProofHash without the victim's PIN.

Strict mode requires a PAIRED token. A missing / invalid / expired / un-paired token is rejected (uniform error), unless the server's token machinery is unconfigured (config fail-open, ยง5.4). This is what closes #621 fully, including the number-only sealed case: a holder whose own number is banned cannot obtain a paired token, because /lookup refuses to mint a token for a banned number (ยง5.2); and a token for some other number they control does not pair with their userId, so it is rejected too. Legitimate logins always present a paired token (you look up your own number).

  • Reuse the existing constant-time compare. verifyProof(proofHmac, storedHash) is module-private in customToken.service.js; export it so /token can run it against the auth_lookup copy.
  • Lockout stays keyed on the client-supplied userId (pinFailedAttempts / pinLockoutUntil on users/{userId}). The client always supplies the userId at /token, so there is no lockout gap.
  • An un-paired token is never used for a write. Cross-binding (a victim token presented with the attacker's own account) fails pairing, so the victim's phoneHash is never used for arming or the phone axis, and in strict mode the request is rejected. The attacker gains nothing they couldn't already do with their own number's token.

authProofHash drift is a non-issue. authProofHash = HMAC(entropy, ...), and entropy is stable for an account's life: a PIN change re-wraps the seed under a new PIN but does not change entropy, so it does not change authProofHash. Only account re-creation changes it, and that writes both rows fresh and in sync. So the two copies do not drift, and a pairing mismatch is therefore an attack/anomaly to reject, never a legitimate owner to wave through. (Invariant to preserve: any future path that could change authProofHash must write both copies. See EC-11.)

3.3 Login reconciliation at /token โ€‹

ELI5: If the account is banned, also stamp the phone number (so they can't just re-register). If only the number is banned, block this login but don't brand the account, because the number might have been recycled to an innocent new owner.

With the token verified and the pair confirmed, /token reconciles ban state. The two directions are deliberately asymmetric:

  • userId ban -> phone block (PERSIST): arm. If users.banned === true and the pair is confirmed and no active banned_accounts row exists for this phoneHash yet, write one now: createBan({ phoneHash, severity: severityForDuration(users.banDuration), reason: users.banReason, expiresAt: users.banExpiresAt, evidence: 'source:login-harvest', bannedBy: users.bannedBy }). No banRecordIds back-link, no userId in the row. Then refuse (403, uniform message). The re-registration block is now durable and survives start-over/reclaim.
  • phone ban -> account (DO NOT PERSIST): enforce transiently. If the phoneHash is banned but users.banned is false, do not stamp users.banned. Refuse the login (403) based on the phone axis, but leave the account record untouched. Rationale below.

Why not stamp users.banned from a phone ban (a refinement of the original instinct). A phone ban is number-scoped. If the number was recycled to an innocent new owner (a real case, see ยง12), the current holder is not the banned person. Persisting users.banned onto their account would carry a false-positive ban onto an identity that could outlive the number (e.g. they later change to a clean number). The /lookup + /token phone-axis checks already block the number transiently; that is sufficient for enforcement and leaves no wrong persisted state. We therefore close #621 by making the phone token required at /token (ยง5.4) so the phone axis is always evaluable for sealed accounts, rather than by persisting a userId stamp.


4. Enforcement matrix (who blocks where) โ€‹

ELI5: A cheat-sheet of who gets stopped at which door (sign-up, look-up, token) in each ban situation.

Situation (sealed account)signup (createUser/verify-otp)/lookup/token
userId banned, phone not yet armednot blocked (no phone row yet)not blocked (no phone row yet)blocked (userId axis) + arms phone row
userId banned, phone armedblocked (phone row)blocked (phone row)blocked (either axis)
number banned (ban-phone), account existsblocked (phone row)blocked (phone row)blocked (phone axis, needs required token)
number banned, scripted /token skipping /lookupn/an/ablocked once token is required (else open: the #621 residual)
not bannedallowedallowedallowed

The first row is the arming path: the first time a userId-banned holder logs in, /token refuses them and materializes the durable phone block, upgrading every subsequent row to "blocked everywhere."


5. Exact changes (line-anchored to current code) โ€‹

ELI5: The precise code edits: add a new tiny token module, mint the token in /lookup, verify + pair + reconcile in /token, and roll it out optional-first then required.

5.1 New module services/api/auth/src/lib/phoneContextToken.js โ€‹

mintPhoneContext(phoneHash, { secret = process.env.PHONE_CONTEXT_SECRET, nowSec }) and verifyPhoneContext(token, { secret, nowSec }) returning { valid, phoneHash } | { valid: false, error }. Mirror otpProof.js mint/verify verbatim; payload per ยง3.1; enforce purpose === 'phone_context' and exp.

5.2 /lookup (lookupHandler, phone.js:89-205): mint after the ban gate โ€‹

  • The phoneHash is computed at phone.js:126-127. The ban gate runs at phone.js:128-133. Mint the token only after the ban gate passes (never hand a usable token to an already-number-banned caller), and attach phoneContextToken to both success responses:
    • sealed response (phone.js:144-153),
    • legacy response (phone.js:194-201).
  • Do not mint for { exists: false } (phone.js:181): a non-account gets no token. (This means a first-time signup does not carry a token; that flow does not need one.)
  • If the pepper is unset (phoneHash null, the fail-open branch), no token is minted; /token then falls back rather than reject (config fail-open, ยง5.4).

5.3 /token (tokenHandler, phone.js:222-303): verify, pair, reconcile โ€‹

Extend TokenBody (phone.js:34-37) with an optional phoneContextToken: z.string().optional(). Then, after the phoneSalt check (phone.js:236) and before/within the checkLoginBan call (phone.js:261):

  1. verifyPhoneContext the token. A missing / invalid / expired token is rejected with a uniform error, UNLESS the server's token machinery is unconfigured (PHONE_HASH_PEPPER / PHONE_CONTEXT_SECRET absent), in which case /token falls back (config fail-open, ยง5.4).
  2. If the token is valid, extract tokenPhoneHash and run the pairing check (ยง3.2): legacy account -> tokenPhoneHash === data.phoneHash; sealed account -> verifyProof(proofHmac, readAuthLookup(tokenPhoneHash).authProofHash). Set pairedHash = tokenPhoneHash iff paired, else pairedHash = null. In strict mode, an un-paired (or missing/invalid) token is rejected (uniform error), except under the config fail-open of step 1. The users proof (step 5 / issueCustomToken) remains the authoritative mint gate; the pairing only decides whether the token's phoneHash is trusted for the phone axis + arming.
  3. Pass pairedHash into checkLoginBan({ phoneHash: pairedHash, userId, userData: data, log }) so the phone axis is evaluated for sealed accounts (today data.phoneHash is null for sealed, so this axis no-ops; a paired token fixes that).
  4. Arm if data.banned === true and pairedHash and !isPhoneBanned(pairedHash) (idempotent: skip if a row already exists): createBan({ phoneHash: pairedHash, severity: severityForDuration(data.banDuration), reason: data.banReason, expiresAt: data.banExpiresAt, evidence: 'source:login-harvest', bannedBy: data.bannedBy }), no banRecordIds back-link. Then sendBanned.
  5. Keep issueCustomToken(userId, proofHmac, data.authProofHash) (phone.js:271-275) and the lockout/error mapping (phone.js:280-301) unchanged.

5.4 Rollout: required token from the start (dev) โ€‹

ELI5: In dev we just make the ticket required from day one. No half-measures, no installed base to migrate around.

Decision (dev): required from the start. The optional / "gentle" phase was only a migration affordance for an installed base of already-deployed clients. In dev there is none, and we deploy client + server together, so /token requires a valid phone-context token directly. This closes #621 residual #2 immediately (a scripted client cannot skip /lookup) instead of leaving a temporary optional window and dead fallback code.

  • Keep PHONE_CONTEXT_REQUIRED as a flag so a future prod cutover can start in optional mode if stale PWA / service-worker caches would otherwise reject in-flight sessions. That is a launch-time tactic, not a dev concern; no extra code now.
  • Fail-open nuance (config, not adversary): /token enforces the token only when the server's own token machinery is configured (PHONE_HASH_PEPPER + PHONE_CONTEXT_SECRET present). If either is missing, /token falls back rather than reject, so a deploy misconfig cannot lock out every login. This keys on server config, not on the request, so an adversary cannot trigger it (they can't unset our secrets). Same principle as the existing pepper fail-open.

5.5 checkLoginBan (banEnforcement.service.js:93-125) โ€‹

No signature change required; it already accepts phoneHash. The only change is that /token now passes a non-null phoneHash for sealed accounts (from the token) instead of data.phoneHash (null). Preserve all three axes and both fail-open branches (phoneHash-null skip; Auth getUser error -> not banned).


6. Unban / reinstate model (the open decision) โ€‹

ELI5: Turning a ban off is harder than turning it on. The moderator can flip the account switch back, but they can't find the phone number from a sealed account to flip the phone switch. So lifting the phone block needs the number, which the user gives us in their appeal.

In a sealed world, a moderator /unban (by userId) clears the userId axis (liftUserBan: re-enable Auth, clear users.ban*), but cannot reach the phone-axis row (no userId-to-phoneHash). So a reinstated account whose phone was armed/banned is still blocked at /lookup and cannot log in to self-clear: the deadlock.

We do both, composed by tagging each phone-axis row with its provenance (source:ban-phone = a deliberate number ban; source:login-harvest = a row auto-armed from a userId ban):

Option A (recommended): moderator reinstate clears both axes, number sourced from the appeal. The reinstate action is /unban (userId) plus unban-phone (number). The number comes from the user's appeal via the in-portal appeal flow (ยง6.1), not an email (no plaintext number in an inbox). Clean, keeps the seal, no new attack surface. The admin "Reinstate" affordance runs both, and the moderator never sees the plaintext number (the appeal already carries the hash). Cost: unban is a two-input human action; a phone block cannot be lifted without the number.

Option B: auto-clear at proven login (provenance-aware soft block). Tag armed rows evidence: 'source:login-harvest'. /lookup soft-passes (returns the blob + token) for login-harvest rows instead of hard-blocking, deferring to /token; /token then, on a confirmed pair with users.banned === false, overturns the row and mints. Deliberate ban-phone rows still hard-block at /lookup. Cost: /lookup must read and branch on row provenance; a phone-banned holder logging in becomes a ban-clearing path, which widens the attack surface and complicates the "block the number" guarantee. More moving parts.

Decision (2026-07-01): both, split by provenance.

  • Deliberate number bans (ban-phone) use Option A only. A blocked number stays blocked until a moderator lifts it with the number (from the appeal). It must not self-clear just because someone logs in.
  • Auto-armed rows (login-harvest) use Option B (and A still works). Once a moderator has lifted the person's userId ban (users.banned=false), the leftover armed row self-clears at that user's next proof-paired login, so a reinstated user is never stuck behind a stale block. /lookup soft-passes login-harvest rows (returns the blob + token) so /token can adjudicate; it still hard-blocks deliberate ban-phone rows.
  • Safety restriction on B: it clears a row ONLY when the userId ban is already lifted (users.banned=false). It never lets a still-banned person clear their own block; it only cleans up a stale artifact after a legitimate reinstate. Cost: /lookup must read the row's provenance tag to choose hard-block vs soft-pass.

6.1 The appeal flow (in-portal, not email) โ€‹

ELI5: Someone who's blocked appeals right inside the app, not by emailing us a phone number. Their number is turned into a fingerprint on the way in and the plaintext is thrown away, so no number ever sits in an inbox and the moderator never sees it. The appeal shows up as an item in the moderation queue; one click reinstates.

Replaces the appeals@ourlantern.app email path. A phone number in an inbox is exactly the plaintext paper trail the sealed model avoids, so the appeal is an in-product flow instead:

  1. Submit + prove possession. The blocked person opens an in-app / web appeal form, enters their number, and proves they currently hold it with an OTP (same possession check as signup). This stops people appealing numbers they don't hold, and it is exactly how a genuine recycled-number newcomer proves they now own the line.
  2. Hash, discard, queue. The server computes the phoneHash, writes an appeal record keyed by that hash plus the appellant's message, and discards the plaintext number. No plaintext number is stored, emailed, or logged (per the ยง8 log rule). The appeal record carries no userId (phone-side only).
  3. Moderator review, number-blind. The appeal surfaces as an item in the moderation queue. The portal matches the appeal's phoneHash to the original banned_accounts row, so the moderator sees why the number was blocked (reason, severity, date) but never the plaintext number.
  4. One-click reinstate. Reinstate overturns the matching banned_accounts row by phoneHash (unban-phone), and, if the appeal is tied to a userId case, also runs /unban. The moderator never types or sees a number.

Privacy properties: the plaintext number transits once (over TLS, to be hashed) and is discarded, exactly as at signup/login; only the hash persists, on a nameless appeal record; the moderator adjudicates on case context, not the number. A lighter variant skips the OTP (number-in, hashed, queued) but loses the possession proof, so keep the OTP.

Fit: this is the same front door the innocent recycled-number newcomer uses (Phase 2, ยง12), so one flow serves both reinstatement and recycled-number appeals. It slots into the existing moderation surfaces (MODERATION_CASES.md), whose case backend is not yet built.


7. Edge-case matrix โ€‹

ELI5: Every weird "but what if..." we could think of, and what should happen in each.

Rule (from EC-17 below): for a sealed account, a userId ban is not self-sufficient. Always issue ban-phone alongside it when the number is available (this writes only a one-way hash of the number to a nameless blocklist, never the number, never a userId link), or re-registration via start-over is an accepted residual.

Caseย #ScenarioExpected behavior
EC-1Sealed account banned by userId, first login after ban/lookup passes (no phone row yet), returns token; /token confirms pair, arms banned_accounts row, refuses 403. Durable block now exists.
EC-2Same account, second login attempt/lookup hard-blocks on the now-armed phone row (403). Never reaches /token.
EC-3Banned userId re-registers fresh (start-over then signup), AND a phone-axis row EXISTS (armed at a prior login, or a ban-phone was issued)start-over deletes auth_lookup + users doc but not banned_accounts; createUser / verify-otp isPhoneBanned check -> 403 BANNED. Re-registration blocked by the durable row. (If NO phone-axis row exists, see EC-17.)
EC-4Number banned by ban-phone, existing holder logs in/lookup hard-blocks (403). /token never runs.
EC-5Number banned, scripted client posts /token directly (no token)Rejected for missing token (token is required, ยง5.4), which closes #621 residual #2. Exception: if the server's token machinery is unconfigured it fails open (config-only, see EC-9).
EC-6Cross-binding attack: attacker grabs victim's token, presents own userId+proofPairing fails (attacker proof does not match victim auth_lookup.authProofHash), so pairedHash=null: victim's phoneHash is never used for arming or the phone axis, and strict mode rejects the un-paired token. The attacker only ever gains access to their own account (which their own number's token allows anyway). Victim untouched, and learns nothing.
EC-7Expired phone token (>120s between /lookup and /token)Rejected; client re-runs /lookup to mint a fresh token.
EC-8Forged/tampered tokenHMAC verify fails -> rejected (uniform error).
EC-9Pepper unset (fail-open)/lookup mints no token; /token phone axis skipped; userId axis + Auth-disabled still enforce. No lockout of all logins.
EC-10Auth getUser transient error at /tokenAxis 3 fails open (not banned), as today; userId flag axis still gates.
EC-11authProofHash "drift" (would a PIN change desync the users and auth_lookup copies?)No. authProofHash = HMAC(entropy) and entropy is stable across PIN changes (a PIN change only re-wraps the seed), so the copies do not drift; only account re-creation changes it, writing both fresh and in sync. A pairing mismatch is therefore an attack/anomaly (pairedHash=null, strict rejects), never a legitimate owner. Invariant to preserve: any future path that changes authProofHash must write both copies (ยง3.2). Never auto-resync a row from an unpaired userId (that would let an attacker overwrite a victim's verifier).
EC-12Idempotent arming (row already exists)isPhoneBanned true -> skip createBan, just refuse. No duplicate rows.
EC-13Legacy (un-sealed) account, transitionaldata.phoneHash is present on the user doc, so /token's existing legacy re-check already works; the token path is a no-op superset. Account seals on next successful login and converges to the sealed path.
EC-14Temporary ban expiry during loginExisting lazy-expiry in checkLoginBan (axis 2) lifts the userId axis; if a phone row was armed, it carries its own expiresAt (from users.banExpiresAt at arm time) and expires in parallel via isPhoneBanned's active check. Verify both expiries align.
EC-15Reinstated userId, phone still armed (login-harvest row)/unban clears the userId axis; the leftover armed row then self-clears at the user's next proof-paired login (Option B, ยง6), or a moderator clears it immediately via unban-phone (Option A). A deliberate ban-phone row clears via A only.
EC-16Multi-tab / double submit at /tokenArming is idempotent (EC-12); createBan guarded by the isPhoneBanned pre-check. No duplicate or race-created rows of concern (same-hash active check is unbounded-scan safe).
EC-17Sealed userId ban, owner NEVER logs in, then verify-otp + start-over + re-register on the same numberCurrently EVADES. No banned_accounts row was ever written (sealed /ban can't derive the phoneHash; login-harvest arming never fired), so isPhoneBanned (phoneOtp.js:211) passes and start-over mints a fresh clean account, discarding the banned one. Fix: pair the userId ban with ban-phone when the number is available (writes the durable hash-only row, so isPhoneBanned blocks start-over). Number unavailable: accepted residual, same family as #621. The phoneOtp.js:262 comment "Ban was re-checked above" re-checks only the phone axis, not the userId axis.

8. Privacy and log analysis โ€‹

ELI5: Proof that we never write the phone-and-account pair down anywhere permanent, and never leak either one into the logs.

  • No new at-rest link. The armed row is a phoneHash-only banned_accounts row (no userId, no banRecordIds), identical in shape to a ban-phone row. The userId axis (users.banned) holds no phoneHash. Neither references the other.
  • Transient coexistence only. The phoneHash and userId coexist in memory for the milliseconds of one authenticated /token request, where the user is actively proving both halves. This is the already-accepted "in-use login moment" residual (SEALED_IDENTITY.md ยง11.5), not a new exposure.
  • Logs (hard rule). A phone number, a phoneHash, or a phone-context token MUST NEVER appear in a log line, ever. GCP logs cannot be surgically deleted (see LOG_HYGIENE.md), so a single leaked number is a permanent paper trail. Enforcement: (a) log calls pass only an event tag, a bare userId, a banId, or a mode-name string, never a phone/hash/token (the arm path logs at most { event: 'ban.login_harvest_arm' }); (b) PINO_REDACT_CONFIG scrubs request bodies/headers, and we add req.body.phoneContextToken to it; (c) definition of done includes an automated test asserting no phone number, phoneHash, or token appears in captured logs on the /lookup, /token, and ban paths. Verified 2026-07-01: the current auth service logs no phone/phoneHash value (only a lookup-mode string and static pepper warnings).
  • Client never learns the phoneHash (no pepper; the token is opaque HMAC). Preserves the "client cannot enumerate phoneHashes" property.

9. Secrets, env, deploy wiring โ€‹

ELI5: The new signing key the token needs, and every place it has to be plugged in so it works on dev and prod.

  • New secret PHONE_CONTEXT_SECRET (per-environment, distinct from dev/prod). Provision in Secret Manager; mount via --update-secrets=PHONE_CONTEXT_SECRET=PHONE_CONTEXT_SECRET:latest in both deploy-dev.yml and deploy-prod.yml (add now even while prod OTP stays firebase, so /token does not 503 on prod later). Add to .env.local.example and tooling/scripts/bootstrap-env.mjs. Read as process.env.PHONE_CONTEXT_SECRET. Never inline in shell (per the no-inline-secrets rule).
  • No IAM change: the auth-api runtime SA already has project-wide secretAccessor.
  • New flag PHONE_CONTEXT_REQUIRED (default true in dev; set false only for a prod cutover window). Even when true, /token fails open if the server's own token machinery is unconfigured.

10. Test matrix (auth-change rigor) โ€‹

ELI5: The tests that must pass before we trust this: the token math, the pairing check, every edge case, and a live smoke on a test number.

Unit

  • phoneContextToken: mint/verify round-trip; wrong secret; expired; tampered payload; wrong purpose; nonce presence.
  • pairing check: match on both verifiers; mismatch on auth_lookup copy; drift tolerance (valid users, stale auth_lookup).

Integration (auth-api, dev Firestore or emulator)

  • EC-1 through EC-16, each as a test.
  • Arming idempotency under concurrent /token (two parallel requests -> one row).
  • Fail-open matrix: pepper unset, PHONE_CONTEXT_SECRET unset, Auth error, missing/expired token.

Manual (dev, Prelude test number only, per the standing guardrail)

  • Ban by userId -> log in -> confirm 403 + a banned_accounts row appears (phoneHash only, no userId) -> re-login blocked at /lookup -> attempt re-registration blocked at signup.
  • Reinstate (Option A): /unban + unban-phone -> confirm login succeeds.
  • Add a run folder under docs/engineering/testing/runs/ following the existing per-run pattern.

Tooling: extend ban-doctor.mjs (already written, services/api/auth/scripts/) to show the armed row's provenance tag, so operators can see arming happen.


11. Interaction with the offline-PIN-crack TODO โ€‹

ELI5: There's a separate known weakness (/lookup hands out crackable material to anyone). The token creates the exact round-trip that would fix it, so build them to fit together, but that fix is its own slice.

/lookup returns phoneSalt + encryptedSeed to unauthenticated callers (flagged inline at phone.js:100-106). The phone token establishes exactly the /lookup-to-/token round-trip that the real remediation wants (deliver the seed only after proof). This spec does not implement that migration, but it should be built to compose with it: the same token can later gate seed delivery. Note the dependency; keep it a separate slice.


12. Phase 2 preview: reclamation (separate spec) โ€‹

ELI5: The next project. Phone numbers get recycled to new people. A permanent ban on a recycled number would wrongly block an innocent newcomer forever. Phase 2 fixes that.

Built on the same primitives, addressing the recycled-number false-positive the current model does not:

  1. Consult severity tiers at the gate. Today isPhoneBanned is active-vs-expired only; warning/shadow block identically to permanent. Make the gate severity-aware so non-permanent tiers behave as designed.
  2. Bound permanent phone bans against recycling. A permanent phone ban is expiresAt: null forever, so a recycled number stays blocked for an innocent new owner. Options: a maximum hashed-identifier retention (the design-only "1-year deletion" in SAFETY_MECHANICS.md, currently unimplemented, with a reaper), and/or routing a recycled-number signup denial into the reclaim/appeal flow.
  3. Reclaim for sealed numbers. Today reclaim is legacy-only (users.where(phoneHash) misses sealed rows). A sealed-aware reclaim needs the same login-harvest style proof, not a server-side resolution.

The recycled-number false-positive is currently undocumented in the residual lists; documenting it is part of phase 2.


13. Code map โ€‹

ELI5: Where each moving piece lives in the codebase.

ConcernLocation
/lookup + /token handlers, ban gatesphone.js lookupHandler (89), tokenHandler (222)
Ban chokepoint (3 axes)banEnforcement.service.js checkLoginBan (93), liftUserBan (34)
phoneHash membership + create/overturnbannedAccounts.service.js isPhoneBanned, createBan, overturnBan
Sealed row (authProofHash copy)authLookup.service.js readAuthLookup, writeAuthLookup
Proof verify + lockoutcustomToken.service.js verifyProof (private, to export), issueCustomToken, computeAuthProofHash
Signing idiom to copyotpProof.js mintProof/verifyProof
Moderation ban/unban routesmoderation.js /ban, /ban-phone, /unban, /unban-phone
Registration + teardown gatesphoneCreateUser.js, phoneOtp.js start-over, phoneRecycling.js
New: phone-context tokenservices/api/auth/src/lib/phoneContextToken.js (to add)

14. Open questions for review โ€‹

ELI5: The choices, now mostly decided.

Decisions (2026-07-01):

  1. Unban model: BOTH (ยง6). Option A (moderator + number from appeal) always applies; Option B (auto-clear at proof-paired login) applies to login-harvest rows once the userId ban is lifted. Split by provenance tag.
  2. Persist users.banned from a phone ban: NO (recycled-number safety, ยง3.3). Decided.
  3. Token requirement: REQUIRED from the start (dev). The optional / gentle phase was only a migration affordance for an installed base; in dev there is none and we control client + server, so /token requires the token directly (closes #621 immediately). PHONE_CONTEXT_REQUIRED stays as a flag for a possible prod cutover. Fail-open retained when the server's own token machinery (pepper / PHONE_CONTEXT_SECRET) is unconfigured.
  4. PHONE_CONTEXT_SECRET: dedicated key (not reusing OTP_PROOF_SECRET). Decided.

Built with VitePress