Authentication & Encryption โ Complete Flow Guide โ
Updated: March 17, 2026 Status: โ
Implemented (PR #289, branch copilot/integrate-seamless-migration) Related: ZERO_KNOWLEDGE_ENCRYPTION.md
Table of Contents โ
- Overview
- Architecture
- Scenario A: Phone+PIN Signup (Primary)
- Scenario B: Passphrase Signup (Legacy)
- Scenario C: Phone+PIN Login
- Scenario D: Passphrase Login (Legacy)
- Scenario E: Key Cache Auto-Restore
- Scenario F: Biometric Unlock
- Scenario G: Migration from Passphrase to Phone+PIN
- Scenario H: Account Recovery via Recovery Phrase
- Scenario I: Phone Number Reclaim (Carrier Recycling)
- Scenario J: Account Deletion
- Data Model
- Security Model
- Edge Cases & Error Handling
- File Reference
Overview โ
Lantern supports two authentication/encryption schemes:
| Phone + PIN (Default) | Passphrase (Legacy) | |
|---|---|---|
| Auth method | Firebase Phone Auth (SMS) | Firebase Email/Password Auth |
| Key derivation | Two-layer (entropy + wrapping key) | Single-layer (passphrase โ key) |
| Recovery | 12-word BIP39 phrase | None (lost = gone) |
| Biometric unlock | Supported (WebAuthn) | Not available |
| Key caching | IndexedDB entropy cache | Not available |
| Route | #/signup, #/login | #/signup/passphrase, #/login/passphrase |
Privacy guarantee is identical for both: Lantern cannot decrypt user data. The encryption key is derived client-side from secrets only the user knows (PIN or passphrase) and never leaves the device.
Architecture โ
Phone+PIN: Two-Layer Key Derivation โ
The phone+PIN scheme uses a two-layer design that separates the encryption key from the user's PIN, enabling recovery without the PIN.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 12-word BIP39 Mnemonic โ
โ (shown once, user writes down) โ
โโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
mnemonicToEntropy()
โ
โผ
โโโโโโโโโโโโโโโโโโโโโ
โ 16-byte entropy โ โ NEVER stored in
โ โ plaintext anywhere
โโโโโโฌโโโโโโโโโโโฌโโโโโ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ
โ โ
PBKDF2(entropy, phoneSalt, 600K) Encrypted with wrapping key
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ
โ Encryption Key โ โ encryptedSeed โ โ Stored in
โ (AES-256-GCM) โ โ (in Firestore) โ Firestore
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ
โฒ
PBKDF2(phone+":"+pin,
phoneSalt, 600K)
โ
โโโโโโโโโโโโโโโโโโโโโ
โ Wrapping Key โ โ NEVER stored
โโโโโโโโโโโโโโโโโโโโโLayer 1 โ Encryption Key: PBKDF2(entropy, phoneSalt, 600K iterations, SHA-256) โ AES-256-GCM key. This key encrypts/decrypts all user data.
Layer 2 โ Wrapping Key: PBKDF2(phone + ":" + pin, phoneSalt, 600K iterations) โ AES-256-GCM wrapping key. This key encrypts the entropy โ encryptedSeed, which is stored in Firestore.
Why two layers? The recovery phrase encodes the same entropy. If the user forgets their PIN, the phrase can regenerate the encryption key directly (Layer 1), bypassing the wrapping key (Layer 2) entirely.
Legacy Passphrase: Single-Layer Key Derivation โ
passphrase + salt โ PBKDF2(600K iterations, SHA-256) โ AES-256-GCM encryption keyNo recovery path โ if the passphrase is lost, data is permanently unrecoverable.
Scenario A: Phone+PIN Signup (Primary Path) โ
Screen: PhonePinSignup โ 6 steps Route: #/signupFiles: PhonePinSignup.jsx, encryption.js, keyCache.js, biometrics.js
Step 1 โ Phone Number + Age Verification โ
- User enters phone number and birth date.
normalizePhoneNumber(phone)validates and converts to E.164 format (e.g.,+15551234567).- Age check: must be 18+ based on birth date.
- Invisible reCAPTCHA (
RecaptchaVerifier) initialized on the container div. signInWithPhoneNumber(auth, normalized, recaptchaVerifier)triggers SMS.confirmationResultstored in component state.
Errors: auth/too-many-requests (SMS rate limit), auth/invalid-phone-number (bad format).
Step 2 โ SMS Verification Code โ
- User enters the 6-digit SMS code.
confirmationResult.confirm(code)โ returnscredential.user(Firebase Auth user now exists).- The
phoneUserobject is stored in state for later use. - Resend option available โ clears and recreates the reCAPTCHA verifier.
Errors: Invalid/expired code, SMS not received.
Step 3 โ PIN Creation โ
- User enters a 6-digit PIN and confirms it.
- Real-time validation checks:
- Exactly 6 digits (
/^\d{6}$/) - Not in
WEAK_PINSset (40+ blocked patterns like123456,000000,112233) - PINs match
- Exactly 6 digits (
validatePIN(pin)performs the final check before proceeding.
Step 4 โ Email Capture (Optional) โ
EmailCaptureStepcomponent shows email input with confirmation field.- User can enter an email (for recovery notifications) or skip.
- Triggers
createAccount(email)orcreateAccount(null).
Step 5 โ Account Creation (Background) + Recovery Phrase Display โ
Account creation happens automatically when moving to step 5:
initializeEncryptionWithPIN(normalized, pin, user.uid):- Generates 128-bit entropy โ 12-word BIP39 recovery phrase.
- Extracts 16-byte entropy bytes from the mnemonic.
- Generates 32-byte random
phoneSalt. - Layer 1:
PBKDF2(entropy, phoneSalt, 600K)โ AES-256-GCM encryption key. - Layer 2:
PBKDF2(phone + ":" + pin, phoneSalt, 600K)โ wrapping key โ encrypts entropy โencryptedSeedBase64. SHA-256(normalizedPhrase)โrecoveryPhraseHash.- Caches encryption key + entropy in module-level variables.
- Returns
{ phoneSaltBase64, encryptedSeedBase64, recoveryPhrase, recoveryPhraseHash }.
Encrypts user data:
encryptData(birthDate)โencryptedBirthDate.createEncryptionCanary()โ encrypts"LANTERN_CANARY_V1"for corruption detection.
Generates identity:
generateLanternName(user.uid)โ anonymous display name.Writes Firestore document
users/{uid}:phone, phoneVerified: true, phoneSalt, encryptedSeed, recoveryPhraseHash, lanternName, encryptedBirthDate, encryptionCanary, interests: [], mood: null, locationTracking: false, authMethod: "phone_pin", createdAt, updatedAt, [email if provided]Updates Firebase Auth
displayNameto the lantern name.Server-side email encryption (if email provided):
- Calls
encryptUserEmailCloud Function โ storesencryptedEmailfield. - Calls
sendRecoveryBackupEmailCloud Function โ sends recovery notification.
- Calls
Caches entropy in IndexedDB via
cacheEntropy(userId, entropy)(convenience mode โ no biometric protection yet).Recovery phrase is displayed via
RecoveryPhraseDisplay:- User must reveal the 12 words and confirm they've saved them.
- Optional:
RecoveryBackupModalโ user sets a password, encrypts the phrase withPBKDF2(password) โ AES-GCM, and emails the encrypted blob viasendEncryptedBackupEmailCloud Function.
Step 6 โ Terms Confirmation + Biometric Prompt โ
- User checks "I've saved my recovery phrase" and agrees to terms.
BiometricPromptmodal appears (if entropy is available):isBiometricAvailable()โ checks for WebAuthn platform authenticator.hasBiometricRegistered(userId)โ skips if already registered.- "Enable":
registerBiometric(userId, displayName)โ WebAuthncredentials.create()โ HKDF(credentialId) โ 256-bit device key โcacheEntropy(userId, entropy, deviceKeyRaw)re-caches with biometric protection (device key NOT stored in IndexedDB). - "Not now": proceeds without biometric.
onComplete(accountData)โ App navigates to#/onboarding/profile.
Scenario B: Passphrase Signup (Legacy) โ
Screen: SignupFlow โ 3 steps Route: #/signup/passphraseFiles: SignUp.jsx, auth.js, encryption.js
- Email + Age: Email validation + birth date + 18+ check.
- Passphrase Creation: 12+ chars, uppercase, lowercase, number, special character. Confirm match.
- Terms Confirmation: Agree โ
signUp(email, passphrase, birthDate):createUserWithEmailAndPassword(auth, email, passphrase)โ Firebase Auth.initializeEncryption(passphrase, userId):- Generates 32-byte random salt.
PBKDF2(passphrase, salt, 600K)โ AES-256-GCM key.
- Encrypts birth date + creates canary.
- Writes
users/{uid}:{ email, salt, lanternName, encryptedBirthDate, encryptionCanary, ... }. - No
phoneSalt,encryptedSeed,authMethod,recoveryPhrase, or key caching.
Scenario C: Phone+PIN Login (Returning User) โ
Screen: PhonePinLoginRoute: #/loginFiles: PhonePinLogin.jsx, phoneLookup.js (Cloud Function), encryption.js, pinAttempts.js
- User enters phone number + 6-digit PIN.
normalizePhoneNumber(phone)+validatePIN(pin).- Lockout check:
getAttemptState()โ if locked, shows countdown timer and blocks submission. - Cloud Function lookup โ
lookupPhoneUser({ phone: normalized }):- Server-side (Admin SDK): queries
userscollection wherephone == normalized. - Returns
{ exists, userId, phoneSalt, encryptedSeed, lanternName, authMethod }. - If
!existsโ "No account found with this phone number." - If no
phoneSalt/encryptedSeedโ "This account uses passphrase login."
- Server-side (Admin SDK): queries
unlockEncryptionWithPIN(normalized, pin, phoneSalt, encryptedSeed, userId):- Derives wrapping key:
PBKDF2(phone + ":" + pin, phoneSalt, 600K). - Decrypts
encryptedSeedโ entropy bytes. - Derives encryption key:
PBKDF2(entropy, phoneSalt, 600K). - Caches key + entropy in module variables.
- If PIN is wrong: AES-GCM tag mismatch โ throws error.
- Derives wrapping key:
- On success:
resetAttempts()clears the failure counter.- Caches entropy in IndexedDB via
cacheEntropy(userId, entropy). BiometricPromptshown (same as signup step 6).onComplete(result).
- On failure:
recordFailedAttempt()increments counter.- After
STALE_CLAIM_THRESHOLD(3): shows "Not your phone number?" reclaim link. - After
MAX_ATTEMPTS(5): locked forLOCKOUT_DURATION_MS(15 minutes) with countdown.
Important: Phone+PIN login does NOT re-authenticate with Firebase Auth. It relies on the Firebase Auth session cached via
browserLocalPersistencefrom the original SMS-verified signup. ThelookupPhoneUserCloud Function is an unauthenticated callable โ it only returns encryption parameters, not personal data.
Scenario D: Passphrase Login (Legacy) โ
Screen: LoginFlowRoute: #/login/passphraseFiles: Login.jsx, auth.js, encryption.js
- User enters email + passphrase.
signInWithEmailAndPassword(auth, email, passphrase)โ Firebase Auth.- Fetches
users/{uid}from Firestore. unlockEncryption(passphrase, userData.salt, uid):PBKDF2(passphrase, salt, 600K)โ AES-256-GCM key.
- Canary verification:
- If
encryptionCanaryexists: decrypts and checks against"LANTERN_CANARY_V1". - Else if
encryptedBirthDateexists: decrypts and validates date format (legacy fallback). - If corrupted: sets
encryptionCorrupted: truein Firestore.
- If
- Updates
lastLoginAt(fire-and-forget). - Maps Firebase error codes to user-friendly messages.
Scenario E: Key Cache Auto-Restore (Zero-Friction Return) โ
Location: App.jsx โ onAuthStateChanged handler Files: App.jsx, keyCache.js, encryption.js
Triggered on page load when Firebase Auth has a cached session but the encryption key is not in memory (e.g., after a page refresh):
- Firebase
onAuthStateChangedfires โ user exists. - Loads
publicProfilefrom Firestore. - Checks
!hasEncryptionKey() && publicProfile.phoneSalt(only for phone+PIN users). hasCachedKey(userId)โ checks IndexedDB forkeyCache/{userId}record.- If cached and biometric-protected:
hasBiometricRegistered(userId)โ verifies credential exists.verifyBiometric(userId)โ WebAuthncredentials.get()โ HKDF(credentialId) โ device key.getCachedEntropy(userId, deviceKeyRaw)โ unwraps entropy using device key.
- If cached and NOT biometric-protected (convenience mode):
getCachedEntropy(userId, null)โ unwraps entropy using stored device key.
restoreEncryptionFromEntropy(entropy, phoneSalt, userId):PBKDF2(entropy, phoneSalt, 600K)โ encryption key. Cached in module.
- Attempts to decrypt profile fields if present.
- On failure: logs warning โ user must enter PIN manually on next interaction.
Result: User opens Lantern โ biometric prompt (or nothing) โ fully decrypted. Zero PIN entry.
Scenario F: Biometric Unlock โ
Subset of Scenario E. This documents the biometric subsystem.
Files: biometrics.js, keyCache.js, BiometricPrompt.jsx
Registration (registerBiometric) โ
- WebAuthn
navigator.credentials.create():authenticatorAttachment: 'platform'(fingerprint, Face ID, Windows Hello only โ no USB keys).userVerification: 'required'(must prove presence with biometric).
- Credential ID stored in IndexedDB (
lantern-biometricsDB,credentialsstore). - Device key derived:
HKDF(credentialIdBytes, "lantern-biometric-device-key", "aes-256-gcm")โ 256-bit key. - Entropy re-cached:
cacheEntropy(userId, entropy, deviceKeyRaw)โ wraps entropy with device key, device key is NOT persisted in IndexedDB (only accessible via WebAuthn assertion).
Verification (verifyBiometric) โ
- WebAuthn
navigator.credentials.get()with stored credential ID. - Same HKDF from assertion's credential ID โ identical device key.
- Device key unwraps entropy from IndexedDB.
Availability โ
isBiometricAvailable()checksPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().- If unavailable (older browser, no hardware): falls back to convenience mode (device key stored in IDB alongside wrapped entropy).
- Browser support: Chrome 109+, Safari 16+, Edge 109+, Firefox 119+.
Scenario G: Migration from Passphrase to Phone+PIN โ
Files: encryptionMigration.js, encryption.js
Detection โ
needsMigration(userData) returns true when userData.salt exists (legacy) but userData.phoneSalt does not.
Flow โ
- User logs in with passphrase (legacy
signIn()โ encryption key is now in memory). getEncryptionKeyForMigration()captures the current CryptoKey.- UI collects new phone number + new 6-digit PIN.
migrateToPhoneAndPIN(userId, phone, pin, oldKey):initializeEncryptionWithPIN(normalized, pin, userId)โ generates new recovery phrase, phoneSalt, encryptedSeed. Module now holds the new encryption key.- Captures new key via
getEncryptionKeyForMigration(). - For each field in
ENCRYPTED_FIELDS(['encryptedBirthDate', 'encryptionCanary']):reEncryptData(ciphertext, oldKey, newKey)โ decrypt with old, re-encrypt with new.
- Updates Firestore atomically:
+ phoneSalt, encryptedSeed, recoveryPhraseHash, encryptionMigratedAt + re-encrypted fields (encryptedBirthDate, encryptionCanary) - salt: deleteField() โ removes legacy field - Returns
{ recoveryPhrase }โ must be shown to user.
- If any field fails re-encryption: keeps legacy
salt, setsmigrationPartial: true+migrationFailedFieldsarray.
Firestore Rules Support โ
The security rules allow migration updates when resource.data.salt != null && !('phoneSalt' in resource.data) โ i.e., legacy user who hasn't migrated yet. Allowed fields are restricted to the migration set.
Scenario H: Account Recovery via Recovery Phrase โ
Files: encryption.js (unlockEncryptionWithRecoveryPhrase, reWrapSeed)
When Needed โ
- User forgot their PIN.
- Biometric + key cache unavailable (new device, cleared browser data).
- Only the 12-word recovery phrase can restore access.
Flow โ
- User enters 12-word BIP39 phrase.
unlockEncryptionWithRecoveryPhrase(phrase, phoneSaltBase64, userId):- Normalizes phrase (trim, lowercase, collapse whitespace).
mnemonicToEntropy(phrase, wordlist)โ 16-byte entropy.PBKDF2(entropy, phoneSalt, 600K)โ encryption key (bypasses Layer 2 entirely).- Key + entropy cached in module.
- User creates a new PIN.
reWrapSeed(phrase, phone, newPin, phoneSaltBase64):- Extracts entropy from phrase.
- Derives new wrapping key:
PBKDF2(phone + ":" + newPin, phoneSalt, 600K). - Encrypts entropy โ new
encryptedSeedBase64. - Firestore updated with new
encryptedSeed.
Validation โ
verifyRecoveryPhrase(phrase)checks BIP39 format and checksum (12 words from standard wordlist, valid checksum byte).recoveryPhraseHashin Firestore can verify correctness via hash comparison but cannot regenerate the key.
Unrecoverable State โ
If the user has lost both their PIN and their recovery phrase, their data is permanently unrecoverable. This is by design โ it's the zero-knowledge guarantee. They can create a new account with the same phone number (via reclaim if needed).
Scenario I: Phone Number Reclaim (Carrier Recycling) โ
Files: phoneReclaim.js, PhoneReclaimFlow.jsx, phoneRecycling.js (Cloud Function)
When Triggered โ
After 3+ failed PIN attempts on the login screen, shouldOfferReclaim() returns true, and "Not your phone number?" appears.
Flow โ
- User clicks "This is my phone number now" โ
PhoneReclaimFlowmodal opens. - Confirm step: UI explains the process (carrier recycled numbers, 48h grace period).
initiateReclaim(phoneNumber)โinitiatePhoneReclaimCloud Function:- Finds user by phone in
userscollection. - Checks if account is dormant (12+ months inactive based on
lastActiveAt). - If active โ
failed-preconditionerror (can't reclaim active accounts). - Creates
phoneReclaims/{id}:{ phoneNumber, oldUserId, requestedBy, status: "grace_period", graceExpiresAt }. - Sends notification email to original owner (decrypts
encryptedEmailif available).
- Finds user by phone in
- Waiting step: Polls
checkPhoneReclaimStatusevery 30 seconds. - Completion (after 48h grace period):
completeReclaim(reclaimId)โcompletePhoneReclaimCloud Function:- Detaches phone from old account.
- Old account becomes "orphaned" (recoverable only with recovery phrase).
- User navigates to
#/signupfor fresh registration with that phone number.
Cancellation โ
Original account owner can cancel during the 48-hour grace period if they receive the notification email and still have access.
Scenario J: Account Deletion โ
Location: handleDeleteAccount in App.jsx, triggered from ProfileSettings
window.confirm()โ double-check prompt.clearCachedKey(userId)โ removes IndexedDB entropy cache.removeBiometric(userId)โ removes stored WebAuthn credential from IndexedDB.deleteUserProfile(userId)โ deletes Firestoreusers/{uid}document.currentUser.delete()โ deletes Firebase Auth user.signOut()โ clears encryption key + entropy from module memory.- Navigates to
#/.
Data Model โ
Firestore users/{uid} โ Phone+PIN Accounts โ
| Field | Type | Source | Description |
|---|---|---|---|
phone | string | Client | E.164 normalized phone number |
phoneVerified | boolean | Client | Always true after SMS verification |
phoneSalt | string (base64) | Client | 32-byte random salt for PBKDF2 |
encryptedSeed | string (base64) | Client | Entropy encrypted with phone+PIN wrapping key |
recoveryPhraseHash | string (hex) | Client | SHA-256 of normalized recovery phrase (verification only) |
authMethod | string | Client | "phone_pin" |
lanternName | string | Client | Generated anonymous display name |
encryptedBirthDate | string (base64) | Client | AES-GCM encrypted birth date |
encryptionCanary | string (base64) | Client | Encrypted "LANTERN_CANARY_V1" for corruption detection |
email | string or null | Client | Optional plaintext email |
encryptedEmail | string | Server | Server-side AES-256-GCM encrypted email |
emailEncryptedAt | timestamp | Server | When server encrypted the email |
interests | array | Client | User interests |
mood | string or null | Client | Current mood |
locationTracking | boolean | Client | Location tracking preference |
createdAt | timestamp | Client | Account creation time |
updatedAt | timestamp | Client | Last profile update time |
lastLoginAt | timestamp | Client | Last login (fire-and-forget) |
Firestore users/{uid} โ Legacy Passphrase Accounts โ
| Field | Type | Description |
|---|---|---|
email | string | Plaintext email (used for Firebase Auth) |
salt | string (base64) | 32-byte salt for passphrase PBKDF2 |
lanternName | string | Generated display name |
encryptedBirthDate | string (base64) | AES-GCM encrypted birth date |
encryptionCanary | string (base64) | Corruption detection canary |
No phoneSalt, encryptedSeed, phone, authMethod | Absence of these fields identifies legacy accounts |
Migration-Related Fields โ
| Field | When Present | Description |
|---|---|---|
encryptionMigratedAt | After successful migration | Timestamp |
migrationPartial | Partial migration failure | true if some fields couldn't re-encrypt |
migrationFailedFields | Partial migration failure | Array of failed field names |
encryptionCorrupted | Corruption detected | true when canary check fails |
encryptionCorruptedDetectedAt | Corruption detected | Timestamp |
IndexedDB Stores (Client-Side) โ
lantern-keystore โ keyCache object store:
- Key:
keyCache/{userId} - Value:
{ wrappedEntropy, iv, biometricProtected, cachedAt, [deviceKeyRaw] } deviceKeyRawpresent only in convenience mode (no biometric). When biometric is enabled, the device key is derived at assertion time from the WebAuthn credential ID and is never persisted.
lantern-biometrics โ credentials object store:
- Key:
userId - Value:
{ credentialId: Uint8Array, createdAt }
Security Model โ
What's Stored vs. What's Derived โ
| Data | Stored Where | Purpose |
|---|---|---|
phoneSalt | Firestore | Public; useless without PIN or entropy |
encryptedSeed | Firestore | Entropy wrapped with phone+PIN key |
recoveryPhraseHash | Firestore | SHA-256 for verification only (one-way) |
encryptedBirthDate | Firestore | AES-GCM ciphertext |
encryptionCanary | Firestore | Corruption detection |
| PIN | NOWHERE | Only in user's memory |
| Recovery phrase | NOWHERE | Only shown once at signup |
| Entropy (wrapped) | IndexedDB | Encrypted with device key |
| Device key (no biometric) | IndexedDB | Stored alongside wrapped entropy |
| Device key (biometric) | NOWHERE | Derived from WebAuthn credential ID via HKDF at assertion time |
encryptedEmail | Firestore | Server-side AES-256-GCM (key in Cloud Secret Manager) |
Threat Model โ
| Attack Vector | Risk | Mitigation |
|---|---|---|
| SIM swap | Medium | Attacker gets SMS but still needs PIN to decrypt |
| Database breach | Low | Only ciphertext + salt exposed; PIN required |
| PIN brute force (online) | Low | 5 attempts โ 15-min lockout, client-side rate limiting |
| PIN brute force (offline) | Medium | 600K PBKDF2 iterations; 10^6 search space โ 5.8 days on GPU |
| Phone number revealed | Low | lookupPhoneUser returns only encryption params, not personal data |
| IndexedDB extraction | Low | Entropy is wrapped with device key; biometric mode never persists device key |
| Recovery phrase theft | High | Full account access if phrase is stolen (by design) |
| Malware on device | High | Key in memory during session; mitigated by browser sandboxing |
Server-Side Email Encryption โ
- Uses
aes-256-gcmwith a key from Google Cloud Secret Manager (EMAIL_ENCRYPTION_KEY). - Lantern can decrypt emails (for sending notifications) โ this is not zero-knowledge for email.
- A database breach alone exposes only email ciphertext.
Edge Cases & Error Handling โ
Locked Out (Too Many PIN Attempts) โ
- Client-side counter: resets on page reload (intentional โ protection is UX friction, not hard enforcement).
- After 5 failures โ 15-minute lockout with countdown timer.
- After 3 failures โ phone reclaim link shown.
applyServerLockout(ms)can enforce server-side lockout if implemented.
Wrong PIN โ
unlockEncryptionWithPINthrows when AES-GCM authentication tag mismatch occurs duringencryptedSeeddecryption.recordFailedAttempt()increments the in-memory counter.- No information leaked about why it failed โ same error for wrong PIN or corrupt data.
Phone Number Existence Disclosure โ
The lookupPhoneUser Cloud Function returns { exists: true/false }, which reveals whether a phone number has an account. This is an intentional UX tradeoff โ it provides immediate feedback rather than requiring SMS verification first. Only encryption parameters are returned (no personal data).
Firebase Auth Session Expiry โ
Phone+PIN login relies on browserLocalPersistence from the original SMS-verified signup. If the Firebase Auth session expires (cleared browser data, new device), the user would need to re-verify their phone via SMS. The current PhonePinLogin flow does not handle re-authentication โ it assumes the session persists. On a new device, the user must go through the full signup flow again (or recovery).
Encryption Corruption Detection โ
- On login:
verifyEncryptionCanary(encryptionCanary)decrypts and checks=== "LANTERN_CANARY_V1". - Fallback for pre-canary accounts: decrypts
encryptedBirthDateand validates date format. - If corrupted: sets
encryptionCorrupted: truein Firestore. Security rules allow re-initialization when this flag is set.
Partial Migration โ
If re-encryption of some fields fails during passphrase โ phone+PIN migration:
- Legacy
saltis kept (not deleted). migrationPartial: trueandmigrationFailedFieldsarray are written.- Those fields remain decryptable only with the old passphrase key.
- This is a documented degraded state requiring manual intervention.
Emulator Mode โ
When VITE_USE_EMULATORS=true:
- Connects Auth (port 9099), Firestore (8080), Functions (5001).
auth.settings.appVerificationDisabledForTesting = trueโ reCAPTCHA skipped for phone auth.- No actual SMS sent; emulator auto-generates verification codes.
- See FIREBASE_EMULATOR_TESTING.md for setup.
Firebase Auth Double-Fire โ
onAuthStateChanged fires twice on load: once from IndexedDB cache, again after network validation. The app handles this by never re-setting authLoading(true) inside the callback โ it only transitions to false.
PILOT_MODE / AccessGate โ
When PILOT_MODE = true in App.jsx:
- All non-auth routes are gated by
AccessGate. - Three states: Loading โ Login prompt (not authenticated) โ Denied (authenticated but not admin).
- Only admin-role users can access the app.
- Auth routes (
#/login,#/signup,#/login/passphrase,#/signup/passphrase) bypass the gate.
Sign-Out Cleanup โ
clearEncryptionKey()โ clears in-memory key, userId, and entropy.- Does NOT clear IndexedDB cache or biometric credentials โ those persist for next login convenience.
firebaseSignOut(auth)โ clears Firebase Auth session.
File Reference โ
Frontend โ Auth Screens โ
| File | Purpose |
|---|---|
apps/web/src/App.jsx | Root component, routing, onAuthStateChanged, key cache restore, account deletion |
apps/web/src/screens/auth/PhonePinSignup.jsx | 6-step phone+PIN signup flow |
apps/web/src/screens/auth/PhonePinLogin.jsx | Phone+PIN login with rate limiting |
apps/web/src/screens/auth/SignUp.jsx | Legacy passphrase signup |
apps/web/src/screens/auth/Login.jsx | Legacy passphrase login |
apps/web/src/components/BiometricPrompt.jsx | "Enable biometric unlock?" modal |
apps/web/src/components/EmailCaptureStep.jsx | Optional email capture during signup |
apps/web/src/components/RecoveryPhraseDisplay.jsx | Recovery phrase reveal + confirmation |
apps/web/src/components/RecoveryBackupModal.jsx | Encrypted email backup of recovery phrase |
apps/web/src/components/PhoneReclaimFlow.jsx | Phone number reclaim UI |
Frontend โ Libraries โ
| File | Purpose |
|---|---|
apps/web/src/lib/encryption.js | Core encryption: key derivation, encrypt/decrypt, canary, PIN unlock, recovery |
apps/web/src/lib/encryptionMigration.js | Passphrase โ phone+PIN migration logic |
apps/web/src/lib/keyCache.js | IndexedDB entropy caching with device key wrapping |
apps/web/src/lib/biometrics.js | WebAuthn platform authenticator registration/verification |
apps/web/src/lib/pinAttempts.js | Client-side PIN attempt tracking and lockout |
apps/web/src/lib/phoneReclaim.js | Phone reclaim state management |
apps/web/src/lib/auth.js | Legacy passphrase auth (signUp, signIn, signOut) |
apps/web/src/lib/emailBackup.js | Recovery phrase backup encryption + email sending |
apps/web/src/firebase.js | Firebase init, emulator detection, persistence setup |
Shared Package โ
| File | Purpose |
|---|---|
packages/shared/encryption/index.js | ENCRYPTION_CONFIG, PIN_ATTEMPT_CONFIG, WEAK_PINS, validatePIN, normalizePhoneNumber, ENCRYPTED_FIELDS |
Cloud Functions โ
| File | Function | Auth Required | Purpose |
|---|---|---|---|
services/functions/firebase/modules/phoneLookup.js | lookupPhoneUser | No | Returns encryption params for a phone number |
services/functions/firebase/modules/emailEncryption.js | encryptUserEmail | Yes | Server-side email encryption |
services/functions/firebase/modules/recoveryBackup.js | sendRecoveryBackupEmail | Yes | Sends encrypted recovery phrase email |
services/functions/firebase/modules/phoneRecycling.js | initiatePhoneReclaim, completePhoneReclaim, checkPhoneReclaimStatus | Varies | Phone number reclaim workflow |
Security Rules โ
| File | Purpose |
|---|---|
firestore.rules | User create/update rules, migration field allowlists, corruption re-init |
storage.rules | Firebase Storage access rules |
Recovery Phrase System โ
What Is It? โ
A BIP39 mnemonic phrase โ 12 random words that encode a 128-bit entropy seed.
Example: apple brave candle dragon eagle flame garden harbor island jungle kindle lunarHow It Works โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ RECOVERY PHRASE SYSTEM โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ At Signup: โ
โ โโโโโโโโโโ โ
โ 1. Generate 128 bits of entropy โ
โ 2. Convert to 12-word BIP39 mnemonic โ
โ 3. Entropy โ PBKDF2 โ same encryption key as phone+PIN path โ
โ 4. Show words to user ONCE โ they write them down โ
โ 5. Store SHA-256 hash of phrase (for verification only) โ
โ โ
โ During Recovery: โ
โ โโโโโโโโโโโโโโโโ โ
โ 1. User enters 12 words โ
โ 2. mnemonicToEntropy() โ 16-byte entropy โ
โ 3. PBKDF2(entropy, phoneSalt) โ encryption key (Layer 1 only) โ
โ 4. User creates new PIN โ new wrapping key โ new encryptedSeed โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ What's stored: Hash of phrase (verification only) โ
โ What's NOT stored: The phrase itself, the entropy โ
โ Who has the phrase: User only (written on paper or backed up) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโSecurity of Recovery Phrase โ
| Threat | Protected? | Notes |
|---|---|---|
| Brute force | Yes | 2^128 combinations = computationally impossible |
| Server breach | Yes | Phrase never stored (only one-way hash) |
| Shoulder surfing | Partial | Only shown once at signup |
| Physical theft of paper | No | User must secure the written phrase |
References โ
- BIP39 Mnemonic Code
- OWASP Password Storage Cheat Sheet
- WebAuthn Guide
- ZERO_KNOWLEDGE_ENCRYPTION.md โ legacy passphrase encryption details
- FIREBASE_EMULATOR_TESTING.md โ emulator setup for local testing