Skip to content

Sealed Identity Architecture β€” Decision Brief ​

Status: Approved direction. Implementation in two stages (Β§4); legal documentation runs in parallel (Β§6), not as a gate. Owner: TBD (privacy lead). Companion plan: docs/superpowers/plans/2026-05-10-sealed-identity-spike.md.

Posture: Architectural privacy protections are not subject to legal-review veto. The premise of this work is that if we cannot produce a piece of data due to how the system is built, we cannot be compelled to rebuild the system to produce it. That position is well-supported in US law (All Writs Act limits, Apple/FBI 2016) and is consistent with Lantern's Immutable Right #6 (cofounder agreement). Counsel's role here is to make sure our privacy policy, ToS, and subpoena-response playbook accurately describe the architecture β€” not to grant or withhold permission to ship it.

1. Summary ​

Lantern's privacy commitments (Immutable Right #6, Business Plan Β§3.2) are met today by client-side profile encryption and k-anonymity gating, but the phone-number β†’ account-record link is server-resolvable in plaintext. A subpoena of the form "what account has phone +1-555-…?" returns a userId directly. This document proposes a two-stage hardening of that link, evaluates legal/operational/engineering cost, and lists trigger criteria for committing to it.

The brief is intentionally narrow: it covers the auth-table identity link only. Profile data sealing (PBKDF2 + AES-GCM, apps/web/src/lib/encryption.js) is already in place and out of scope.

2. v1 reality (what the code actually does today) ​

Earlier drafts of this brief described a v1 with HMAC-hashed phones, a login_events table, and a banned_phone_hashes index. None of those exist. This section is the authoritative description.

2.1 Storage ​

Phone-PIN users live in the Firestore users/{userId} collection. Relevant fields:

FieldContentsSensitivity
phoneE.164-normalized phone number, plaintextHigh β€” direct PII
phoneSaltPer-user salt for client-side wrapping-key derivationPublic-by-design
encryptedSeedBIP39 entropy AES-GCM-wrapped with a phone+PIN-derived keySealed (we cannot decrypt)
authProofHashHMAC-SHA256(entropy, "lantern-auth-proof-v1")Hash; verifier only
pinFailedAttempts, pinLockoutUntilServer-enforced lockout stateOperational
encryptedBirthDate, encryptionCanary, saltProfile encryptionSealed
lanternName, authMethod, lastLoginAtDisplay + auditLow–medium

There is no auth_table distinct from users/{userId}. The phone→userId link is just users.where('phone', '==', normalized).limit(5) (services/api/auth/src/routes/phone.js:42).

2.2 Login flow (zero-knowledge proof of PIN, server-resolvable identity) ​

  1. Client POST /auth/phone/lookup with { phone }. Server queries users by plaintext phone, returns { userId, phoneSalt, encryptedSeed, lanternName, authMethod }.
  2. Client derives wrapping key from phone+PIN, decrypts encryptedSeed β†’ entropy (16 bytes).
  3. Client computes proofHmac = HMAC-SHA256(entropy, "lantern-auth-proof-v1") and POST /auth/phone/token with { userId, proofHmac }.
  4. Server compares against stored authProofHash with timingSafeEqual (customToken.service.js:46–55). On match, issues a Firebase custom token; on mismatch, increments pinFailedAttempts; locks out at 5 failures for 15 minutes.

What this already gets us: server cannot recover the PIN; encryptedSeed is not decryptable server-side; profile fields are sealed.

What this does not get us: step 1 returns userId indexed by plaintext phone. A compelled-disclosure request providing a phone number gets back the userId and the entire users/{userId} document (minus the sealed fields).

2.3 Login event logging ​

There is no central login_events collection. PIN attempt counters live on users/{userId} itself. Admin and merchant logins write to adminActions (no IP) (adminAuth.js:81–84). User phone+PIN logins update lastLoginAt only. Cloud Run access logs (timestamps, IPs, request paths) exist outside Firestore at the platform layer.

2.4 Ban enforcement ​

User-level bans are implemented at the userId level only (moderation.js:22–66) β€” sets users/{userId}.banned, disables Firebase Auth account. No phone-hash ban table exists. The banned_accounts collection sketched in docs/features/safety/SAFETY_MECHANICS.md (bcrypt-hashed phone + email) is designed but unbuilt.

