Skip to content

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

  1. Overview
  2. Architecture
  3. Scenario A: Phone+PIN Signup (Primary)
  4. Scenario B: Passphrase Signup (Legacy)
  5. Scenario C: Phone+PIN Login
  6. Scenario D: Passphrase Login (Legacy)
  7. Scenario E: Key Cache Auto-Restore
  8. Scenario F: Biometric Unlock
  9. Scenario G: Migration from Passphrase to Phone+PIN
  10. Scenario H: Account Recovery via Recovery Phrase
  11. Scenario I: Phone Number Reclaim (Carrier Recycling)
  12. Scenario J: Account Deletion
  13. Data Model
  14. Security Model
  15. Edge Cases & Error Handling
  16. File Reference

Overview โ€‹

Lantern supports two authentication/encryption schemes:

Phone + PIN (Default)Passphrase (Legacy)
Auth methodFirebase Phone Auth (SMS)Firebase Email/Password Auth
Key derivationTwo-layer (entropy + wrapping key)Single-layer (passphrase โ†’ key)
Recovery12-word BIP39 phraseNone (lost = gone)
Biometric unlockSupported (WebAuthn)Not available
Key cachingIndexedDB entropy cacheNot 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 key

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

  1. User enters phone number and birth date.
  2. normalizePhoneNumber(phone) validates and converts to E.164 format (e.g., +15551234567).
  3. Age check: must be 18+ based on birth date.
  4. Invisible reCAPTCHA (RecaptchaVerifier) initialized on the container div.
  5. signInWithPhoneNumber(auth, normalized, recaptchaVerifier) triggers SMS.
  6. confirmationResult stored in component state.

Errors: auth/too-many-requests (SMS rate limit), auth/invalid-phone-number (bad format).

Step 2 โ€” SMS Verification Code โ€‹

  1. User enters the 6-digit SMS code.
  2. confirmationResult.confirm(code) โ†’ returns credential.user (Firebase Auth user now exists).
  3. The phoneUser object is stored in state for later use.
  4. Resend option available โ€” clears and recreates the reCAPTCHA verifier.

Errors: Invalid/expired code, SMS not received.

Step 3 โ€” PIN Creation โ€‹

  1. User enters a 6-digit PIN and confirms it.
  2. Real-time validation checks:
    • Exactly 6 digits (/^\d{6}$/)
    • Not in WEAK_PINS set (40+ blocked patterns like 123456, 000000, 112233)
    • PINs match
  3. validatePIN(pin) performs the final check before proceeding.

Step 4 โ€” Email Capture (Optional) โ€‹

  1. EmailCaptureStep component shows email input with confirmation field.
  2. User can enter an email (for recovery notifications) or skip.
  3. Triggers createAccount(email) or createAccount(null).

Step 5 โ€” Account Creation (Background) + Recovery Phrase Display โ€‹

Account creation happens automatically when moving to step 5:

  1. 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 }.
  2. Encrypts user data:

    • encryptData(birthDate) โ†’ encryptedBirthDate.
    • createEncryptionCanary() โ†’ encrypts "LANTERN_CANARY_V1" for corruption detection.
  3. Generates identity: generateLanternName(user.uid) โ†’ anonymous display name.

  4. 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]
  5. Updates Firebase Auth displayName to the lantern name.

  6. Server-side email encryption (if email provided):

    • Calls encryptUserEmail Cloud Function โ†’ stores encryptedEmail field.
    • Calls sendRecoveryBackupEmail Cloud Function โ†’ sends recovery notification.
  7. Caches entropy in IndexedDB via cacheEntropy(userId, entropy) (convenience mode โ€” no biometric protection yet).

  8. 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 with PBKDF2(password) โ†’ AES-GCM, and emails the encrypted blob via sendEncryptedBackupEmail Cloud Function.

