Key storage

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.

In one sentenceRecovery phrases and private keys never enter the app’s JavaScript. They live in the native signer, encrypted on the phone under a key that only the owner’s Face ID, fingerprint, device code — or the wallet PIN — can release, and they are used only inside an isolated engine running a pinned, reviewed signing core.

At a glance

Everything secret is the signer’s. The app only ever sees what cannot spend.

WhatWhere it livesWho can read it
Recovery phrases, private keysSealed 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 PINThe OS after Face ID / fingerprint / code; the signer after a correct PIN
Wallet PINNowhere — only a key derived from it, mixed with a hardware secretNo one
Failed-PIN counterKeychain / private file, HMAC-protectedThe native signer
xpubs, addresses, estate listSigner metadata + app storageAnyone with the phone unlocked — they cannot spend
Casa names, colors, currenciesApp storage (AsyncStorage)The app

Three layers

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.

App — React Native / Hermes

Not trusted with keys
  • Shows balances, builds unsigned transactions.
  • Derives every address from the xpubs the signer publishes.
sign(request)signature only

Native signer — Swift / Kotlin

Trusted
  • Custody: the DEK, the sealed records, the PIN wrap, the attempt counter.
  • Authentication: Face ID / fingerprint / device code, or its own PIN pad.
  • Its own confirmation screens — screenshots and recording blocked.
fresh context per operation

Signer core — signer-core.js

Isolated engine
  • iOS: a dedicated JavaScriptCore VM · Android: JavaScriptSandbox (QuickJS fallback).
  • noble / scure 2.2.0 only — no network, no timers, no require.
  • Its SHA-256 is compiled into the native binary: any other bytes are refused.

Custody model

One random data key seals every estate. That key is wrapped twice, so losing one way in never loses the wallet.

1 · One data key, sealed records

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.

2 · The DEK is wrapped twice

Every day

dek.os — the OS lock

  • iOS: keychain item with access control .userPresence, accessible WhenPasscodeSetThisDeviceOnly. iOS deletes it if the passcode is removed.
  • Android: the DEK under an AndroidKeyStore RSA-2048 key (OAEP / SHA-256) that requires authentication — strong biometric or device credential. StrongBox when available. Not invalidated by adding a fingerprint; invalidated if the screen lock is removed. API 30+ binds each use to the prompt; API 26–29 allow 10 s after authentication.
Fallback

dek.pin — PIN + hardware

  • Used when the phone has no screen lock, or lost its OS wrap.
  • iOS: the hardware part is an ECDH secret from a Secure Enclave key that never leaves the chip (a keychain pepper without an enclave).
  • Android: the PIN-wrapped DEK is sealed again by a non-exportable Keystore AES key.
  • A copied file cannot be brute-forced off the phone: the hardware part is missing.
KEK = HKDF-SHA256( PBKDF2-SHA256(PIN, salt, 600 000) ‖ hw,
                   info = "casa-signer:v1:pin-kek" )
Why two wraps?With only the OS wrap, removing the screen lock would destroy the wallet. With the PIN wrap as a fallback, the wallet keeps opening with its PIN, and Settings › Protect with the screen lock again rewraps the DEK once a lock is back.

3 · PIN attempts

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.

4 · The commit point

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.

Where the bytes are

iOSAndroid
Records, wraps, counter, metaKeychain, class Internet password, server casa-signer.local, ThisDeviceOnlyFiles in noBackupFilesDir/casa-signer/
Hardware keysSecure Enclave (PIN wrap), keychain access control (OS wrap)AndroidKeyStore, StrongBox when present
Backups, other devicesNever (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.

What the app holds

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.

Account xpubs

  • Bitcoin legacy m/0'/0
  • Bitcoin segwit m/84'/0'/0'/0
  • Bitcoin taproot m/86'/0'/0'/0
  • EVM m/44'/60'/0'/0
  • Every casa is a non-hardened child: …/hd.

Also

  • The public keys of imported keys.
  • A fingerprint, to refuse a phrase or key added twice.
  • A guard test (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.

Signing, end to end

  1. The app builds an unsigned requestA PSBT, an EVM transaction with nonce, gas and fees already filled, a message, typed data.
  2. The core prepares what will be shownIn a fresh context, 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.
  3. The owner confirms and authenticatesOn the signer’s own screen, with a long press, then the OS lock or the PIN.
  4. The core signs — or refusesThe DEK is unwrapped and the record decrypted. sign decodes the request again and refuses (E_DIGEST_MISMATCH) if what it would sign differs from what was shown.
  5. Only the result goes backThe signature or signed transaction. Secrets and the DEK are discarded with the context.

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.

Showing secrets

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.

Migrations

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.

Build ≤ 44

JS vault (expo-secure-store)

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.

Titanium ≤ 1.2.0

The original app

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.

What it protects against

ThreatOutcome
A compromised npm dependency in the appCannot read any secret; can only ask for signatures, each shown and confirmed natively.Covered
A dApp asking to sign something misleadingThe signer shows what the bytes actually do; unlimited approvals, permits and partial signatures are flagged.Covered
A stolen locked phoneRecords need the OS lock, or the PIN plus the phone’s hardware; PIN guessing is throttled.Covered
A backup or copied filesNothing is in backups; copied files lack the hardware keys.Covered
Removing the screen lockThe wallet keeps opening with its PIN.Covered
Someone who knows the device code, holding the unlocked phoneCan 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 phraseFunds are lost: the keys exist only on that phone.Not covered

For developers

Sources

  • Core: signer-core/src/ → npm run signer:build; signer:check runs before the tests and fails if the bundle drifts.
  • Custody: modules/casa-signer/ios/Custody.swift, …/android/…/Custody.kt.

Testing

  • On a phone: Dev Tools › Signer self-test.
  • npm run signer:native-test runs custody and the core on macOS.
  • Jest uses modules/casa-signer/mock.ts — the real bundled core in a Node VM, custody in memory. Never part of the app.