3. The actual gap, in subpoena terms ​

Questionv1 response
"Does an account exist for this phone?"yes/no
"What's the userId for this phone?"returns userId directly
"What did this user do?"full users/{userId} doc + any userId-indexed records (frens, waves, lit-lantern events)
"Decrypt their profile"cannot β€” sealed
"Decrypt their seed / PIN"cannot β€” sealed

The first three rows are the meaningful disclosure surface. Sealing the third (everything keyed by userId) is hard β€” it's the operational data of the app. Sealing the first two is what this proposal addresses.

4. Proposal β€” two stages, not one ​

The original brief jumped straight to encrypting the link. That skips the bigger and cheaper win.

Stage A β€” Hash the phone (no behavior change for users) ​

Replace plaintext users/{userId}.phone with phoneHash = HMAC-SHA256(KMS_pepper, e164(phone)). Lookup becomes users.where('phoneHash', '==', clientOrServerComputedHash).limit(5). KMS pepper rotation strategy TBD in spike.

This alone:

  • Removes plaintext PII from the dominant subpoena entry path
  • Forces a compelled-disclosure request to either provide the pepper-hashed value (which they cannot, without our KMS access) or compel us to compute it (legally distinguishable from "produce the row")
  • Unblocks the banned_accounts design in SAFETY_MECHANICS.md (same hash form)
  • Doesn't touch the proof-of-entropy chain

This is roughly the v1 the original brief thought we already had.

Stage B β€” Seal the userId resolution (the original proposal, restated) ​

After Stage A, users/{phoneHash β†’ userId} is still server-resolvable: phone-with-pepper β†’ row β†’ userId. Stage B encrypts the userId itself with a passphrase-derived key:

auth_lookup:  (phoneHash, encryptedUserIdBlob, phoneSalt, encryptedSeed, authProofHash)
              encryptedUserIdBlob = AES-256-GCM(userId, key = HKDF(entropy))

The login flow gains one step:

  1. POST /auth/phone/lookup returns { phoneSalt, encryptedSeed, encryptedUserIdBlob, authProofHash } β€” no userId.
  2. Client decrypts encryptedSeed with PIN β†’ derives entropy β†’ derives blob key via HKDF β†’ decrypts encryptedUserIdBlob β†’ has userId.
  3. Client sends { userId, proofHmac } to /auth/phone/token as today.

Server never observes phoneHash β†’ userId resolution as a single readable step. Compelled disclosure of the auth_lookup row yields a hashed phone and an opaque blob.

Trade-offs vs. v1:

CapabilityStage AStage B
Subpoena "userId for phone X"hash + row, still resolvableciphertext only
CS lookup by phoneunchanged (compute hash, query row)requires user-initiated session sharing
Anti-fraud on phone-reuseunchangedreduced; phone-hash visible but cannot link to userId activity
banned_accounts enforcementenables itunaffected (independent index)
Login UXunchangedone extra round-trip's worth of decryption (sub-50ms client-side)
Engineering scopemedium (~1 sprint)high (multi-sprint, plus migration)

5. Trigger criteria ​

Stage A: should land before Phase 5 (soft launch) per INITIAL_LAUNCH.md. Plaintext phones in production are difficult to walk back once we have real users.

Stage B: should also land before Phase 5 (soft launch) if engineering capacity allows. The case for sealing pre-launch:

  • Sealing the architecture before the first subpoena lands looks like a privacy commitment; sealing it after looks like obstruction.
  • Migrating existing users is harder than building it for new users β€” every month of pre-launch growth raises the migration cost.
  • Public privacy claims (privacy policy, marketing) made under a v1 architecture become legal liabilities if we then change the architecture and the claims drift.

If engineering capacity forces a deferral, Stage B can ship in a Phase 4–5 patch window or, at the latest, Phase 6.

Market-entry triggers that would force Stage B regardless of timing:

  • Move into a jurisdiction with compelled-redesign powers (UK IPA, Australia TOLA). Those jurisdictions can order us to log decrypted data going forward β€” the only architectural defense is to ship Stage B before market entry, so the absence of a logging mechanism is the pre-existing state of the system, not a post-hoc retreat.
  • A merchant, governmental partner, or pilot partner requiring sealed identity as a contracting condition.
  • A material privacy incident at a comparable platform implicating phoneβ†’userId resolution.

