Where Casa Tookan keeps recovery phrases and private keys, who can unlock them, and how they are used to sign — from version 2.1.0.
Everything secret is the signer’s. The app only ever sees what cannot spend.
| What | Where it lives | Who can read it |
|---|---|---|
| Recovery phrases, private keys | Sealed estate records (keychain / app-private files) | The native signer, after the owner authenticates |
| The key that seals them (DEK) | Wrapped twice: by the OS lock, and by the wallet PIN | The OS after Face ID / fingerprint / code; the signer after a correct PIN |
| Wallet PIN | Nowhere — only a key derived from it, mixed with a hardware secret | No one |
| Failed-PIN counter | Keychain / private file, HMAC-protected | The native signer |
| xpubs, addresses, estate list | Signer metadata + app storage | Anyone with the phone unlocked — they cannot spend |
| Casa names, colors, currencies | App storage (AsyncStorage) | The app |
The app can ask; only the signer can sign — and only after the owner saw, on the signer’s own screen, what the core decoded from the exact bytes being signed.
signer-core.jsrequire.One random data key seals every estate. That key is wrapped twice, so losing one way in never loses the wallet.
On setup the signer draws a random 32-byte data encryption key (DEK). Each estate — a recovery phrase, or the imported keys — becomes a record sealed with AES-256-GCM under the DEK. The authenticated data binds the record to its estate, so a record moved to another estate id no longer decrypts.
record = AES-256-GCM( DEK, iv, recordJSON,
aad = "casa-signer:v1:estate:" + estateId )
A record holds the phrase and its precomputed seeds (BIP-39 stretching is too slow in a JIT-less engine), or the imported keys.
dek.os — the OS lock.userPresence, accessible WhenPasscodeSetThisDeviceOnly. iOS deletes it if the passcode is removed.dek.pin — PIN + hardwareKEK = HKDF-SHA256( PBKDF2-SHA256(PIN, salt, 600 000) ‖ hw,
info = "casa-signer:v1:pin-kek" )
The PIN is never stored. Wrong attempts are counted natively: the first 5 are free, then each failure locks the PIN for 30 s, doubling up to 1 h. The counter is HMAC-protected with a device-only key, so neither the app’s JavaScript nor a restored backup can reset it.
Every setup writes the sealed records, then both wraps, reads everything back and compares it byte for byte — and only then writes meta, the public description of the wallet. No meta means nothing here: leftovers of an interrupted setup are wiped on the next launch.
| iOS | Android | |
|---|---|---|
Records, wraps, counter, meta | Keychain, class Internet password, server casa-signer.local, ThisDeviceOnly | Files in noBackupFilesDir/casa-signer/ |
| Hardware keys | Secure Enclave (PIN wrap), keychain access control (OS wrap) | AndroidKeyStore, StrongBox when present |
| Backups, other devices | Never (ThisDeviceOnly) | Never (no-backup directory, hardware-bound keys) |
The app’s JavaScript has no keychain or keystore module at all. On iOS the signer’s items use a keychain class that generic-password stores — the kind React Native modules use — cannot address.
Per estate, the signer publishes public data only. From it the app derives every address (src/signer/publicKeys.ts); it can show balances and build transactions, never spend.
m/0'/0m/84'/0'/0'/0m/86'/0'/0'/0m/44'/60'/0'/0…/hd.forbidden.test.ts) fails the build if app code imports BIP-39, derives from a seed, touches a private key, signs a transaction or opens a secret store.prepare decodes the exact bytes: amounts, recipients, change, fee, the Counterparty message, token approvals, the signing address. App-supplied context — site name, token symbol, casa name — is labelled unverified.sign decodes the request again and refuses (E_DIGEST_MISMATCH) if what it would sign differs from what was shown.One screen and one authentication cover a batch — several PSBTs, a multi-chain login. One request at a time, at most three in ten seconds.
Settings › Reveal asks the signer to authenticate the owner and show the phrase or a casa’s keys on its own screen: screenshots and screen recording are blocked, and it hides itself after 60 s. The secret is never returned to the app.
Both run natively: the phrase never passes through the app’s JavaScript. A migration interrupted before its commit simply starts again; if the old PIN is forgotten, the user erases that wallet and restores from the recovery phrase.
The signer reads the old vault, asks the old PIN on its own pad (keeping the old attempt count), decrypts the primary and extra estates, seals them under a new DEK, verifies, commits — then deletes every old entry.
The signer reads the old keychain / keystore record, checks the old 4-digit PIN (throttled), asks a new PIN, seals, verifies, commits and deletes the old record. Casa names and currency are read separately: they are not secret.
| Threat | Outcome | |
|---|---|---|
| A compromised npm dependency in the app | Cannot read any secret; can only ask for signatures, each shown and confirmed natively. | Covered |
| A dApp asking to sign something misleading | The signer shows what the bytes actually do; unlimited approvals, permits and partial signatures are flagged. | Covered |
| A stolen locked phone | Records need the OS lock, or the PIN plus the phone’s hardware; PIN guessing is throttled. | Covered |
| A backup or copied files | Nothing is in backups; copied files lack the hardware keys. | Covered |
| Removing the screen lock | The wallet keeps opening with its PIN. | Covered |
| Someone who knows the device code, holding the unlocked phone | Can sign — the device code is the authentication, as for Apple Pay. | By design |
| A compromised OS (jailbreak, root) | Out of scope. | Not covered |
| Losing the phone without the recovery phrase | Funds are lost: the keys exist only on that phone. | Not covered |
signer-core/src/ → npm run signer:build; signer:check runs before the tests and fails if the bundle drifts.modules/casa-signer/ios/Custody.swift, …/android/…/Custody.kt.npm run signer:native-test runs custody and the core on macOS.modules/casa-signer/mock.ts — the real bundled core in a Node VM, custody in memory. Never part of the app.