Sealed Identity Stage A โ Design Spec โ
Date: 2026-05-10 Companion brief: docs/privacy/SEALED_IDENTITY.mdCompanion plan: docs/superpowers/plans/2026-05-10-sealed-identity-spike.mdCloses (partial): #145 (Layer 1 only โ see ยง6 for descope rationale) Coordinates with: #307 (prereq), #308, #146, #352 Target branch: dev
1. Problem โ
users/{userId}.phone is stored as E.164 plaintext and looked up with users.where('phone', '==', normalized) (services/api/auth/src/routes/phone.js:42). This is the dominant compelled-disclosure surface: a phone-number-as-input subpoena returns a userId and the user's entire document. We want it gone before Phase 5 (soft launch).
2. Goal โ
Replace users.phone plaintext with users.phoneHash = HMAC-SHA256(KMS_pepper, e164(phone)). Keep login UX identical. Land a minimal banned_accounts collection using the same hash form so re-registration prevention has a server-side primitive (Layer 1 of #145).
Out of scope: encrypting the userId resolution itself (Stage B), device fingerprinting, behavioral fingerprinting, social graph analysis (see ยง6).
3. Schema diff โ
users/{userId} collection โ
| Field | Today | After Stage A | Migration |
|---|---|---|---|
phone | E.164 plaintext | removed (final step) | dual-write, then drop |
phoneHash | โ | hex HMAC-SHA-256(pepper, e164) | computed at signup, backfilled for v1 users |
phoneSalt, encryptedSeed, authProofHash, pinFailedAttempts, pinLockoutUntil, encryptedBirthDate, โฆ | unchanged | unchanged | โ |
banned_accounts/{banId} (new collection) โ
{
banId: string // doc id (uuid)
reason: string // policy violation code
severity: 'warning'|'temporary'|'shadow'|'permanent'
bannedAt: Timestamp
expiresAt: Timestamp | null
phoneHash: string | null // HMAC-SHA256 (same pepper as users)
emailHash: string | null // HMAC-SHA256 (same pepper)
appealStatus: 'none'|'pending'|'upheld'|'overturned'
evidence: string | null // reference to encrypted moderator note
}Cryptographic primitive: HMAC-SHA-256 with the same KMS pepper as users.phoneHash. Not bcrypt โ see ยง6 for divergence from #145/#146 and rationale.
Firestore indexes โ
- Add composite index:
users.phoneHash(asc) +role(asc) โ preserves the duplicate-detection logic atphone.js:48โ55(withRolepreference). - Add single-field index:
banned_accounts.phoneHash. - Drop
users.phoneindex after Stage A.5.
Firestore security rules โ
users.phoneHash: server-only read (no client read access). Lookup happens via Cloud Run, which holds the pepper.banned_accounts/*: server-only read and write. No client access ever.
4. KMS pepper โ
- Storage: Cloud KMS symmetric MAC key. Resource:
projects/<proj>/locations/global/keyRings/auth/cryptoKeys/phone-pepper. - Auth service account permissions:
cloudkms.cryptoKeyVersions.useToSignMac(required to callmacSignfor hash computation) andcloudkms.cryptoKeyVersions.useToVerifyMac(for any hash-comparison verification path). No raw key export โ the key never leaves KMS, the SA only invokes the MAC operations remotely. - Web client: never sees the pepper. Web client posts plaintext phone to
/auth/phone/lookup; server computes the HMAC via KMS and queries Firestore. - Rotation: dual-pepper window via two KMS key versions. Login lookup tries primary first, falls back to secondary; on secondary hit, lazy re-writes the row with the primary-version hash. Drop secondary after 90 days. Documents the 90-day rotation cadence called for in #146.
- Hash form: hex string of
HMAC-SHA-256(pepper, e164_normalized). Versioned prefix:v1:<hex>so rotation across hash families (e.g. future migration to HMAC-SHA-3) doesn't require a full re-scan.
5. Code touch list โ
| File | Change |
|---|---|
services/api/auth/src/routes/phone.js:31โ69 | /lookup handler: compute phoneHash via KMS, query by phoneHash; remove plaintext phone from request handling path |
services/api/auth/src/services/customToken.service.js | No change |
packages/shared/encryption/phoneHash.js (new) | computePhoneHash(e164, kmsClient) server-side helper using @google-cloud/kms MAC sign |
services/api/auth/src/services/banCheck.service.js (new) | isPhoneBanned(phoneHash) โ bool, banPhone(phoneHash, reason, severity) |
apps/web/src/lib/auth.js (signup paths) | Stop writing users.phone; rely on server to derive phoneHash from the phone the client already submits |
services/api/auth/src/routes/moderation.js | Add POST /moderation/ban-phone that writes to banned_accounts |
firestore.indexes.json | Add composite + single-field indexes |
firestore.rules | Server-only read on users.phoneHash + entire banned_accounts collection |
services/api/auth/openapi.json | Document the hash-only contract; remove any reference to plaintext phone in response shape |
tooling/scripts/backfill-phone-hash.mjs (new) | One-shot Node CLI: iterate users via cursor pagination, compute phoneHash, batched writes. --dry-run and --limit=N for staged execution |
Behind feature flag auth.phoneHash.enabled (env var + remote config).
6. Scope conflicts and resolutions โ
6.1 bcrypt vs HMAC-SHA-256 (diverges from #145, #146, SAFETY_MECHANICS.md) โ
docs/features/safety/SAFETY_MECHANICS.md:240โ241, issue #145, and issue #146 specify bcrypt (cost 10) for phoneHash in banned_accounts. This spec uses HMAC-SHA-256 + KMS pepper instead.
Rationale:
- bcrypt is per-row salted. Firestore
where('phoneHash', '==', X)returns nothing because each banned row has a different salt. You would have to fetch all banned rows and bcrypt-verify the input phone against each. That's tolerable for a small ban list, but fatal foruserslookup (would scan millions of rows on every login). - The same hash form must work for both
users.phoneHash(hot path, equality lookup) andbanned_accounts.phoneHash(signup check, equality lookup). Consistency is worth more than per-row uniqueness here. - bcrypt's offline-attack resistance is not the relevant threat. The pepper is in KMS, never exfiltrable. Without the pepper, an attacker who steals a database dump cannot precompute hashes against a phone-number dictionary. HMAC-SHA-256 with a KMS-held pepper provides the same dictionary-attack resistance as bcrypt-without-pepper, but supports equality queries.
- Both #145 and #146 should be updated to reference this spec. Filing follow-up edits.
6.2 Device + behavioral fingerprinting (Layers 2โ5 of #145) โ descope โ
Issue #145 specifies five layers of re-registration prevention. Layers 2โ5 (device fingerprinting, behavioral fingerprinting, social graph analysis, payment-method banning) directly contradict the non-negotiables in the sealed-identity brief and Immutable Right #6:
- "No cross-device tracking, no device fingerprinting" โ Layer 2 violates this
- "No per-user behavioral profiles for ad targeting" โ Layer 3 produces exactly such profiles, and even though the stated purpose is ban evasion, the data structure is reusable for targeting
- Layer 4 (social graph correlation) โ fine in principle but trivially de-anonymizing in a small user base; conflicts with ยง3.2 anonymity commitment
Stage A implements Layer 1 only (hashed phone/email in ban list). Layers 2โ5 are descoped pending an explicit governance decision per Cofounder Agreement ยง9.4 (Mission Arbiter review). A follow-up issue will close out the contradiction in #145 by editing the issue body, not by silently shipping the conflicting work.
6.3 #307 (PII log hygiene) is a prerequisite โ
#307 catalogs PII-in-logs sites we haven't fixed. Hashing phones in Firestore while still writing phones to Cloud Logging is theatre. Sequence:
- Land #307: stop logging PII, set 30-day retention.
- Then this spec.
Without #307 first, Stage A's privacy improvement is mostly cosmetic. Estimated 2โ3 days for #307; not blocking on it would force re-doing log audits after Stage A ships.
6.4 #308 (GDPR deletion / BQ pseudonymization) โ co-design, not block โ
#308's SHA256(user_id + deletion_salt) pattern with a discarded salt is different from this spec's HMAC-with-KMS-pepper. They serve different goals (irreversibility on deletion vs. queryable-but-not-reversible identification). Coexistence is fine; just need to make sure the privacy-policy language describes both clearly. Will surface to #308 owner.
6.5 #352 (tiered recovery) โ coordinate on lookup response shape โ
#352 is changing what the auth response surface looks like at signup/recovery time. If both ship in the same window, coordinate the /auth/phone/lookup response shape so we don't iterate it twice.
7. Migration โ
Phased rollout, each step independently reversible until step 5.
- Dual-write (no behavior change). Signup writes both
phoneandphoneHash. Read path still usesphone. Validates the hash function in prod traffic. - Backfill existing rows via
tooling/scripts/backfill-phone-hash.mjs. Cursor-paginated reads (orderBy('__name__') + startAfter()), batched writes, idempotent (skips rows that already havephoneHash).--dry-runand--limit=Nflags allow staged 1% โ 10% โ full rollout. Estimated cost at current scale: negligible. - Switch reads to
phoneHashbehind feature flagauth.phoneHash.enabled. Canary 1% โ 10% โ 100%. Verify the duplicate-detection / role-preference logic (phone.js:48โ55) still works. - Stop writing plaintext
phoneon new signups. Existing rows still have it. - Drop
phonefield from all rows via second migration job. Drop thephoneindex.
Each step is a separate PR to dev (per AGENTS.md rule 6, one theme per PR; phased rollout is one theme split by reversibility milestone).
8. Testing โ
- Unit (
services/api/auth/test/):phoneHashdeterminism, hex-encoding, pepper-version prefix- Rotation fallback path: hash with secondary pepper, verify lookup still succeeds
- E.164 normalization edge cases (US/intl, formatting variants, invalid)
- Integration (Firebase emulator):
- Full signup โ login flow with
phoneHash-only data - Duplicate-phone case (
limit(5)+ role preference) - Ban check fires correctly on signup with banned phone
- Full signup โ login flow with
- Migration:
- Dry-run on a snapshot copy of prod
users. Verify row counts, zero collision warnings, zero phone-normalization mismatches.
- Dry-run on a snapshot copy of prod
- Security audit:
users.where('phone', ...)queries return zero results post-cutover (grep services/ + apps/)- Firestore rules tests: no client can read
users.phoneHashorbanned_accounts/* - No phone numbers in
services/log statements post-#307
9. OpenAPI โ
/auth/phone/lookup request shape unchanged ({ phone }). Response shape unchanged structurally ({ exists, userId, phoneSalt, encryptedSeed, lanternName, authMethod }). What changes is internal: the lookup uses phoneHash instead of phone. No client-facing API break.
Run npm run validate (which includes openapi-sync per AGENTS.md) before PR.
10. Estimate โ
- #307 prerequisite: 2โ3 days (separate PR)
- Stage A code: ~1 sprint (8 working days) including migration job
- Rollout window: 2 weeks (phased canary)
Total wall-clock: ~3.5 weeks from kickoff to plaintext-phone removal.
11. Decision log โ
- 2026-05-10: Use HMAC-SHA-256 + KMS pepper (not bcrypt) for
phoneHash. Diverges from #145/#146; rationale in ยง6.1. Follow-up edits to both issues required. - 2026-05-10: Descope #145 Layers 2โ5 (device + behavioral + social-graph fingerprinting). Conflicts with Immutable Right #6 + brief non-negotiables. Layer 1 only in this spec.
- 2026-05-10: Sequence #307 before this spec. PII-in-logs makes hash-at-rest cosmetic.