Technical Security Overview
A deep dive into Cryptograph's security architecture
Architecture
Cryptograph is built on a fundamental principle: private spending keys never leave the Apple Watch. The iPhone acts as a display and network proxy; all signing operations occur on the watch.
┌─────────────────┐ ┌─────────────────┐ │ iPhone App │◄────────►│ Apple Watch │ │ │ WCSession│ │ │ • Display UI │ │ • Key storage │ │ • WalletConnect│ │ • Signing │ │ • Network/API │ │ • Mnemonic │ │ • QR scanning │ │ • PIN decrypt │ └─────────────────┘ └─────────────────┘
The iPhone never sees private spending keys or mnemonics. The mnemonic is generated on the watch, stored in the watch Keychain (encrypted by the Secure Enclave), and signing requests are sent to the watch for approval.
For Zcash shielded transactions, the phone holds a Unified Full Viewing Key (UFVK) that allows it to read balances and transaction history. See Zcash Shielded Transactions for details.
Supported Chains
Bitcoin, Ethereum, Base, Solana, Zcash (including shielded transactions via Orchard and Sapling), Litecoin, Dogecoin, XRP, Tron, and EVM-compatible networks (Arbitrum, Optimism, Polygon, Avalanche, BSC).
Key Management
Key Derivation Paths
Standard BIP-32/BIP-44 derivation paths are used for each chain:
| Chain | Derivation Path | Standard |
|---|---|---|
| Ethereum / EVM chains | m/44'/60'/0'/0/0 |
BIP-44 (shared by Base, Arbitrum, Optimism, Polygon, Avalanche, BSC) |
| Bitcoin | m/84'/0'/0'/0/0 |
BIP-84 (native SegWit) |
| Litecoin | m/84'/2'/0'/0/0 |
BIP-84 (native SegWit) |
| Dogecoin | m/44'/3'/0'/0/0 |
BIP-44 |
| Solana | m/44'/501'/0'/0/0 |
Phantom-compatible |
| XRP | m/44'/144'/0'/0/0 |
BIP-44 |
| Tron | m/44'/195'/0'/0/0 |
BIP-44 |
| Zcash (transparent) | m/44'/133'/0'/0/0 |
BIP-44 |
| Zcash (shielded) | ZIP-32 UFVK | Orchard + Sapling |
Storage
The mnemonic is stored in the watchOS Keychain with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. This means:
- Keys are only accessible when the watch is unlocked and a passcode is set
- Keys are destroyed if the passcode is removed
- Keys are NOT included in iCloud Keychain sync
- Keys are NOT included in any backup (iTunes, iCloud, or encrypted)
- Keys are wiped if the device is restored from backup
Key Lifecycle
Derived private keys are never cached. Each signing operation follows this flow:
- Retrieve encrypted mnemonic from Keychain
- Decrypt using Secure Enclave key (see below)
- Derive the chain-specific private key
- Sign the transaction
- Zero all key material before deallocation
Only addresses are cached. The mnemonic and derived keys exist in memory only for the duration of the signing operation.
Secure Enclave Encryption
While the mnemonic is protected by Keychain access controls, we add an additional layer: encryption with a key that lives in the Secure Enclave and can never be exported.
Why ECDH?
The Secure Enclave only supports P-256 (not secp256k1 or Ed25519), so we use the SE for encryption at rest rather than direct signing. This provides hardware-backed protection for the mnemonic.
Storage Flow
STORE MNEMONIC:
1. Generate ephemeral P-256 key pair
2. Load SE P-256 key (created on first use, never exportable)
3. ECDH: ephemeral_private + SE_public → shared_secret
4. HKDF(shared_secret) → symmetric_key
5. ChaCha20-Poly1305(mnemonic, symmetric_key) → ciphertext
6. Store: version || ephemeral_public || nonce || ciphertext || tag
RETRIEVE MNEMONIC:
1. Parse stored blob
2. ECDH: SE_private + ephemeral_public → shared_secret
(SE encryption key never leaves enclave)
3. HKDF(shared_secret) → symmetric_key
4. Decrypt ciphertext → mnemonic
5. Return mnemonic in secure buffer (zeros on dealloc)
Security Properties
| Property | Implementation |
|---|---|
| SE key isolation | P-256 key in Secure Enclave via SecKeyCreateRandomKey with kSecAttrTokenIDSecureEnclave — never exportable |
| Key access control | kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + .privateKeyUsage |
| Mnemonic zeroing | Secure buffer with explicit zeroing on deallocation |
Threat Model Impact
| Attack Vector | Without SE Encryption | With SE Encryption |
|---|---|---|
| Keychain extraction (device unlocked) | Plaintext mnemonic | Encrypted blob, requires SE key |
| Keychain extraction (device locked) | Blocked by access control | Blocked by access control |
| Memory dump during signing | Mnemonic in heap | Secure buffer zeros on dealloc |
Recovery Encryption
Cryptograph offers two encrypted backup methods: a printed Recovery Sheet (encrypted QR code) and Photo Backup (encrypted data steganographically hidden in ordinary photos). In both cases, the phone never sees the mnemonic — it only handles opaque ciphertext.
BACKUP: Watch: mnemonic → encrypt(PIN or passphrase, PBKDF2 + ChaCha20-Poly1305) → ciphertext Watch → Phone: ciphertext only Phone: prints QR (Recovery Sheet) or embeds in photos (Photo Backup) RECOVERY: Phone: scans QR or extracts from photos → forwards ciphertext to watch (never decrypts) Watch: prompts for PIN/passphrase → decrypts → validates BIP-39 → imports Watch: shows fingerprint for user verification (Recovery Sheet)
Cryptographic Stack
| Component | Algorithm |
|---|---|
| KDF | PBKDF2-HMAC-SHA256 (1,000,000 iterations) |
| AEAD | ChaCha20-Poly1305 (via CryptoKit) |
| Payload encoding | CBOR, base64url, CGREC1: prefix |
| Salt | 16 random bytes per encryption |
Authentication Modes
Two credential modes are supported for recovery backup encryption:
- PIN (numeric): 6–10 digits. Entered on the watch via a numeric keypad. Validated against sequential and repeated-digit patterns.
- Passphrase: Free-form text (minimum 8 characters, at least 6 unique characters after case-folding). Passphrases are normalized to lowercase before both validation and KDF to eliminate case-mismatch errors during recovery on watch.
Both modes use the same underlying PBKDF2 + ChaCha20-Poly1305 stack. The choice affects only the entropy of the input secret.
Security Features
- Mnemonic never transmitted to phone (encrypt on watch, decrypt on watch)
- High iteration count mitigates offline brute-force
- Rate limiting on PIN/passphrase attempts (exponential backoff)
- Fingerprint verification after decrypt (anti-tampering)
- Weak credential rejection (sequential/repeated PINs; passphrases with low entropy)
- Iteration count stored in payload for future upgrades
KDF Hardening
We benchmarked offline cracking cost to validate the iteration count.
RTX 4090 PBKDF2-HMAC-SHA256 performance: ~8.87 MH/s at 1000 iterations, which translates to ~8,870 attempts/second at 1M iterations.
| Credential | Combinations | Crack Time (1x RTX 4090) |
|---|---|---|
| 6-digit PIN | 106 | ~2 minutes |
| 8-digit PIN | 108 | ~3 hours |
| 10-digit PIN | 1010 | ~13 days |
| 8-char passphrase (random lowercase) | 268 | ~9 months |
That said, we believe both recovery methods are more threat-resistant than industry alternatives. A plaintext seed phrase grants immediate access to funds. Some wallets store backups in iCloud with names like "WALLET BACKUP — DO NOT DELETE," which is directionally wrong: it tells an attacker exactly what to target.
A printed Recovery Sheet is encrypted — an attacker who finds it must still crack the PIN or passphrase. Photo Backup goes further: the encrypted data is steganographically hidden inside ordinary photos. An attacker with a compromised iCloud account would need to:
- Download every photo in the library
- Run steganalysis tools to identify which images contain embedded data
- Crack the PBKDF2 + ChaCha20-Poly1305 encryption — months or longer with a well-chosen passphrase
The encryption is the security boundary, not the steganography. But steganography means the backup doesn't advertise itself. Your photos look like photos.
Transaction Verification
A critical security property: the watch displays exactly what it signs. The phone cannot manipulate what appears on the watch screen.
Canonical Payload Parsing
The watch decodes the signing payload directly. All display data, spend limit values, and signing inputs are derived from the parsed payload — phone-supplied summaries are never trusted.
| Method | Display Source | Signing Source |
|---|---|---|
eth_sendTransaction / eth_signTransaction |
Decoded from protobuf | Same payload |
eth_signTypedData_v4 |
Parsed from JSON in payload | Same payload |
personal_sign |
Raw bytes from payload | Same payload |
eth_sign |
Raw 32-byte hash from payload | Same payload |
solana_signTransaction / solana_signAndSendTransaction |
Decoded via transaction parser | Same payload |
solana_signMessage |
Raw bytes from payload | Same payload |
xrp_sendTransaction |
Decoded via XRP transaction parser | Same payload |
| Native sends (BTC, ETH, SOL, ZEC, LTC, DOGE, XRP) | Same struct | Same struct |
Permit2 Detection
Permit2 (Uniswap's signature-based approval system) receives special handling:
- Detected by domain name or verifying contract address
- Parses token, amount, spender, and expiration from signed payload
- Flags "UNLIMITED" amounts (max uint160/uint256)
- Checks spender against trusted contract registry
- Shows CRITICAL warning for unlimited approval to untrusted spender
Verified vs. Unverified Contracts
The watch checks contracts against an embedded registry that can only change with an app update:
- Verified: No warning banner — the transaction is presented cleanly
- Unverified: Orange warning — "Only approve if you trust this contract"
When Time Lock is enabled, interactions with unverified contracts are blocked entirely until you wait through your delay.
Embedded Registry
129 contracts across Ethereum, Base, BSC, Solana, and NEAR:
| Chain | Contracts |
|---|---|
| Ethereum | 71 |
| Base | 20 |
| BSC | 21 |
| Solana | 16 |
| NEAR | 1 |
Full registry with contract addresses: contract-registry.json
Zcash Shielded Transactions
Cryptograph supports both transparent and shielded Zcash transactions via our lightweight open-source no_std signing library, zcash-signer.
Transparent Transactions
Standard BIP-44 derivation on watch. The watch builds and signs the entire transaction, similar to Bitcoin.
Shielded Transactions (Orchard)
Shielded transactions use the PCZT (Partially Created Zcash Transaction) pattern:
1. Phone builds transaction structure via ZcashLightClientKit SDK 2. Phone sends unsigned PCZT to watch 3. Watch signs with RedPallas (Orchard) or RedJubjub (Sapling) 4. Watch returns signed PCZT (spending key never leaves watch) 5. Phone adds zero-knowledge proofs after signing 6. Phone broadcasts completed transaction
Key Separation
Zcash uses two distinct key types for shielded addresses:
- Authorizing Spend Key (ASK) — Required to sign transactions and spend funds. Generated on the watch and never leaves the watch.
- Unified Full Viewing Key (UFVK) — Allows viewing balances and transaction history without spending capability. Generated on the watch, then shared with the phone.
This separation allows the phone to sync with the Zcash blockchain, decrypt incoming transactions, and display your shielded balance—all without ever having the ability to spend your funds.
Security Model
The critical property is maintained: the spending key never leaves the watch. The phone handles the computationally expensive proof generation, but cannot spend funds without the watch's signature.
| Component | Location |
|---|---|
| Authorizing Spend Key (ASK) | Watch only (never leaves) |
| Unified Full Viewing Key (UFVK) | Generated on watch, shared with phone |
| Transaction building | Phone (ZcashLightClientKit) |
| RedPallas/RedJubjub signing | Watch (via Rust FFI) |
| Zero-knowledge proofs | Phone (after signing) |
| Blockchain sync & balance viewing | Phone (using UFVK) |
Dependencies
We rely on well-audited third-party cryptographic libraries. Where we've made modifications, our forks are public. Unmodified upstream dependencies are pinned to specific versions.
| Library | Purpose | Source |
|---|---|---|
| WalletCore | BIP-39/BIP-32 key derivation, BTC/EVM/SOL/LTC/DOGE/XRP/Tron signing | perpetua-engineering/wallet-core (fork) |
| zcash-signer | Minimal no_std Zcash signing for watchOS (ZIP-32, RedPallas, transparent) | perpetua-engineering/zcash-signer (original) |
| zcash-swift-wallet-sdk | iOS Zcash light client framework | Electric-Coin-Company/zcash-swift-wallet-sdk (upstream, unmodified) |
| zcash-light-client-ffi | Light client FFI layer for librustzcash | Electric-Coin-Company/zcash-light-client-ffi (upstream, unmodified) |
| pczt | PCZT crate with external signer support | zcash/librustzcash (upstream, pinned rev) |
| orchard | Zcash Orchard protocol implementation | perpetua-engineering/orchard (fork, debug-tools feature); upstream v0.12 for PCZT signing |
| sapling-crypto | Zcash Sapling cryptography | Upstream crate v0.6 (via crates.io) |
| ReownWalletKit | WalletConnect v2 protocol | perpetua-engineering/reown-swift (fork) |
| CryptoKit | ChaCha20-Poly1305, HMAC-SHA256, HKDF | Apple (system framework) |
| Security.framework | Secure Enclave key generation, ECDH, Keychain | Apple (system framework) |
Known Limitations
- WCSession transport: Watch Connectivity is encrypted by Apple, but we haven't independently verified the specifics
- PIN entropy: 6-10 digit numeric PIN has limited entropy; PBKDF2 iterations provide mitigation. Users may alternatively protect backups with a passphrase for higher entropy.
- Third-party key handling: WalletCore handles some derived key material internally; we've audited and patched our fork
Install Integrity Check
The app verifies its own installation origin approximately once per day using StoreKit 2's AppTransaction.shared. Verification is entirely on-device — the OS validates the JWS-signed app transaction against Apple's root certificates locally. No network call, no server, no device identifiers transmitted.
The result is a verdict: official (App Store or TestFlight), not_official, or unknown. If not_official, a persistent warning advises the user to install only from the App Store or TestFlight. unknown triggers no user-facing warning — only a subtle status in settings. The check backs off from 6 hours to 24 hours on repeated unknowns, and caches known verdicts for 24 hours.
Limitations
This is informational only and does not gate any functionality. It cannot detect sophisticated runtime hooking or jailbreak environments. Client-side rate limiting is trivially bypassable by a modified binary, but the check's purpose is to warn unaware users of tampered installs, not to resist determined adversaries.
Security Improvements
Summary of security hardening since initial design:
| Area | Before | After |
|---|---|---|
| Recovery backup | Plaintext mnemonic | Encrypted (ChaCha20-Poly1305); steganographic Photo Backup option |
| Recovery authentication | None | PIN or passphrase + PBKDF2 (1M iterations) |
| Mnemonic at rest | Keychain only | SE-encrypted + Keychain |
| Key provenance | Unknown | Serial proves on-device generation |
| Weak credentials | Allowed | Rejected (sequential/repeated PINs) |
| Passcode requirement | None | Keychain probe blocks signing |
| Wrist detection | None | LAContext blocks if off-wrist |
| Spend limits | None | Configurable with time delay |
| Approval gating | None | Warns on unverified; blocks when Time Lock active |
Questions about our security architecture? Email security@perpetua.watch