Step 6 โ€” Terms Confirmation + Biometric Prompt โ€‹

  1. User checks "I've saved my recovery phrase" and agrees to terms.
  2. BiometricPrompt modal appears (if entropy is available):
    • isBiometricAvailable() โ€” checks for WebAuthn platform authenticator.
    • hasBiometricRegistered(userId) โ€” skips if already registered.
    • "Enable": registerBiometric(userId, displayName) โ†’ WebAuthn credentials.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.
  3. 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

  1. Email + Age: Email validation + birth date + 18+ check.
  2. Passphrase Creation: 12+ chars, uppercase, lowercase, number, special character. Confirm match.
  3. 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

  1. User enters phone number + 6-digit PIN.
  2. normalizePhoneNumber(phone) + validatePIN(pin).
  3. Lockout check: getAttemptState() โ€” if locked, shows countdown timer and blocks submission.
  4. Cloud Function lookup โ€” lookupPhoneUser({ phone: normalized }):
    • Server-side (Admin SDK): queries users collection where phone == 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."
  5. 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.
  6. On success:
    • resetAttempts() clears the failure counter.
    • Caches entropy in IndexedDB via cacheEntropy(userId, entropy).
    • BiometricPrompt shown (same as signup step 6).
    • onComplete(result).
  7. On failure:
    • recordFailedAttempt() increments counter.
    • After STALE_CLAIM_THRESHOLD (3): shows "Not your phone number?" reclaim link.
    • After MAX_ATTEMPTS (5): locked for LOCKOUT_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 browserLocalPersistence from the original SMS-verified signup. The lookupPhoneUser Cloud 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

  1. User enters email + passphrase.
  2. signInWithEmailAndPassword(auth, email, passphrase) โ€” Firebase Auth.
  3. Fetches users/{uid} from Firestore.
  4. unlockEncryption(passphrase, userData.salt, uid):
    • PBKDF2(passphrase, salt, 600K) โ†’ AES-256-GCM key.
  5. Canary verification:
    • If encryptionCanary exists: decrypts and checks against "LANTERN_CANARY_V1".
    • Else if encryptedBirthDate exists: decrypts and validates date format (legacy fallback).
    • If corrupted: sets encryptionCorrupted: true in Firestore.
  6. Updates lastLoginAt (fire-and-forget).
  7. 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):

  1. Firebase onAuthStateChanged fires โ†’ user exists.
  2. Loads publicProfile from Firestore.
  3. Checks !hasEncryptionKey() && publicProfile.phoneSalt (only for phone+PIN users).
  4. hasCachedKey(userId) โ†’ checks IndexedDB for keyCache/{userId} record.
  5. If cached and biometric-protected:
    • hasBiometricRegistered(userId) โ†’ verifies credential exists.
    • verifyBiometric(userId) โ†’ WebAuthn credentials.get() โ†’ HKDF(credentialId) โ†’ device key.
    • getCachedEntropy(userId, deviceKeyRaw) โ†’ unwraps entropy using device key.
  6. If cached and NOT biometric-protected (convenience mode):
    • getCachedEntropy(userId, null) โ†’ unwraps entropy using stored device key.
  7. restoreEncryptionFromEntropy(entropy, phoneSalt, userId):
    • PBKDF2(entropy, phoneSalt, 600K) โ†’ encryption key. Cached in module.
  8. Attempts to decrypt profile fields if present.
  9. 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) โ€‹

  1. WebAuthn navigator.credentials.create():
    • authenticatorAttachment: 'platform' (fingerprint, Face ID, Windows Hello only โ€” no USB keys).
    • userVerification: 'required' (must prove presence with biometric).
  2. Credential ID stored in IndexedDB (lantern-biometrics DB, credentials store).
  3. Device key derived: HKDF(credentialIdBytes, "lantern-biometric-device-key", "aes-256-gcm") โ†’ 256-bit key.
  4. 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) โ€‹

  1. WebAuthn navigator.credentials.get() with stored credential ID.
  2. Same HKDF from assertion's credential ID โ†’ identical device key.
  3. Device key unwraps entropy from IndexedDB.

