Skip to content

Sealed-Identity Stage A Phase 4 โ€” Implementation Plan โ€‹

Date: 2026-05-11 Status: Draft, awaiting review. Companion spec: docs/superpowers/specs/2026-05-10-sealed-identity-stage-a-design.md โ€” Phase 4 is covered in ยง5 (code touch list) and ยง7 (migration). Companion brief: docs/privacy/SEALED_IDENTITY.mdDepends on: PR #485 (Phase 3 + banned_accounts end-to-end). Land that first.

1. Goal โ€‹

Stop writing plaintext users.phone on new signups. After Phase 4, fresh user documents contain phoneHash only; existing rows still have phone and will be cleaned up in Phase 5.

This also makes the ban check server-enforced at signup time โ€” a malicious client can no longer skip the pre-SMS check and silently create an account with a banned phone, because the user-doc write itself moves server-side.

2. Why server-side, not client-only โ€‹

A client-only fix (have the web app fetch a hash from the server and write phoneHash directly) gets the plaintext-phone-removal goal but does not solve the ban-bypass problem: the client could still write a user doc with phoneHash and skip the ban check entirely. The server endpoint that owns the write is the right enforcement point.

The trade-off is bigger scope. The client โ†’ server endpoint refactor touches the signup flow, custom-token handling, and the moderation guarantees. We accept that.

3. Approach โ€” server-side createUser endpoint โ€‹

New endpoint: POST /auth/phone/createUser

3.1 Preconditions (client must do these first, unchanged) โ€‹

  1. Submit phone + DOB to the existing pre-SMS gate flow (check-banned โ†’ admin-check โ†’ signInWithPhoneNumber).
  2. Receive SMS code, enter it, complete confirmationResult.confirm() โ€” this creates the Firebase Auth user with a phone-linked credential. The web client now holds an authenticated Firebase Auth session whose UID corresponds to the phone.
  3. Generate client-side encryption material: 16-byte entropy, phoneSalt, derive wrapping key from phone+PIN, encrypt the entropy โ†’ encryptedSeed, compute authProofHash = HMAC-SHA256(entropy, "lantern-auth-proof-v1"), encrypt the birth date, build the encryption canary.

3.2 What the client posts โ€‹

http
POST /auth/phone/createUser
Authorization: Bearer <firebase-id-token>
X-Firebase-AppCheck: <app-check-token>
Content-Type: application/json

{
  "phone": "+14155550100",
  "phoneSalt": "<base64>",
  "encryptedSeed": "<base64>",
  "authProofHash": "<64-char-hex>",
  "encryptedBirthDate": "<base64>",
  "encryptionCanary": "<base64>",
  "recoveryPhraseHash": "<base64>",
  "lanternName": "<generated client-side>",
  "salt": "<base64>"
}

Notes on each field:

  • phone is sent in plaintext for this request only. The server hashes it and never writes the plaintext to Firestore.
  • All encrypted* and hashed fields are derived from material the server cannot decrypt โ€” the zero-knowledge guarantee for profile data is preserved.
  • lanternName is generated client-side (existing behavior, deterministic from UID + entropy).
  • App Check + Firebase ID token gate the endpoint.

3.3 What the server does โ€‹

1. Verify Firebase ID token (existing middleware: verifyFirebaseToken).
2. Verify the token's `phone_number` claim matches the submitted `phone`
   (normalized E.164). If not โ€” 403 PHONE_MISMATCH. This is the core
   "you can't create a user for a phone you didn't verify via SMS" check.
3. Re-run ban check server-side:
     phoneHash = computePhoneHash(phone, PHONE_HASH_PEPPER)
     if isPhoneBanned(phoneHash) โ†’ 403 BANNED, do NOT create the doc.
4. Validate body shape (zod schema; all encrypted fields are base64 of
   reasonable length; authProofHash is 64-char hex; lanternName matches
   the known pattern).
5. Check `users/{uid}` doesn't already exist with phone+PIN material
   (idempotency: re-running this endpoint after a partial failure
   should be safe). If exists with phoneSalt โ†’ 409 ALREADY_PROVISIONED.
