Skip to content

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

FieldTodayAfter Stage AMigration
phoneE.164 plaintextremoved (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, โ€ฆunchangedunchangedโ€”

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 at phone.js:48โ€“55 (withRole preference).
  • Add single-field index: banned_accounts.phoneHash.
  • Drop users.phone index 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 call macSign for hash computation) and cloudkms.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 โ€‹

FileChange
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.jsNo 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.jsAdd POST /moderation/ban-phone that writes to banned_accounts
firestore.indexes.jsonAdd composite + single-field indexes
firestore.rulesServer-only read on users.phoneHash + entire banned_accounts collection
services/api/auth/openapi.jsonDocument 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 for users lookup (would scan millions of rows on every login).
  • The same hash form must work for both users.phoneHash (hot path, equality lookup) and banned_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:

  1. Land #307: stop logging PII, set 30-day retention.
  2. 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.

  1. Dual-write (no behavior change). Signup writes both phone and phoneHash. Read path still uses phone. Validates the hash function in prod traffic.
  2. Backfill existing rows via tooling/scripts/backfill-phone-hash.mjs. Cursor-paginated reads (orderBy('__name__') + startAfter()), batched writes, idempotent (skips rows that already have phoneHash). --dry-run and --limit=N flags allow staged 1% โ†’ 10% โ†’ full rollout. Estimated cost at current scale: negligible.
  3. Switch reads to phoneHash behind feature flag auth.phoneHash.enabled. Canary 1% โ†’ 10% โ†’ 100%. Verify the duplicate-detection / role-preference logic (phone.js:48โ€“55) still works.
  4. Stop writing plaintext phone on new signups. Existing rows still have it.
  5. Drop phone field from all rows via second migration job. Drop the phone index.

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/):
    • phoneHash determinism, 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
  • Migration:
    • Dry-run on a snapshot copy of prod users. Verify row counts, zero collision warnings, zero phone-normalization mismatches.
  • Security audit:
    • users.where('phone', ...) queries return zero results post-cutover (grep services/ + apps/)
    • Firestore rules tests: no client can read users.phoneHash or banned_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.

Built with VitePress