Availability โ€‹

  • isBiometricAvailable() checks PublicKeyCredential.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 โ€‹

  1. User logs in with passphrase (legacy signIn() โ€” encryption key is now in memory).
  2. getEncryptionKeyForMigration() captures the current CryptoKey.
  3. UI collects new phone number + new 6-digit PIN.
  4. 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.
  5. If any field fails re-encryption: keeps legacy salt, sets migrationPartial: true + migrationFailedFields array.

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

  1. User enters 12-word BIP39 phrase.
  2. 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.
  3. User creates a new PIN.
  4. 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).
  • recoveryPhraseHash in 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 โ€‹

  1. User clicks "This is my phone number now" โ†’ PhoneReclaimFlow modal opens.
  2. Confirm step: UI explains the process (carrier recycled numbers, 48h grace period).
  3. initiateReclaim(phoneNumber) โ†’ initiatePhoneReclaim Cloud Function:
    • Finds user by phone in users collection.
    • Checks if account is dormant (12+ months inactive based on lastActiveAt).
    • If active โ†’ failed-precondition error (can't reclaim active accounts).
    • Creates phoneReclaims/{id}: { phoneNumber, oldUserId, requestedBy, status: "grace_period", graceExpiresAt }.
    • Sends notification email to original owner (decrypts encryptedEmail if available).
  4. Waiting step: Polls checkPhoneReclaimStatus every 30 seconds.
  5. Completion (after 48h grace period): completeReclaim(reclaimId) โ†’ completePhoneReclaim Cloud Function:
    • Detaches phone from old account.
    • Old account becomes "orphaned" (recoverable only with recovery phrase).
  6. User navigates to #/signup for 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

  1. window.confirm() โ€” double-check prompt.
  2. clearCachedKey(userId) โ€” removes IndexedDB entropy cache.
  3. removeBiometric(userId) โ€” removes stored WebAuthn credential from IndexedDB.
  4. deleteUserProfile(userId) โ€” deletes Firestore users/{uid} document.
  5. currentUser.delete() โ€” deletes Firebase Auth user.
  6. signOut() โ€” clears encryption key + entropy from module memory.
  7. Navigates to #/.

Data Model โ€‹

Firestore users/{uid} โ€” Phone+PIN Accounts โ€‹

FieldTypeSourceDescription
phonestringClientE.164 normalized phone number
phoneVerifiedbooleanClientAlways true after SMS verification
phoneSaltstring (base64)Client32-byte random salt for PBKDF2
encryptedSeedstring (base64)ClientEntropy encrypted with phone+PIN wrapping key
recoveryPhraseHashstring (hex)ClientSHA-256 of normalized recovery phrase (verification only)
authMethodstringClient"phone_pin"
lanternNamestringClientGenerated anonymous display name
encryptedBirthDatestring (base64)ClientAES-GCM encrypted birth date
encryptionCanarystring (base64)ClientEncrypted "LANTERN_CANARY_V1" for corruption detection
emailstring or nullClientOptional plaintext email
encryptedEmailstringServerServer-side AES-256-GCM encrypted email
emailEncryptedAttimestampServerWhen server encrypted the email
interestsarrayClientUser interests
moodstring or nullClientCurrent mood
locationTrackingbooleanClientLocation tracking preference
createdAttimestampClientAccount creation time
updatedAttimestampClientLast profile update time
lastLoginAttimestampClientLast login (fire-and-forget)

Firestore users/{uid} โ€” Legacy Passphrase Accounts โ€‹

FieldTypeDescription
emailstringPlaintext email (used for Firebase Auth)
saltstring (base64)32-byte salt for passphrase PBKDF2
lanternNamestringGenerated display name
encryptedBirthDatestring (base64)AES-GCM encrypted birth date
encryptionCanarystring (base64)Corruption detection canary
No phoneSalt, encryptedSeed, phone, authMethodAbsence of these fields identifies legacy accounts
FieldWhen PresentDescription
encryptionMigratedAtAfter successful migrationTimestamp
migrationPartialPartial migration failuretrue if some fields couldn't re-encrypt
migrationFailedFieldsPartial migration failureArray of failed field names
encryptionCorruptedCorruption detectedtrue when canary check fails
encryptionCorruptedDetectedAtCorruption detectedTimestamp

IndexedDB Stores (Client-Side) โ€‹

lantern-keystore โ†’ keyCache object store:

  • Key: keyCache/{userId}
  • Value: { wrappedEntropy, iv, biometricProtected, cachedAt, [deviceKeyRaw] }
  • deviceKeyRaw present 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 โ€‹

DataStored WherePurpose
phoneSaltFirestorePublic; useless without PIN or entropy
encryptedSeedFirestoreEntropy wrapped with phone+PIN key
recoveryPhraseHashFirestoreSHA-256 for verification only (one-way)
encryptedBirthDateFirestoreAES-GCM ciphertext
encryptionCanaryFirestoreCorruption detection
PINNOWHEREOnly in user's memory
Recovery phraseNOWHEREOnly shown once at signup
Entropy (wrapped)IndexedDBEncrypted with device key
Device key (no biometric)IndexedDBStored alongside wrapped entropy
Device key (biometric)NOWHEREDerived from WebAuthn credential ID via HKDF at assertion time
encryptedEmailFirestoreServer-side AES-256-GCM (key in Cloud Secret Manager)

Threat Model โ€‹

Attack VectorRiskMitigation
SIM swapMediumAttacker gets SMS but still needs PIN to decrypt
Database breachLowOnly ciphertext + salt exposed; PIN required
PIN brute force (online)Low5 attempts โ†’ 15-min lockout, client-side rate limiting
PIN brute force (offline)Medium600K PBKDF2 iterations; 10^6 search space โ‰ˆ 5.8 days on GPU
Phone number revealedLowlookupPhoneUser returns only encryption params, not personal data
IndexedDB extractionLowEntropy is wrapped with device key; biometric mode never persists device key
Recovery phrase theftHighFull account access if phrase is stolen (by design)
Malware on deviceHighKey in memory during session; mitigated by browser sandboxing

Server-Side Email Encryption โ€‹

  • Uses aes-256-gcm with 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 โ€‹

  • unlockEncryptionWithPIN throws when AES-GCM authentication tag mismatch occurs during encryptedSeed decryption.
  • 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 encryptedBirthDate and validates date format.
  • If corrupted: sets encryptionCorrupted: true in 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 salt is kept (not deleted).
  • migrationPartial: true and migrationFailedFields array 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 โ€‹

FilePurpose
apps/web/src/App.jsxRoot component, routing, onAuthStateChanged, key cache restore, account deletion
apps/web/src/screens/auth/PhonePinSignup.jsx6-step phone+PIN signup flow
apps/web/src/screens/auth/PhonePinLogin.jsxPhone+PIN login with rate limiting
apps/web/src/screens/auth/SignUp.jsxLegacy passphrase signup
apps/web/src/screens/auth/Login.jsxLegacy passphrase login
apps/web/src/components/BiometricPrompt.jsx"Enable biometric unlock?" modal
apps/web/src/components/EmailCaptureStep.jsxOptional email capture during signup
apps/web/src/components/RecoveryPhraseDisplay.jsxRecovery phrase reveal + confirmation
apps/web/src/components/RecoveryBackupModal.jsxEncrypted email backup of recovery phrase
apps/web/src/components/PhoneReclaimFlow.jsxPhone number reclaim UI

Frontend โ€” Libraries โ€‹

FilePurpose
apps/web/src/lib/encryption.jsCore encryption: key derivation, encrypt/decrypt, canary, PIN unlock, recovery
apps/web/src/lib/encryptionMigration.jsPassphrase โ†’ phone+PIN migration logic
apps/web/src/lib/keyCache.jsIndexedDB entropy caching with device key wrapping
apps/web/src/lib/biometrics.jsWebAuthn platform authenticator registration/verification
apps/web/src/lib/pinAttempts.jsClient-side PIN attempt tracking and lockout
apps/web/src/lib/phoneReclaim.jsPhone reclaim state management
apps/web/src/lib/auth.jsLegacy passphrase auth (signUp, signIn, signOut)
apps/web/src/lib/emailBackup.jsRecovery phrase backup encryption + email sending
apps/web/src/firebase.jsFirebase init, emulator detection, persistence setup

Shared Package โ€‹

FilePurpose
packages/shared/encryption/index.jsENCRYPTION_CONFIG, PIN_ATTEMPT_CONFIG, WEAK_PINS, validatePIN, normalizePhoneNumber, ENCRYPTED_FIELDS

Cloud Functions โ€‹

FileFunctionAuth RequiredPurpose
services/functions/firebase/modules/phoneLookup.jslookupPhoneUserNoReturns encryption params for a phone number
services/functions/firebase/modules/emailEncryption.jsencryptUserEmailYesServer-side email encryption
services/functions/firebase/modules/recoveryBackup.jssendRecoveryBackupEmailYesSends encrypted recovery phrase email
services/functions/firebase/modules/phoneRecycling.jsinitiatePhoneReclaim, completePhoneReclaim, checkPhoneReclaimStatusVariesPhone number reclaim workflow

Security Rules โ€‹

FilePurpose
firestore.rulesUser create/update rules, migration field allowlists, corruption re-init
storage.rulesFirebase 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 lunar

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

ThreatProtected?Notes
Brute forceYes2^128 combinations = computationally impossible
Server breachYesPhrase never stored (only one-way hash)
Shoulder surfingPartialOnly shown once at signup
Physical theft of paperNoUser must secure the written phrase

References โ€‹

Built with VitePress