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 (L2), Solana, and Zcash (including shielded transactions via Orchard).
Key Management
Key Derivation Paths
Standard BIP-32/BIP-44 derivation paths are used for each chain:
| Chain | Derivation Path | Standard |
|---|---|---|
| Ethereum / Base | m/44'/60'/0'/0/0 |
BIP-44 |
| Bitcoin | m/84'/0'/0'/0/0 |
BIP-84 (native SegWit) |
| Solana | m/44'/501'/0'/0/0 |
Phantom-compatible |
| Zcash (transparent) | m/44'/133'/0'/0/0 |
BIP-44 |
| Zcash (shielded) | ZIP-32 UFVK | Orchard |
Storage
The mnemonic is stored in the watchOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. This means:
- Keys are only accessible when the watch is unlocked
- 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 | SecureEnclave.P256.KeyAgreement.PrivateKey — never exportable |
| Key access control | kSecAttrAccessibleWhenUnlockedThisDeviceOnly + .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
The Recovery Sheet is an encrypted backup. The phone never sees the mnemonic in any form — it only handles opaque ciphertext.
BACKUP: Watch: mnemonic → encrypt(PIN, PBKDF2 + ChaCha20-Poly1305) → ciphertext Watch → Phone: ciphertext only Phone: prints QR with CGREC1: prefix (opaque blob) RECOVERY: Phone: scans QR → forwards ciphertext to watch (never decrypts) Watch: prompts for PIN → decrypts → validates BIP-39 → imports Watch: shows fingerprint for user verification against printed 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 |
Security Features
- Mnemonic never transmitted to phone (encrypt on watch, decrypt on watch)
- PIN-based encryption with high iteration count
- Rate limiting on PIN attempts (exponential backoff)
- Fingerprint verification after decrypt (anti-tampering)
- Weak PIN rejection (sequential, repeated digits)
- 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.
| PIN Length | Combinations | Crack Time (1x RTX 4090) |
|---|---|---|
| 6 digits | 106 | ~2 minutes |
| 8 digits | 108 | ~3 hours |
| 10 digits | 1010 | ~13 days |
That said, we believe an encrypted Recovery Sheet with a PIN is still more threat-resistant than a traditional naked seed phrase. An attacker who finds your Recovery Sheet must still crack the PIN, whereas a plaintext seed phrase grants immediate access to funds.
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. Display data is derived only from the parsed payload — phone-supplied summaries are never trusted.
| Method | Display Source | Signing Source |
|---|---|---|
eth_sendTransaction |
Decoded from protobuf | Same payload |
eth_signTypedData_v4 |
Parsed from JSON in payload | Same payload |
personal_sign |
Raw bytes from payload | Same payload |
solana_signTransaction |
Decoded via transaction parser | Same payload |
| Native sends | 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: Green banner with contract name
- Unverified: Orange warning — "Only approve if you trust this contract"
Zcash Shielded Transactions
Cryptograph supports both transparent and shielded Zcash transactions. As far as we know, we are the first production wallet to implement Orchard PCZT signing, 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 ONLY the signatures (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.
| Library | Purpose | Source |
|---|---|---|
| WalletCore | BIP-39/BIP-32, EVM/BTC/SOL signing | perpetua-engineering/wallet-core |
| zcash-swift-wallet-sdk | iOS Zcash light client framework | perpetua-engineering/zcash-swift-wallet-sdk |
| zcash-light-client-ffi | Light client FFI layer for librustzcash (watchOS support) | perpetua-engineering/zcash-light-client-ffi |
| zcash-signer | Minimal Zcash signing primitives for watchOS (ZIP-32, RedPallas) | perpetua-engineering/zcash-signer |
| pczt | PCZT crate with external signer support | perpetua-engineering/pczt |
| zcash-transparent | Transparent transaction support with external signer | perpetua-engineering/zcash-transparent |
| orchard | Zcash Orchard protocol implementation | perpetua-engineering/orchard |
| sapling-crypto | Zcash Sapling cryptography | perpetua-engineering/sapling-crypto |
| ReownWalletKit | WalletConnect v2 protocol | reown-com/reown-swift |
| CryptoKit | ChaCha20-Poly1305, HMAC-SHA256, Secure Enclave | 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
- Third-party key handling: WalletCore handles some derived key material internally; we've audited and patched our fork
Security Improvements
Summary of security hardening since initial design:
| Area | Before | After |
|---|---|---|
| Recovery QR | Plaintext mnemonic | Encrypted (ChaCha20-Poly1305) |
| Recovery authentication | None | PIN + 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 untrusted contract approvals |
Questions about our security architecture? Email security@perpetua.watch