Secrets & backups
what is stored where.
Every key and secret the product uses: where it lives, what protects it, who can read it, what happens if it is lost and how to replace it. Plus the backups to set up before launch.
The commands on this page are written for macOS and Linux shells. On Windows, run them in Git Bash or WSL, where they work as written. In PowerShell, set a variable with $env:NAME="value" instead of export. Flutter, Node.js and the Firebase and FlutterFire CLIs run natively on Windows; iOS and macOS builds need a Mac.
01Three rules
- User keys never leave the device. You cannot see, back up or recover them for anyone.
- Anything the app can read is public. That includes
.envbuild keys, Remote Config,/config.jsonand theapp_configcollection. Only client-side keys go there, restricted in each provider's dashboard. - Server secrets live only in your Firebase project:
functions/.envand Secret Manager. Never in the app, never in the admin panel's code.
02Where each secret lives
| Secret | Stored in | Protected by | Who can read it |
|---|---|---|---|
| User seed phrase and private keys | The user's device (lib/core/vault_service.dart) | AES-256-GCM.
The encryption key sits in the iOS Keychain (this device only, after first unlock, not restored to a new phone), Android encrypted storage backed by the Keystore, or — in the web build — behind the vault passphrase the user sets:
PBKDF2-HMAC-SHA256 with 600,000 iterations in the browser's Web Crypto turns that passphrase into 64 bytes.
The first 32 encrypt the vault's own AES key (AES-256-GCM) before it is ever stored; the last 32 are the wrapKey given to flutter_secure_storage, so the key that library keeps in the browser's localStorage is wrapped instead of exportable.
Only a random salt, a wrap IV and a SHA-256 verifier are saved (lib/core/web_vault_passphrase.dart, lib/core/vault_kdf_web.dart). | Only the app on that device |
Client API keys from .env | Compiled into the app (--dart-define-from-file) | Nothing. They can be extracted from the app. | Anyone with the app |
| Remote API Keys (Admin → System Config) | Firestore app_config/global and app_config/runtime_admin_config, served at /config.json and in Remote Config; then each device's secure storage | Firestore rule allow read: if true on app_config: public by design | Anyone |
| Transak key | Firestore admin_config/onramp | Public read (the rules allow onramp before sign-in) | Anyone |
Store credentials:
APP_STORE_PRIVATE_KEY (.p8), APP_STORE_KEY_ID, APP_STORE_ISSUER_ID, APPLE_BUNDLE_ID, GOOGLE_PLAY_SERVICE_ACCOUNT_JSON, WEBHOOK_SECRET | crypto_wallet/functions/.env, deployed as environment variables of your functions | Google Cloud access control. The product does not encrypt them itself. | Your functions and people with access to your Google Cloud project |
OWNER_UID, CLAUDE_API_KEY, ADMIN_SETUP_TOKEN | Google Secret Manager (defineSecret in functions/src/index.ts) | Secret Manager, encrypted by Google | Only the functions that declare them |
| Admin passwords | Firebase Authentication | Firebase | Nobody, not even you |
| Admin authenticator secret | Firestore admin_users/<email>, field mfa_secret, as plain text. Backup codes are stored only as hashes. | Firestore rules: only signed-in admins can read admin_users | Every admin, including viewers. Give admin access only to people you trust. |
| Fee wallet and crypto-payment wallet | Only the public addresses are stored (Monetization, System Config) | The private keys stay in your own wallet, never in the product | Anyone (addresses are public on-chain anyway) |
| Android upload keystore, Apple signing certificates | Your computer (android/key.properties), Xcode | You | You. Not in the package. |
A browser has no Keychain and no secure chip, so in the web build the user must set a vault passphrase of at least 10 characters before a wallet can be created or imported, and type it again to unlock that browser, and once more before a sensitive operation: revealing the recovery phrase, exporting or importing an encrypted backup, changing a security setting,
and every signing, whatever the amount: a send, a swap, a WalletConnect signature or transaction, a speed-up or cancel (requiresSigningReauth in lib/core/security/sensitive_operation_policy.dart).
On iOS and Android the device asks again from $50, or when the value of a transfer cannot be priced (kSensitiveOperationUsdThreshold).
The passphrase is never stored and never leaves the browser, so nothing in the browser, in Firebase or with you can recover it: if a user forgets it, that browser wallet is gone and only the written recovery phrase brings it back. iOS and Android are unchanged — the Keychain and the Android Keystore hold the key, and the device asks for biometrics or the device passcode for sensitive operations.
A browser vault created by version 1.0.0 is moved behind the new passphrase the first time one is set (configureWebPassphrase in lib/core/vault_service.dart).
Keys you type into Admin → System Config → Remote API Keys are readable by anyone at your /config.json. That is the same exposure as a key compiled into the app, so use the same protection: a separate key per app, domain or bundle restrictions where the provider offers them, and rate limits. Never type a server secret there.
The Portfolio Brain function reads its Claude key only from the CLAUDE_API_KEY secret (firebase functions:secrets:set CLAUDE_API_KEY). The admin panel has no field for it and never stores it.
callPortfolioBrain is not an open AI endpoint: it enforces App Check, requires a server-verified, paid PRO plan (a free trial does not qualify), accepts only structured portfolio data with a 500-character question limit, and stops at a per-user and a global daily cap (limits). bootstrapFirstAdmin requires the ADMIN_SETUP_TOKEN secret or the OWNER_UID account and locks itself after the first admin (details).
firebase_options.dart, google-services.json and GoogleService-Info.plist contain client identifiers, not secrets. They are in .gitignore anyway; keep them out of public repositories.
03If something is lost or leaked
| Situation | What happens | What to do |
|---|---|---|
| A user loses the phone or uninstalls the app | The keys on that device are gone. | The user imports the wallet again with the recovery phrase. Without the phrase the funds cannot be recovered by anyone. |
| Android cannot decrypt its secure storage (for example after some system restores) | The storage is set to reset on error (resetOnError: true), so the stored wallet is removed. | Same as above: import with the recovery phrase. Tell users to keep it safe. |
| A user forgets the web vault passphrase | That browser's vault cannot be decrypted. There is no reset and no backdoor; the passphrase is not stored anywhere. | Import the wallet again with the recovery phrase. In the same browser, clear the site's data first so the app returns to onboarding. |
| A client API key leaks or hits its limit | Swaps or data from that provider fail. | Create a new key at the provider. Enter it in Admin → System Config → Remote API Keys, save, publish. Devices store the new key on their next config load. Delete the old key at the provider once users have the new one. Also update .env for your next build. |
| A store credential leaks or expires | PRO purchases stop being verified. | Revoke it in App Store Connect or Google Cloud, create a new one, update functions/.env, then firebase deploy --only functions. No user data is lost. |
| A Secret Manager value must change | Functions keep the old value until redeployed. | firebase functions:secrets:set OWNER_UID (or CLAUDE_API_KEY), then firebase deploy --only functions. |
| An admin leaves or a password leaks | They keep access until removed. | Admin → User Roles: remove them. Disable their Firebase Authentication account (Firebase: manage users). Check the Activity Log. |
| An admin loses the authenticator phone | They cannot pass the second step. | Use a backup code, or a super admin disables their authenticator in User Roles. |
| Your fee wallet is compromised | Future fees go to a wallet someone else controls. | Admin → Monetization: enter a new wallet, Apply Fee Config, publish. Check the Activity Log for who changed what. |
| The Android upload keystore is lost | You cannot sign updates with it. | With Play App Signing, ask Google Play support to reset the upload key. Back it up from day one. |
04What users can back up
- Recovery phrase: shown once at wallet creation. It is the only full backup.
- Encrypted vault backup: the wallet's backup and recovery screen in Settings can export the vault as an encrypted text, copied to the clipboard. It needs a passphrase of at least 12 characters and uses AES-256-GCM with a PBKDF2-SHA256 key (600,000 rounds) (
exportEncryptedBackupBlobinlib/core/vault_service.dart). The passphrase cannot be recovered. It is typed on the backup screen and is not the same thing as the web vault passphrase above. - Contacts and custom tokens: a separate encrypted export that never contains keys (
lib/core/sync/wallet_backup_service.dart).
05Your backups as operator
The product does not back up your Firebase data by itself. Set these up before launch.
Firestore
Settings, content lists, admins, PRO status and fee records live in Firestore. A daily backup with the Firebase CLI (Google Cloud bills backup storage; check the current pricing):
cd crypto_wallet
firebase firestore:backups:schedules:create --recurrence DAILY --retention 14d
firebase firestore:backups:list # see what exists
Remote Config
Firebase keeps the published versions of your Remote Config. Save a copy and roll back if a publish went wrong:
cd crypto_wallet
firebase remoteconfig:get -o remoteconfig.backup.json
firebase remoteconfig:versions:list --limit 10
firebase remoteconfig:rollback -v <version-number>
Admin snapshots
Admin → Publish Config → Save Version Snapshot before big changes. Version history on the same page has Rollback. Snapshots are stored in the config_versions collection, so they are covered by the Firestore backup.
Outside Firebase
Keep functions/.env, your Apple .p8 key, the Google service-account file, the Android upload keystore and its passwords in a password manager or encrypted storage. They are not in Firebase and cannot be downloaded again from it.