6. Open questions for review ​

Counsel (parallel to implementation, not a gate) ​

These are documentation/policy tasks. They produce artifacts that describe the shipped architecture; they do not grant or withhold permission to ship it.

  1. Privacy policy + ToS language. Audit the description of phone-number handling and identity resolution against what the code actually does post-Stage-A and post-Stage-B. Avoid claims that overstate sealing (e.g. "we never see your phone number" β€” false; we see it transiently to compute the hash) or understate it (e.g. silence on the userId-blob means we lose the marketing benefit).
  2. Subpoena-response playbook. A prepared template for the most common request shapes:
    • "What account has phone X" β†’ post-Stage-A: we can compute the hash and return whether a row exists, but the row contains no plaintext PII; post-Stage-B: we can return the blob but cannot decrypt it.
    • "Decrypt this user's profile" β†’ cannot, by design.
    • "Log future activity for phone X" β†’ covered separately; see question 5.
  3. User notification policy. When a subpoena identifier (phone) cannot be linked to a userId server-side, what does notification mean? Possible answer: notify all users via a transparency report, since we cannot identify the specific user.
  4. GDPR DPIA if/when EU market entry is on the roadmap. Sealed identity helps the DPIA, not hurts it β€” but the document needs to exist.
  5. Compelled-redesign jurisdictions. Document which markets we will and won't enter without a Stage B equivalent already shipped (UK, Australia are the obvious risks). This is a market-entry checklist, not an architecture question.
  6. Pseudonymization classification under GDPR Art. 4(5): does HMAC-SHA-256 with a KMS-held pepper qualify? (Likely yes; document the answer.)

Engineering ​

  1. Session token lifecycle and rotation under passphrase-derived keys (how do background refreshes work without re-prompting for PIN?)
  2. Recovery flow when user clears app data but retains passphrase / recovery phrase
  3. Migration path for existing users (re-encrypt at next login? batch backfill via a one-time client task?)
  4. Anti-fraud detection redesign for userId-only signals (ban-evasion via phone reuse β€” does the phone-hash ban list cover the gap?)
  5. Performance impact: extra round-trip for blob fetch + client-side decryption at every login (likely negligible but should be measured)
  6. KMS pepper rotation: how do we re-hash without a phone-number table to iterate over? (Likely answer: lazy re-hash on next successful login, with both old and new pepper accepted during a rotation window.)
  7. App Check / IP rate limiting: does sealing userId force changes to abuse heuristics that currently key on userId?

Operational ​

  1. Customer support workflow redesign β€” read-only support views? user-initiated session sharing? out-of-band confirmation?
  2. T&S investigation tools that don't rely on phone→userId linkage
  3. Internal-access audit policy for encryptedUserIdBlob (who can read what under what authorization)
  4. Incident response runbook update β€” what does "a user's phone was leaked" look like when we can't connect it to their userId?

7. Non-negotiable constraints ​

