MetroVault is designed as a secure offline Bitcoin signing device. This document provides a detailed explanation of the security architecture, encryption model, and protective measures implemented throughout the application.
MetroVault employs a defense-in-depth strategy with multiple layers of protection:
- Physical Layer: Air-gapped operation (no network connectivity)
- Authentication Layer: Password + optional biometric authentication
- Encryption Layer: Dual-layer encryption (session key + Android Keystore)
- Application Layer: Screenshot protection, clipboard clearing, autofill prevention
- Rate Limiting Layer: Exponential backoff against brute force attacks
- Zero Network Access: The app is designed to operate on a device with airplane mode enabled and all radios disabled
- Keys Never Leave Device: Private keys and mnemonics never touch any network interface
- Memory Wiping: Sensitive keys are wiped from RAM on logout
- Minimal Attack Surface: Focused functionality reduces potential vulnerabilities
When you set up MetroVault, you create a master password. This password:
- Is never stored - only a one-way verifier is stored
- Derives the encryption key - used to encrypt/decrypt wallet data
- Cannot be recovered - there is no "forgot password" functionality by design
Format: salt:verifier:iterations:version (Base64 encoded)
Components:
- Salt: 256-bit random value (cryptographically secure)
- Verifier: 256-bit HKDF-SHA256(masterKey, "password-verification")
- Iterations: PBKDF2 count used for THIS record (600,000 for new records)
- Version: record format version (2 = verifier format)
Domain separation (important): The stored verifier is derived from the PBKDF2 master key through a one-way HKDF step with a dedicated context string. The wallet encryption key is derived from the same master key with a different context string ("wallet-encryption") and exists only in RAM. An attacker who reads the stored verifier therefore cannot compute the wallet encryption key — password verification and data encryption are cryptographically separated.
Legacy records: Versions prior to the verifier format stored the raw PBKDF2
output (which doubled as the master key) in a 3-part salt:hash:iterations
record. Such records are still accepted and are transparently upgraded to the
verifier format on the first successful password check. The master key is
unchanged by the upgrade, so no wallet data needs re-encryption. Legacy records
keep their original iteration count (210,000) until the next password change,
which re-derives everything at the current count.
┌─────────────────────────────────────────────────────────────────┐
│ Password Entry │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Rate Limit Check (LoginAttemptManager) │
│ • Check if currently locked out │
│ • If locked: return error with remaining time │
└─────────────────────────────────────────────────────────────────┘
│ (not locked)
▼
┌─────────────────────────────────────────────────────────────────┐
│ PBKDF2 Key Derivation (intentionally slow) │
│ • Algorithm: PBKDF2-HMAC-SHA256 │
│ • Iterations: from the stored record (600,000 for new) │
│ • Output: 256-bit master key │
│ • Runs ONCE per candidate vault (main, then decoy) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Verifier Derivation + Constant-Time Comparison │
│ • Candidate verifier = HKDF(masterKey, "password-verification")│
│ • Compared with stored verifier via MessageDigest.isEqual() │
│ to prevent timing attacks │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ SUCCESS │ │ FAILURE │
└───────────┘ └───────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Session initialized from │ │ Record Failed Attempt │
│ the already-derived master │ │ Apply exponential backoff │
│ key (no second PBKDF2 run) │ │ (skipped for biometric- │
│ Reset attempt counter │ │ originated attempts) │
│ Upgrade legacy record │ └─────────────────────────────┘
└─────────────────────────────┘
MetroVault uses a dual-layer encryption model to protect sensitive wallet data:
┌─────────────────────────────────────────────────────────────────┐
│ Master Key Derivation │
├─────────────────────────────────────────────────────────────────┤
│ Input: User Password + Stored Salt │
│ Algorithm: PBKDF2-HMAC-SHA256 │
│ Iterations: from stored record (600,000 for new records) │
│ Output: 256-bit Master Key (RAM only, never stored) │
│ Timing: intentionally slow (hundreds of ms) │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────────┐
│ Password Verifier │ │ Wallet Encryption Key │
├─────────────────────────────┤ ├─────────────────────────────────┤
│ HKDF-SHA256 with context │ │ HKDF-SHA256 with context │
│ "password-verification" │ │ "wallet-encryption" │
│ → 256-bit verifier │ │ → 256-bit encryption key │
│ STORED ON DISK │ │ RAM ONLY, wiped on logout │
│ (one-way: cannot yield the │ │ Timing: <1ms │
│ encryption key) │ │ │
└─────────────────────────────┘ └─────────────────────────────────┘
The HKDF (HMAC-based Key Derivation Function) follows RFC 5869:
Extract Phase:
PRK = HMAC-SHA256(salt=zeros[32], IKM=masterKey)
Expand Phase:
OKM = HMAC-SHA256(PRK, info || 0x01)
Where:
- PRK: Pseudo-Random Key (intermediate value)
- IKM: Input Key Material (master key from PBKDF2)
- OKM: Output Key Material (derived key)
- info: Context string — "wallet-encryption" for the data encryption
key (RAM only), "password-verification" for the stored verifier
The two context strings give domain-separated outputs: the stored verifier and the encryption key are independent HKDF outputs of the same master key, so possession of one reveals nothing about the other.
All wallet secrets are encrypted using AES-256-GCM:
┌─────────────────────────────────────────────────────────────────┐
│ AES-256-GCM Encryption │
├─────────────────────────────────────────────────────────────────┤
│ Algorithm: AES-256-GCM (Galois/Counter Mode) │
│ Key Size: 256 bits │
│ IV Size: 96 bits (12 bytes, randomly generated) │
│ Auth Tag: 128 bits │
│ │
│ Encrypted Format: [IV (12 bytes)] + [Ciphertext] + [Auth Tag] │
└─────────────────────────────────────────────────────────────────┘
Security Properties of GCM:
- Confidentiality: Data cannot be read without the key
- Authenticity: Any tampering is detected (via auth tag)
- No Padding Oracle: Block cipher modes like CBC are not used
All app data is stored in EncryptedSharedPreferences, providing:
┌─────────────────────────────────────────────────────────────────┐
│ EncryptedSharedPreferences │
├─────────────────────────────────────────────────────────────────┤
│ Key Encryption: AES-256-SIV (Synthetic IV mode) │
│ Value Encryption: AES-256-GCM │
│ Key Storage: Android Keystore (hardware-backed if avail) │
│ │
│ Benefits: │
│ • Keys stored in secure hardware (TEE/StrongBox) │
│ • Keys never exposed to app layer │
│ • Resistant to device rooting (hardware-backed) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Wallet Secrets │
│ (mnemonic, BIP39 seed) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: Session Key Encryption │
│ │
│ plaintext ──► AES-256-GCM (Session Key) ──► ciphertext │
│ │
│ Protection: Password-derived, wiped on logout │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2: EncryptedSharedPreferences │
│ │
│ ciphertext ──► AES-256-GCM (Keystore Key) ──► stored blob │
│ │
│ Protection: Hardware-backed, survives app restart │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Device Storage │
│ │
│ Even if extracted, data requires: │
│ 1. Keystore key (hardware-protected) │
│ 2. User password (to derive session key) │
└─────────────────────────────────────────────────────────────────┘
Wallet data is split into two categories with different security levels:
┌─────────────────────────────────────────────────────────────────┐
│ Wallet Metadata │
├─────────────────────────────────────────────────────────────────┤
│ Contents: │
│ • Wallet ID (UUID) │
│ • Wallet Name │
│ • Derivation Path (e.g., m/84'/0'/0') │
│ • Master Fingerprint │
│ • Has Passphrase flag │
│ • Creation Timestamp │
│ │
│ Storage: Plain JSON within EncryptedSharedPreferences │
│ Encryption: Single layer (platform only) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Wallet Keys │
├─────────────────────────────────────────────────────────────────┤
│ Contents: │
│ • Key ID (UUID - unique identifier) │
│ • Mnemonic Phrase (12 or 24 words) │
│ • BIP39 Seed (512-bit, hex-encoded) │
│ • Master Fingerprint (8 hex chars) │
│ • User Label (e.g., "Key 1", "Cold Storage") │
│ │
│ ARCHITECTURE: │
│ • Keys are stored SEPARATELY from wallets │
│ • Multiple wallets can reference the same key (via keyId) │
│ • Single-sig wallets have exactly 1 keyId reference │
│ • Multisig wallets have 0-N keyId references (local signers) │
│ • Keys only deleted when no wallet references them │
│ │
│ IMPORTANT: The raw passphrase is NEVER stored to disk. │
│ Only the derived BIP39 seed is saved, which is computed │
│ using PBKDF2 (mnemonic + passphrase). This seed cannot be │
│ reversed to recover the original passphrase. │
│ │
│ Storage: Session-key encrypted within EncryptedSharedPreferences│
│ Encryption: Dual layer (session key + platform) │
└─────────────────────────────────────────────────────────────────┘
Users can choose not to save their passphrase locally. In this case:
┌─────────────────────────────────────────────────────────────────┐
│ Session-Only Passphrase Mode │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Storage Behavior: │
│ • Mnemonic: Always saved (encrypted) │
│ • BIP39 Seed: Base seed (without passphrase) saved to disk │
│ • Passphrase: NEVER stored to disk in any form │
│ │
│ On Wallet Open: │
│ • User prompted to enter passphrase │
│ • Correct BIP39 seed computed in RAM (mnemonic + passphrase) │
│ • Session seed stored in memory, wiped on logout │
│ │
│ Security Benefits: │
│ • Passphrase never touches persistent storage │
│ • Even if device seized, passphrase cannot be extracted │
│ • Protects against malware extracting encrypted storage │
│ • Higher security for high-value wallets │
│ • Passphrase reuse on other services cannot be compromised │
│ │
│ Trade-offs: │
│ • Less convenient (re-entry required each session) │
│ • User MUST remember passphrase (no recovery possible) │
│ │
│ Fingerprint Mismatch Detection: │
│ • Master fingerprint displayed in RED if wrong passphrase │
│ • Stored fingerprint is from the WITH-passphrase derivation │
│ • Allows user to verify correct passphrase was entered │
│ │
└─────────────────────────────────────────────────────────────────┘
When session seeds are stored in RAM, they use a specialized SecureSeedCache instead of plain String objects:
┌─────────────────────────────────────────────────────────────────┐
│ Session Seed Memory Protection │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Problem with Java Strings: │
│ • Strings are IMMUTABLE in the JVM │
│ • String contents cannot be overwritten/zeroed │
│ • Calling clear() on a map only removes the reference │
│ • Actual string data remains in heap until garbage collected │
│ • GC timing is unpredictable - seeds may linger in memory │
│ │
│ Solution - SecureSeedCache: │
│ • Seeds stored as byte arrays (SecureByteArray wrapper) │
│ • Byte arrays CAN be zeroed (filled with 0x00) │
│ • On clear(): all bytes zeroed BEFORE removing references │
│ • On replace: old seed wiped before storing new one │
│ • Deterministic cleanup - no waiting for GC │
│ │
│ Memory Wipe Flow: │
│ │
│ sessionSeeds.clear() │
│ │ │
│ ▼ │
│ For each seed: │
│ 1. Get underlying byte array │
│ 2. Fill with zeros: Arrays.fill(data, 0) │
│ 3. Set reference to null │
│ │ │
│ ▼ │
│ Clear map references │
│ │ │
│ ▼ │
│ Seed data is GONE (not waiting for GC) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BIP39 Seed Storage Model │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Wallet Creation: │
│ │
│ 1. User provides: mnemonic + passphrase │
│ 2. BIP39 seed derived: PBKDF2(mnemonic, passphrase, 2048 iter) │
│ 3. Stored to disk: │
│ • mnemonic (encrypted) │
│ • bip39Seed (512-bit hex, encrypted) ← NOT the passphrase │
│ │
│ Why This is Secure: │
│ • PBKDF2 is one-way function (cannot reverse to passphrase) │
│ • Seed is 512 bits of entropy (infeasible to brute force) │
│ • Passphrase commonly reused - seed is unique to this wallet │
│ │
│ Performance Benefit: │
│ • Wallet loading skips PBKDF2 (seed already derived) │
│ • Faster app responsiveness on wallet open │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Key Lifecycle │
├─────────────────────────────────────────────────────────────────┤
│ │
│ App Start ────► Keys not in memory │
│ │
│ Login ────────► PBKDF2 derives master key (intentionally slow) │
│ └─► HKDF derives wallet key (<1ms) │
│ └─► Keys stored in RAM only │
│ │
│ Operations ───► Use cached keys (instant) │
│ │
│ Logout ───────► Keys wiped from RAM │
│ └─► Arrays overwritten with zeros │
│ └─► References set to null │
│ │
│ App Kill ─────► RAM cleared by OS │
│ │
└─────────────────────────────────────────────────────────────────┘
All session key state is guarded by a lock: a concurrent emergency wipe
(triggered by lifecycle events) can never hand a partially-zeroed key to an
in-flight encryption — an operation either gets a complete copy of the key or
fails cleanly. The same locking applies to the in-memory seed cache
(SecureByteArray).
Changing a vault's password re-encrypts that vault's key material atomically:
- The old vault key is re-derived from the old password + that vault's stored salt — deliberately not taken from the active session, so changing the decoy password from a main-vault session (or vice versa) always uses the correct key
- Every encrypted blob in the vault is decrypted with the old key and re-encrypted with the new key in memory
- The new password record (current iteration count, verifier format) and all re-encrypted blobs are written in a single atomic commit — a crash before the commit leaves the vault fully intact under the old password
- The active session is switched to the new key only if it belongs to the vault being changed
- A new password is rejected if it matches the other vault's password (with a deliberately neutral error message that does not reveal the other vault's existence)
MetroVault supports fingerprint/face unlock as a convenient alternative to password entry, while maintaining strong security guarantees.
┌─────────────────────────────────────────────────────────────────┐
│ Biometric Authentication Flow │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Hardware-Backed Key Generation │
│ │
│ • Key stored in Android Keystore (TEE/StrongBox) │
│ • setUserAuthenticationRequired(true) │
│ • Key can ONLY be used after valid biometric │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Encrypted Password Storage │
│ │
│ On Setup: │
│ 1. Generate biometric-gated AES key in Keystore │
│ 2. Encrypt password with this key │
│ 3. Store encrypted password + IV │
│ │
│ On Login: │
│ 1. Initialize cipher with Keystore key │
│ 2. Authenticate with biometric (unlocks key) │
│ 3. Decrypt password │
│ 4. Use password to derive session key │
└─────────────────────────────────────────────────────────────────┘
| Property | Implementation |
|---|---|
| Algorithm | AES-256-GCM |
| Key Storage | Android Keystore (hardware-backed when available) |
| Authentication | BIOMETRIC_STRONG (Class 3 biometrics only) |
| Key Access | Requires valid biometric on each use; on API 30+ the key is explicitly pinned to auth-per-use with AUTH_BIOMETRIC_STRONG (device credential can never unlock it) |
| Invalidation | Key invalidated if biometrics change (new fingerprint enrolled) |
The biometric-stored password can go stale if the vault password is changed:
- After a password change, the app immediately prompts to re-encrypt the new password under the biometric key. Any outcome other than a successful update — cancellation, error, or store failure — disables biometric unlock and removes the stale ciphertext, rather than leaving an old password armed
- If a biometric unlock ever decrypts a password that no longer matches any vault, biometric unlock is automatically disabled with an explanatory message
- Failed logins originating from biometric-decrypted passwords are not counted toward the rate limiter or the optional data-wipe counter — they are machine-originated, not evidence of brute force
The biometric prompt uses a CryptoObject binding:
// Key can ONLY be used after successful biometric authentication
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))This ensures:
- The decryption cipher is unusable until biometric succeeds
- Malware cannot extract the password without user's biometric
- The key remains hardware-protected at all times
MetroVault implements exponential backoff to prevent brute force attacks:
┌────────────────────────────────────────────────────────────────┐
│ Failed Attempt Delays │
├────────────────────────────────────────────────────────────────┤
│ Attempts 1-2: No delay (2 free retries for typos) │
│ Attempt 3: 30 seconds │
│ Attempt 4: 1 minute │
│ Attempt 5: 5 minutes │
│ Attempt 6: 15 minutes │
│ Attempt 7+: 1 hour each │
│ Attempt 20+: 24-hour lockout per failure │
└────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Brute Force Comparison │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT Rate Limiting: │
│ • ~1,000 attempts/second possible │
│ • 4-digit PIN: cracked in ~10 seconds │
│ • 6-character password: hours to days │
│ │
│ WITH Rate Limiting: │
│ • Maximum ~6 attempts per day │
│ • 4-digit PIN: ~4.5 years to exhaust │
│ • 6-character password: effectively impossible │
│ │
│ Combined with PBKDF2 (600,000 iterations for new records): │
│ • Each attempt costs hundreds of ms of CPU time on-device, │
│ and the same cost applies to offline cracking of the │
│ stored verifier │
│ │
└─────────────────────────────────────────────────────────────────┘
Rate limit state is persisted to disk:
- Survives app restarts and device reboots
- Prevents bypassing via app force-stop
- Stored in separate SharedPreferences file
Because the device is air-gapped, there is no trusted network time and the wall clock can be freely changed in system settings. To blunt the obvious bypass (advancing the clock to skip a lockout), every lockout deadline is recorded against both clocks:
- Wall clock (
currentTimeMillis) — survives reboots - Monotonic clock (
elapsedRealtime) — cannot be adjusted by the user
The stricter of the two is enforced. Advancing the wall clock therefore does not shorten a lockout; the only way to reset the monotonic deadline is a full reboot, after which the wall-clock deadline still applies. This raises the cost of clock-manipulation attacks without requiring secure time.
Users can enable "Wipe Data on Failed Login" in Security settings. When enabled, the 4th consecutive failed password attempt permanently destroys all app data (see What a Wipe Destroys). Failed attempts originating from biometric unlock never count toward this threshold. This is an explicit opt-in, protected by a confirmation dialog, intended for high-threat models where seizure of the device is the primary concern.
A duress password is an optional third password, set from the main vault's Security settings, that destroys all app data the moment it is entered on the unlock screen. The same effect can be bound to a fingerprint: the biometric target selector offers Duress Wipe alongside Main and Decoy, and a successful biometric unlock then wipes instead of opening a vault. Both complement the decoy password for coercion scenarios where showing a real wallet is unacceptable.
Behaviour:
- Looks like a normal unlock: after the wipe the app opens a duress
session, a fake session in decoy mode holding one freshly generated
12-word Native SegWit wallet ("My Wallet"), so the attacker sees a working
wallet app rather than an empty or wiped one. Everything in that session,
including anything the attacker creates, lives only in RAM:
SecureStorageroutes both vault files to an in-memory store for as long as the session lasts. The first lock or backgrounding ends the session, and with no main password left the app then shows first-time setup, like a fresh install. - In-session prompts: seed export and other password prompts accept the duress password. Its verifier (salt plus one-way HKDF output, never the password itself) is the only thing carried across the wipe, copied into the RAM store and discarded with the session. After a biometric duress unlock with no duress password set, any non-empty password is accepted, since there is nothing the user could know. An in-session password change replaces the RAM verifier only.
- Always available: the duress password is honoured even while the rate limiter has locked the screen, and independently of the "Wipe Data on Failed Login" toggle. A person under coercion must never be told to wait.
- Unlock screen only: every other password prompt (seed export, biometric setup, password change) treats the duress password as incorrect. A biometric-stored main or decoy password is never checked against it; the duress fingerprint slot holds a random token, and a successful decrypt of that token is the trigger.
- Never collides: it must differ from both vault passwords at the time it is set, and changing a vault password to the duress value is rejected with the same neutral message used for main/decoy collisions, so decoy mode never learns that a duress password exists. A collision would otherwise wipe the device on a legitimate unlock.
- Storage: only a verifier is stored (same PBKDF2 → HKDF record format as the vault passwords, in the main vault file). Nothing is encrypted under it, so changing it needs no old-password step and it is removable from settings.
- Cost: it is checked last, after both vault records fail, so a correct login never pays for the extra derivation. Wrong passwords cost one more PBKDF2 run while a duress password is set.
- Safety net: whenever the app starts, or a session ends, with no main password but vault, settings, biometric or lockout data still on disk (a duress session's settings writes, or a wipe cut short by a process kill), it runs the full wipe before showing setup. At startup this happens off the main thread while the splash screen is up; on session end it happens before the setup screen is shown. A genuinely fresh install has no such residue and is untouched.
Every wipe trigger (failed logins, duress password, duress fingerprint, the startup safety net) calls the same routine, which clears and then deletes:
- Main vault data (wallets, keys, password record, duress record)
- Decoy vault data (wallets, keys, password record)
- Biometric ciphertexts and their Android Keystore wrapping keys
- User preferences, including biometric and wipe settings
- Rate-limiter state and the in-memory session key
- Every file under the app's
shared_prefs/,files/,cache/andno_backup/directories, including SharedPreferences'.bakbackups
Each store is first cleared through its live SharedPreferences instance with a
synchronous commit, so memory and disk agree before the files are removed.
The files are deleted directly rather than through
Context.deleteSharedPreferences, because that call evicts Android's
process-wide cached instance and any storage object created afterwards would
hold a second, independent in-memory copy of the same file whose writes
silently clobber each other. Keeping the cached instances alive preserves one
authoritative copy per file; a later write simply recreates the file from it.
MetroVault supports a decoy password feature for plausible deniability under duress.
┌─────────────────────────────────────────────────────────────────┐
│ Dual Password System │
├─────────────────────────────────────────────────────────────────┤
│ │
│ MAIN PASSWORD DECOY PASSWORD │
│ ═══════════════ ═══════════════ │
│ • Real wallets • Decoy wallets │
│ • Real funds • Small/fake amounts │
│ • Full access • Appears identical │
│ │
│ Same app, same UI - only the password determines which │
│ wallet set is loaded │
│ │
└─────────────────────────────────────────────────────────────────┘
- Separate Storage: Main and decoy data stored in different EncryptedSharedPreferences files
- Independent Keys: Each password derives its own session key
- No Cross-References: Decoy mode has no access to main wallet data
- Identical UX: App behavior is indistinguishable between modes
- Collision Prevention: The two passwords can never be set (or changed) to the same value; the rejection message is deliberately neutral so a decoy-mode user never learns that another vault exists
- Isolated Password Changes: Changing either vault's password derives keys from that vault's own stored record and only re-encrypts that vault — the operation is independent of which vault the current session belongs to
If forced to unlock the device:
- Enter decoy password
- App shows decoy wallets with minimal funds
- Attacker sees a functional wallet app
- Real funds remain hidden and inaccessible
If even the decoy vault must not be shown, enter the duress password (or use the duress fingerprint) instead: the app wipes itself and opens a throwaway wallet in its place.
// Applied to all activities
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)This prevents:
- Screenshots
- Screen recording
- Display on non-secure external displays
- App preview in recent apps (shows blank)
All sensitive input fields use a secure text field that:
// Prevents keyboard/system autofill suggestions
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
// Disables autocorrect (prevents learning from sensitive input)
keyboardOptions = keyboardOptions.copy(autoCorrectEnabled = false)This prevents:
- Password managers from capturing mnemonic phrases
- Keyboard learning from sensitive input
- Autofill popups revealing sensitive fields exist
When copying sensitive data (addresses, XPUBs):
// Auto-clear after 20 seconds
copyToClipboardWithAutoClear(context, label, sensitiveText, delayMs = 20_000)Behavior:
- Data copied to clipboard
- Background timer starts
- After delay, clipboard is cleared (only if unchanged)
- Prevents stale sensitive data in clipboard
┌─────────────────────────────────────────────────────────────────┐
│ Air-Gapped Device Setup │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ✓ Airplane mode: ENABLED │
│ ✓ WiFi: DISABLED │
│ ✓ Bluetooth: DISABLED │
│ ✓ NFC: DISABLED │
│ ✓ Mobile data: DISABLED / No SIM card │
│ ✓ Location services: DISABLED │
│ ✓ USB debugging: DISABLED │
│ │
│ Recommended: Factory reset before installing MetroVault │
│ Recommended: Remove Google account / use offline setup │
│ │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────┐ ┌───────────────────┐
│ ONLINE DEVICE │ │ OFFLINE DEVICE │
│ (Watch-Only) │ │ (MetroVault) │
└─────────┬─────────┘ └─────────┬─────────┘
│ │
│ 1. Create unsigned PSBT │
│ 2. Display as QR code │
├───────────────────────────────────────►│
│ (Camera scan - no network) │
│ │
│ 3. Verify transaction
│ 4. Sign with private key
│ 5. Display signed PSBT
│ (Camera scan - no network) │
│◄───────────────────────────────────────┤
│ │
│ 6. Broadcast to network │
│ │
| Purpose | Algorithm | Standard |
|---|---|---|
| Password Hashing | PBKDF2-HMAC-SHA256 | NIST SP 800-132 |
| Key Derivation | HKDF-SHA256 | RFC 5869 |
| Data Encryption | AES-256-GCM | NIST SP 800-38D |
| Biometric Key | AES-256-GCM (Keystore) | Android Keystore API |
| Key Encryption (ESP) | AES-256-SIV | RFC 5297 |
| Random Generation | SecureRandom | FIPS 140-2 |
All elliptic-curve operations (signing, verification, ECDH) use
bitcoin-core/secp256k1 — the reference
implementation used by Bitcoin Core — via ACINQ's
secp256k1-kmp JNI bindings. Release APKs build the
native library from source (see FDROID_BUILD.md) with compact
precomputed multiplication tables (ECMULT_WINDOW_SIZE=4, ECMULT_GEN_KB=2) — a supported,
upstream-CI-tested build configuration that trades a small constant-factor slowdown for ~4 MB
of APK size. Table size affects only speed: signatures are byte-identical (deterministic
nonces per RFC 6979 / BIP-340) and signing remains constant-time in every configuration.
| Requirement | MetroVault Implementation | Status |
|---|---|---|
| PBKDF2-HMAC-SHA256 iterations ≥ 600,000 | 600,000 for new records (legacy records upgraded at next password change) | ✓ |
| Salt ≥ 128 bits | 256-bit salt | ✓ |
| Don't store password-equivalent material | One-way HKDF verifier stored; master key never persisted | ✓ |
| Use authenticated encryption | AES-GCM with 128-bit tag | ✓ |
| Rate limit login attempts | Exponential backoff + 24h lockout, dual-clock enforcement | ✓ |
| Secure key storage | Android Keystore (hardware-backed) | ✓ |
If you discover a security vulnerability in MetroVault, please report it responsibly:
- Do not open a public GitHub issue
- Email the maintainer directly (check repository for contact)
- Provide detailed reproduction steps
- Allow reasonable time for a fix before disclosure
MetroVault implements multiple layers of security to protect your Bitcoin:
| Layer | Protection |
|---|---|
| Air-Gap | No network = no remote attack surface |
| Password | PBKDF2 (600k iterations for new records) prevents offline brute force |
| Verifier Storage | Only a one-way verifier is stored — it cannot yield the encryption key |
| Encryption | Dual-layer AES-256-GCM protects data at rest |
| Biometrics | Hardware-backed keys with crypto binding; stale credentials auto-disabled |
| Rate Limiting | Exponential backoff on both wall and monotonic clocks |
| Optional Wipe | Opt-in destruction of all data after 4 consecutive failed logins |
| Duress Unlock | Optional password or fingerprint that wipes all data at unlock and opens a throwaway wallet in its place |
| Plausible Deniability | Decoy password protects against coercion |
| UI Hardening | Screenshot/autofill/clipboard protection |
The combination of these measures means that even if an attacker has physical access to your device, they cannot access your Bitcoin without your password (and defeating rate limiting, PBKDF2 costs, and hardware-backed encryption).