6. Write `users/{uid}` with:
     {
       phoneHash,                  // โ† new: no plaintext `phone` field
       phoneVerified: true,
       phoneSalt, encryptedSeed,
       recoveryPhraseHash,
       authProofHash,
       lanternName,
       encryptedBirthDate, encryptionCanary,
       interests: [], vibe: null,
       locationTracking: false,
       authMethod: 'phone_pin',
       createdAt: FieldValue.serverTimestamp(),
       updatedAt: FieldValue.serverTimestamp(),
     }
   Use admin SDK transaction so we don't race with the dual-write
   trigger (`phoneHashSync.js` โ€” which is a no-op when phoneHash is
   already present anyway, but cleaner with a transaction).
7. Update Firebase Auth display name to lanternName (admin SDK).
8. Return: { ok: true }.

3.4 Endpoint placement โ€‹

Lives in services/api/auth/src/routes/phone.js (next to /lookup, /token, /check-banned), with auth via the existing verifyFirebaseToken middleware (mounted at /auth/phone/createUser โ€” but unlike /lookup and /token, this one DOES require auth).

The route order in index.js needs care: /auth/phone/createUser must register BEFORE the public unauthPhone catch-all so the authenticated middleware applies.

4. Client changes (apps/web) โ€‹

4.1 PhonePinSignup.jsx โ€” replace direct setDoc with endpoint call โ€‹

Current code at lines ~466โ€“503:

js
const profileData = {
  phone: normalized,             // โ† REMOVE
  phoneVerified: true,
  phoneSalt: saltBase64,
  ...
}
await setDoc(userDocRef, { ...profileData, createdAt: serverTimestamp() })

After:

js
await fetch(`${authApiUrl}/auth/phone/createUser`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${idToken}`,
    'X-Firebase-AppCheck': appCheckToken,
  },
  body: JSON.stringify({
    phone: normalized,
    phoneSalt: saltBase64,
    encryptedSeed: encryptedSeedBase64,
    authProofHash,
    encryptedBirthDate,
    encryptionCanary,
    recoveryPhraseHash,
    lanternName,
    salt,
  }),
})

The updateDoc branch (existing admin-merged docs) stays client-side for now โ€” it's a different code path that touches admin-owned records. Moving that server-side is out of scope for Phase 4 (covered by the admin-auth-decoupling project, separate workstream).

4.2 Error handling โ€‹

Server responseClient behavior
200 okContinue to next signup step (existing)
403 PHONE_MISMATCHShow generic "Verification failed โ€” please retry" (don't reveal the token-vs-phone mismatch detail)
403 BANNEDShow the same appeals message as the pre-SMS gate
409 ALREADY_PROVISIONEDTreat as success โ€” the doc exists with this material; continue
400 INVALID_BODYGeneric "Setup failed โ€” please try again." Log details for ops
Network / 5xxRetry once with exponential backoff. If still fails, error out โ€” do NOT fall back to client-side setDoc (that would defeat the point of moving server-side)

4.3 Race with admin detection โ€‹

If the user is detected as an admin in the pre-SMS gate (existing flow), they go through the linking path which DOES NOT use the new endpoint. That path is unchanged. Add a comment in PhonePinSignup.jsx clarifying that only the standard new-user path calls createUser.

5. Backward compat & migration โ€‹

Phase 4 only changes the write path. Existing rows are untouched:

  • Old users: still have phone AND phoneHash (after Phase 1โ€“2 backfill). Read path uses phoneHash when the Stage A flag is on, or phone otherwise. Both continue to work.
  • New users (post-Phase-4): have phoneHash only. Read path with the Stage A flag ON works fine. With the flag OFF (legacy plaintext lookup), new users can't be found. Therefore the Stage A flag MUST be flipped ON in every environment before Phase 4 deploys.

This is the key sequencing constraint: Phase 3 read switch (flag=on) must be live in production before Phase 4 ships. Otherwise we strand new signups behind a lookup that can't find them.

Document this in the deploy runbook.

6. Tests โ€‹

6.1 Server (services/api/auth) โ€‹

__tests__/createUser.route.test.js (vitest):

  • Body validation: missing fields โ†’ 400, malformed base64 โ†’ 400, wrong-length authProofHash โ†’ 400.
  • ID token mismatch: stub verifyFirebaseToken to return a UID whose phone_number claim differs from body.phone โ†’ 403 PHONE_MISMATCH.
  • Banned phone: stub isPhoneBanned to return true โ†’ 403 BANNED, no Firestore write happens.
  • Pepper missing: returns 503 PEPPER_NOT_CONFIGURED.
  • Happy path: returns 200, Firestore add called with phoneHash (not phone), serverTimestamp for createdAt.
  • Idempotency: if users/{uid} already exists with phoneSalt, returns 409.

6.2 Client (apps/web) โ€‹

PhonePinSignup itself remains hard to unit-test (large component). Extract the network call into a thin helper apps/web/src/lib/signupApi.js with one function:

js
export async function createPhoneUser(authApiUrl, headers, body) { ... }

Test the helper in apps/web/src/lib/__tests__/signupApi.test.js:

  • Happy path: returns { ok: true }.
  • 409 ALREADY_PROVISIONED: returns { ok: true, alreadyExisted: true }.
  • 403 BANNED: throws an error with code: 'BANNED'.
  • 403 PHONE_MISMATCH: throws an error with code: 'PHONE_MISMATCH'.
  • Network error: throws a wrapped error after one retry.

This also addresses the coverage shortfall flagged on PR #485 (we add 5 tested branches in a measured file).

6.3 OpenAPI โ€‹

Add the new endpoint to services/api/auth/openapi.json. Document the auth requirement, body schema, all response codes. Run npm run lint:openapi-sync after.

7. Rollout โ€‹

  1. Deploy code with flag off. New endpoint exists but the client doesn't call it yet. Verify endpoint health via direct curl with a real Firebase ID token.
  2. Flip an env-var feature flag in the web app (e.g. VITE_USE_SERVER_CREATEUSER=true) to route the new signup write through the endpoint. Canary 10% โ†’ 50% โ†’ 100% via the existing rollout pattern.
  3. After 100% rollout for 7 days with no error spike, remove the flag and the legacy client-side setDoc branch in a follow-up PR.
  4. Phase 5 then handles backfilling existing rows by removing phone from already-provisioned docs.

Each step is independently reversible up to step 3.

8. Estimated effort โ€‹

TaskEstimate
Server endpoint + tests0.5 day
OpenAPI + sync verification0.25 day
Client signupApi.js helper + tests0.25 day
PhonePinSignup integration + manual test on dev0.5 day
Rollout flag wiring + docs0.25 day
Total~1.75 days

9. Open questions โ€‹

  1. Cloud Run cold start on the new endpoint โ€” same auth service as the rest, so warm pool already in place. Should be a non-issue, but worth a smoke test.
  2. Reclaim flow interaction โ€” phoneRecycling (services/api/auth/src/routes/phoneRecycling.js) currently assumes plaintext phone on the recycled user's doc. After Phase 4, new docs won't have it. Phase 5 will remove old docs' plaintext too. The reclaim service needs to be audited to ensure it works on phoneHash-only docs โ€” likely already does given Phase 3, but verify.
  3. Admin-flow / phone-migration flow โ€” explicitly out of scope here; uses different write paths. Document the exclusion in PhonePinSignup.jsx comments.

10. PR strategy โ€‹

Two acceptable paths:

A โ€” Stacked PR off claude/sealed-identity-followups. Base of the Phase 4 PR is this branch. When #485 merges to dev, rebase the Phase 4 branch onto dev. Clean separation in review.

B โ€” Append to PR #485. Bigger PR, but stays consistent with the user's stated preference to keep related work together. Risks ballooning #485's review surface.

Per the spec's own phasing rule ("each step is a separate PR"), A is recommended. Confirm before starting implementation.

Built with VitePress