Any proposal must respect:

  • Immutable Right #6 (no data sales) β€” cannot be voted away, not even unanimously
  • Β§3.2 k-anonymity (β‰₯ 3 unique users per reported cell) on any merchant-surfaced metric
  • No per-user behavioral profiles for ad targeting (Right #6 + Β§3.2)
  • No cross-device tracking, no device fingerprinting
  • Cannot weaken existing privacy commitments to gain operational capability
  • Phase 1 capital posture (Cofounder Agreement Β§11): work must be deliverable on founder time + minimal infrastructure spend until Phase 2 trigger
  1. Stage A (hash the phone) ships first. Removes plaintext PII, unblocks banned_accounts, ~1 sprint. See the spike plan.
  2. Stage B (seal the userId) ships immediately after Stage A β€” preferably pre-Phase-5. The architectural commitment is strongest when made before the first subpoena and before public-facing growth.
  3. Counsel-track work runs in parallel (privacy policy + ToS audit, subpoena playbook, DPIA prep). It produces artifacts that describe what shipped; it does not block what ships.
  4. This brief is the canonical reference. Older drafts pointing to docs/economics/AD_PLACEMENT_ECONOMICS.md are dead links β€” that file does not exist.

9. Subpoena flow under the end-state architecture ​

This is what happens when law enforcement hands us a phone number and asks "who is this user?" once Stage A + Stage B are both shipped. The diagram is the answer to that question.

mermaid
flowchart TD
    Q1([Subpoena: identify the user with phone +1-555-XXXX])
    Q1 --> N[Server normalizes input to E.164]
    N --> P[Server fetches PHONE_HASH_PEPPER<br/>from KMS / Secret Manager]
    P --> H["Compute phoneHash =<br/>HMAC-SHA-256(pepperBytes, e164)"]
    H --> LK["Query auth_lookup / phoneHash"]
    LK --> EX{Row exists?}
    EX -->|No| RESP1([Truthful response:<br/>no such account])
    EX -->|Yes| ROW["Row contains:<br/>encryptedUserIdBlob<br/>phoneSalt, encryptedSeed"]
    ROW --> DEC{Can the server<br/>decrypt the blob?}
    DEC -->|"No β€” blob key = HKDF(entropy);<br/>entropy is only recoverable by<br/>decrypting encryptedSeed with the<br/>user's PIN, which the server never sees<br/>and cannot derive"| RESP2([Truthful response:<br/>opaque ciphertext returned;<br/>no path from phone to userId])
    UID[/userId β€” never recovered server-side/] -.never reached.- RESP2

    classDef sealed fill:#1f4f3a,color:#fff,stroke:#2d8659,stroke-width:2px
    classDef ghost fill:transparent,color:#888,stroke:#666,stroke-dasharray:5 5
    class RESP1,RESP2 sealed
    class UID ghost

Why each step is irreversible ​

StepWhat we haveWhat we don't have
Hash the phoneThe pepper (in KMS/Secret Manager) and the input phoneA reverse β€” HMAC isn't reversible; without the pepper, even a leaked DB dump can't be rainbow-tabled against a phone-number dictionary at scale
Look up the rowThe phoneHash to query againstThe userId of the row owner β€” Stage B replaces the userId field with a ciphertext blob
Decrypt the blobThe ciphertext, returned by FirestoreThe decryption key β€” it's HKDF(entropy), and entropy comes only from decrypting encryptedSeed with the user's PIN. The PIN never leaves the user's device, and we don't store it in any form (only authProofHash, an HMAC of the entropy under a fixed context string, which is one-way)

What we hand over vs. what we don't ​

Subpoena asksWhat we can produceWhat we cannot produce
"Does an account exist for phone X?"yes / noβ€”
"What's the userId for phone X?"the row's ciphertext blobthe userId itself
"What did userId Y do?" (if they hand us the userId)everything indexed by userIdprofile fields (still PBKDF2 + AES-GCM client-encrypted)
"Decrypt this user's profile"nothingprofile bytes (we never had the key)
"Decrypt this user's seed"nothingseed bytes (PIN-wrapped, we never had the PIN)

What each stage contributes ​

StageStatusWhat it addsWhat still leaks without it
Stage A β€” hash the phone with KMS pepperPhase 1+2 shipped on dev (PR #479); phase 3-5 pendingRemoves plaintext phone numbers from the database. A leaked Firestore export becomes useless without the pepper. Enables banned_accounts to share the same hash form.Plaintext phone field still present during phases 1-2 (dual-write); the phoneHash β†’ userId resolution is still server-side once a phone is provided to the server.
Stage B β€” encrypt the userId resolutionNot started; gated on engineering capacity, not legal reviewReplaces the userId in the auth lookup row with a passphrase-keyed ciphertext blob. The server can find the row via phoneHash, but the row no longer reveals userId server-side. Only the user, by entering their PIN, can decrypt it.Without Stage B, a subpoena providing a phone produces the corresponding userId and any data indexed by it.

Important caveat about the current state ​

We have shipped Stage A phases 1-2 only (dual-write of phoneHash alongside plaintext phone). Today, a subpoena providing a phone number can still be answered with a userId: the server can either query by plaintext phone (fallback path) or compute the hash and query by phoneHash. Either path returns the row, and the row contains the userId. The diagram above describes the end-state after Stage A phase 4 (drop plaintext) and Stage B (seal the userId blob) both ship.

The interim state still meaningfully reduces certain attack surfaces — a leaked DB dump (without the pepper) is much harder to rainbow-table than the previous plaintext-phone state — but it does not yet make the phone→userId link unrecoverable. The diagram is what we are building toward, not what is live today.

10. References ​

Built with VitePress