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) โ
- Submit phone + DOB to the existing pre-SMS gate flow (
check-bannedโ admin-check โsignInWithPhoneNumber). - 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. - Generate client-side encryption material: 16-byte entropy,
phoneSalt, derive wrapping key from phone+PIN, encrypt the entropy โencryptedSeed, computeauthProofHash = HMAC-SHA256(entropy, "lantern-auth-proof-v1"), encrypt the birth date, build the encryption canary.
3.2 What the client posts โ
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:
phoneis 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. lanternNameis 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:
const profileData = {
phone: normalized, // โ REMOVE
phoneVerified: true,
phoneSalt: saltBase64,
...
}
await setDoc(userDocRef, { ...profileData, createdAt: serverTimestamp() })After:
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 response | Client behavior |
|---|---|
| 200 ok | Continue to next signup step (existing) |
| 403 PHONE_MISMATCH | Show generic "Verification failed โ please retry" (don't reveal the token-vs-phone mismatch detail) |
| 403 BANNED | Show the same appeals message as the pre-SMS gate |
| 409 ALREADY_PROVISIONED | Treat as success โ the doc exists with this material; continue |
| 400 INVALID_BODY | Generic "Setup failed โ please try again." Log details for ops |
| Network / 5xx | Retry 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
phoneANDphoneHash(after Phase 1โ2 backfill). Read path usesphoneHashwhen the Stage A flag is on, orphoneotherwise. Both continue to work. - New users (post-Phase-4): have
phoneHashonly. 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
verifyFirebaseTokento return a UID whosephone_numberclaim differs from body.phone โ 403 PHONE_MISMATCH. - Banned phone: stub
isPhoneBannedto 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 withphoneSalt, 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:
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 โ
- 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.
- 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. - After 100% rollout for 7 days with no error spike, remove the flag and the legacy client-side
setDocbranch in a follow-up PR. - Phase 5 then handles backfilling existing rows by removing
phonefrom already-provisioned docs.
Each step is independently reversible up to step 3.
8. Estimated effort โ
| Task | Estimate |
|---|---|
| Server endpoint + tests | 0.5 day |
| OpenAPI + sync verification | 0.25 day |
Client signupApi.js helper + tests | 0.25 day |
| PhonePinSignup integration + manual test on dev | 0.5 day |
| Rollout flag wiring + docs | 0.25 day |
| Total | ~1.75 days |
9. Open questions โ
- 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.
- Reclaim flow interaction โ
phoneRecycling(services/api/auth/src/routes/phoneRecycling.js) currently assumes plaintextphoneon 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. - Admin-flow / phone-migration flow โ explicitly out of scope here; uses different write paths. Document the exclusion in
PhonePinSignup.jsxcomments.
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.