Key storage and authentication
BitTool Signer stores each wallet's recovery phrase (and BIP39 passphrase, if one is set) in its own encrypted file. Two independent keys are needed to open it: one derived from the wallet password, one kept by iOS on that device only.
This page describes the iPhone app. BitTool Signer for Android uses the same two layers with Android's own key storage; see On Android.
The vault
Each wallet is a file Vaults/<UUID>.bsvault in the app's private Library/Application Support/BitToolSigner/ directory. Creating it works like this:
- Generate a fresh 32-byte salt with
SecRandomCopyBytes. - Derive a 256-bit password key: PBKDF2-HMAC-SHA256, 600,000 iterations (CommonCrypto).
- Encrypt the secret (phrase + passphrase, as JSON) with AES-256-GCM under the password key: fresh 96-bit nonce, 128-bit tag.
- Encrypt that ciphertext again with AES-256-GCM under a random 256-bit device key.
- Store the device key in the Keychain with
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, iCloud sync off. If the phone has no passcode, wallet creation fails; there is no weaker fallback. - Write the file atomically with complete file protection, owner-only permissions and backup exclusion.
vault file
├── header (not secret, but authenticated)
│ format version 2 · KDF parameters · salt · wallet ID · name · created at
└── AES-GCM(device key,
AES-GCM(PBKDF2-SHA256(password, salt, 600 000),
{ mnemonic, passphrase }))
Both AES-GCM layers authenticate the stored header bytes as associated data, prefixed with a domain label (com.bittool.signer.vault.v2/<layer>). Editing, re-encoding or reordering any header byte makes decryption fail.
The wallet password encrypts the local vault. It is not the BIP39 passphrase and has no effect on addresses. Rules: 8–64 characters, at least three of uppercase, lowercase, digits and symbols, no control characters; used exactly as typed (no trimming or normalization).
The device key never leaves the phone and is excluded from backups, so a copied vault file cannot be opened elsewhere. Losing the phone, deleting the app or removing the passcode can make the vault unreadable. The written recovery phrase (and passphrase) is the only backup.
Why PBKDF2 and not Argon2id?
Argon2id is memory-hard and would be a stronger password KDF. This version uses CommonCrypto's PBKDF2 (at the current OWASP recommendation of 600,000 iterations for HMAC-SHA256) to avoid adding a native cryptographic dependency. The KDF parameters live in the versioned header, so a future migration does not need a new file layout.
The Opus 5 security review rated this choice Informational (BTS-OP5-03) rather than a weakness: because of the second layer under the device key, someone who copies only the vault file cannot begin a password search at all. It suggests Argon2id for a future vault format, as defence in depth in case the device key is also compromised.
Password attempts
Failed password attempts are counted per wallet in an encrypted, authenticated .attempts file. The count is saved before the slow key derivation, so killing the app does not reset it.
| Failed attempts | Wait before the next try |
|---|---|
| 1–4 | none |
| 5 | 2 s |
| 6 | 4 s |
| n | min(2n−4 s, 5 min) |
A correct password resets the counter. The app never deletes a wallet because of wrong passwords. This is an application control, not a hardware counter: someone who can roll back the app's files can reset it. The cryptographic protection is the KDF.
Public data without the password
Addresses, pairing QR codes and activity are available without the password, because they need only public keys. When a wallet is created or imported, the engine derives a public index once and saves it, outside the vault:
- six Bitcoin account nodes (BIP84, BIP86, BIP49, BIP44, and BIP48 native and nested multisig)
- the EVM and TRON account nodes, plus TRON addresses 0–99
- the three Solana public keys (see Derivation paths)
The public index file carries an HMAC-SHA256 under a separate random Keychain key. If it fails verification (someone edited it, or it belongs to another wallet), the app treats it as missing and asks for the password once to rebuild it. An attacker who can write to the app's files therefore cannot swap in their own addresses.
Authorization
- There is no "unlocked" state and no global session. Every signing task asks for that wallet's password, and so do showing the recovery phrase, changing the password and deleting the wallet.
- App Lock (optional, Face ID) protects only access to the app. It never authorizes a signature. With App Lock on, the app locks on cold launch and after 120 seconds in the background, timed with a clock that keeps counting while the phone sleeps.
- When the app goes to the background, it drops any entered password, risk acknowledgement and displayed recovery phrase immediately. A privacy cover hides the screen in the app switcher and during screen recording.
Memory hygiene
The BIP39 seed is computed in native code and the temporary buffers are overwritten with memset_s. Each operation that touches a secret or untrusted input runs in a fresh JavaScriptCore context that is discarded afterwards. This shortens the lifetime of secrets in memory, but neither Swift nor JavaScript strings can be guaranteed to be wiped, so the app does not claim complete zeroization.
On Android
The vault has the same two layers and the same authenticated header. What differs is where the device key lives and how the platform protects the app.
- Password layer. PBKDF2-HMAC-SHA256 with 600,000 iterations, computed in native code (with the ARMv8 SHA-256 instructions when the processor has them), then AES-256-GCM with a fresh 12-byte nonce and a new 32-byte salt for every encryption.
- Device layer. A per-wallet AES-GCM key generated inside the Android Keystore that can never be exported, in StrongBox when the phone has one. The vault requires a screen lock.
- Files. In the app's private storage, written atomically, and excluded from cloud backups and device-to-device transfer.
- Password attempts. Counted per wallet before the slow derivation, as on iPhone; a damaged counter refuses every attempt.
- Authorization. Every signature needs the wallet password. App Lock (optional, fingerprint or face) never authorizes one. Every window is kept out of screenshots, screen recordings and the Recents screen, other apps' overlays are hidden on Android 12 and later, and the Sign button and the risk acknowledgement refuse taps while another window covers them.
- Memory. Secrets are held in containers that are closed and wiped after use, including when an operation is cancelled, and every operation that touches a secret runs in a fresh QuickJS runtime. The recovery phrase is typed on the app's own keyboard, which no input method sees. The JVM cannot guarantee that every copy is wiped, so here too the app does not claim complete zeroization.
The Opus 5.5 review lists two Low findings about this layer: the key that authenticates public wallet data can be regenerated after a Keystore error on Android 8–11 (BTS-13), and two auxiliary keys do not check for a screen lock (BTS-14). See Security reviews.