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.
Why the Watch?
A HSM on 200-300 million wrists. To build Apple Pay for watchOS, Apple added their Secure Enclave TEE/HSM to what was otherwise a fitness device. This enables unique root-of-trust possibilities, separate but connected to a full smartphone. It's also attached to a device with a large, clear screen suitable for strong passphrase entry and detailed on-device transaction decoding.
No browser based exploits. The most common way to compromise an iPhone is through WebKit, the rendering engine behind Safari, in-app browsers, and web views. Every major iOS exploit chain in recent years has started with a WebKit vulnerability: visit a malicious page, and the attacker gains code execution.
Discretion and anonymity. Legacy hardware wallets' form factors are now well-known. Using one in public, or even carrying one on your person / in your luggage can be a risk to your safety in low-trust societies. The Apple Watch, with its huge worldwide sales volume, is a perfect way to secure your wallet hidden in plain sight. And unfortunately, hardware wallet vendors have proven to be poor stewards of our shipping addresses, with the market leaders both having had their CRMs compromised, some multiple times.
Can a Compromised Phone Install Malware on the Watch?
The natural question for a two-device architecture: if the phone is fully compromised, can the attacker push a malicious app to the watch? It is extremely difficult. Multiple independent gates stand between a compromised phone and code execution on the watch.
watchOS: Code Signing + Sealed System
Bundling gate. Watch apps are delivered as extensions bundled inside the iPhone app. Every binary in the bundle must be signed by a certificate Apple trusts. If malware modifies even one byte of the watch binary on the phone, the signature becomes invalid. Critically, it is the watch's own amfid (Apple Mobile File Integrity Daemon) that checks the hash when the binary arrives — not the phone's. Even if an attacker patches out amfid on a compromised phone, the watch's independent integrity check still rejects the tampered binary.
Provisioning gate. Even with a stolen Enterprise certificate, watchOS requires the user to physically navigate to Settings → General → VPN & Device Management and manually trust the developer. This cannot be triggered programmatically.
Independent boot chain. A kernel-level exploit on the phone could hook system daemons like installd in memory (as chains like Coruna demonstrate), potentially bypassing phone-side integrity checks for the duration of that session. But the watch boots and verifies its own OS independently via its own Secure Boot chain. Phone-side compromise does not propagate to the watch's kernel or system daemons.
Signature verification at every launch. Unlike platforms that only check signatures at install time, watchOS re-verifies code signatures against the Secure Enclave at every app launch. A binary that passes installation but is later tampered with will not run.
Wear OS: Separate Kernel, Separate Trust
On Wear OS, the phone and watch communicate over Bluetooth via Google Play Services. A compromised phone can transfer files to the watch, but cannot silently install them — the watch's Package Installer requires explicit user approval. Root access on the phone does not grant root on the watch; the Bluetooth protocol does not pass kernel-level permissions. An attacker would need a separate local privilege escalation exploit targeting the watch's own kernel.
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, which takes 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
Solana Transaction Decoding & Threat Detection
Solana transactions are bundles of arbitrary program instructions. Unlike EVM, there's no single approve() pattern. A malicious transaction might appear as "Create Account" on a legacy hardware wallet while actually transferring token ownership to an attacker. Cryptograph decodes the wire-format bytes directly on the watch and runs multi-layer threat detection before displaying anything to the user.
Instruction-Level Decoding
The watch parses every instruction in a Solana transaction from the raw wire format (both legacy and versioned v0 messages). For known programs, each instruction is decoded to its specific type and parameters:
| Program | Decoded Instructions |
|---|---|
| System Program | Transfer (amount in SOL), AdvanceNonceAccount (durable nonce detection) |
| SPL Token / Token-2022 | Transfer, TransferChecked, Approve (delegate amount), SetAuthority (authority type + new authority address), CloseAccount |
| Associated Token Account | CreateATA |
| Compute Budget | SetComputeUnitLimit, SetComputeUnitPrice |
| Jupiter (v4, v5, v5.1, v6, DCA, Limit, Perps) | Anchor IDL-decoded with named parameters (amounts, slippage, direction) |
| Orca, Raydium, Marinade | Anchor IDL-decoded with named parameters |
For DeFi programs that use Anchor, the watch decodes instruction parameters from bundled IDL schemas, showing the user actual amounts, slippage settings, and swap directions rather than opaque byte blobs. Instructions that can't be fully decoded are flagged.
Threat Detection
Six independent detectors run against every decoded Solana transaction:
| Detector | What It Catches | Risk Level |
|---|---|---|
| Authority Transfer | SPL Token SetAuthority changing account owner, mint authority, freeze authority, or close authority to an address other than the signer |
Critical |
| Delayed Execution | Durable nonce (AdvanceNonceAccount as first instruction): Transaction never expires and can be executed at any future time. Hard-blocked at signing (see below) |
Blocked |
| Batch Drain | 3+ transfers to the same non-signer address in a single transaction | High / Critical |
| Unknown Program | Interaction with a program not in the trusted registry. Transaction effects cannot be determined | Medium (escalates to Critical) |
| Upgradeable Program | RPC verification that a program can be modified by its upgrade authority. What you sign today may behave differently when executed | Medium (escalates to Critical) |
| Simulation Failure | Transaction fails RPC simulation. May indicate anti-simulation techniques or time-dependent exploits | Medium / High |
Compound Escalation
Individual threats are dangerous. Combinations are worse. When multiple risk signals appear in the same transaction (for example, an unknown program combined with an upgradeable program), the aggregate risk escalates to Critical. This catches TOCTOU (time-of-check/time-of-use) patterns where an attacker could change a program's behavior between when you sign and when the transaction executes.
Durable Nonce Blocking
Durable nonce transactions (AdvanceNonceAccount as the first instruction) allow signed transactions to be held indefinitely and executed at any arbitrary future time. They never expire. This is a key vector in real-world Solana drainer exploits: the victim signs what looks routine, and the attacker holds the signed transaction until conditions are favorable (e.g., after upgrading a program's behavior).
Cryptograph refuses to sign any Solana transaction that uses the durable nonce pattern. The transaction is rejected before reaching the approval screen, and the watch displays a non-dismissable explanation. This is a hard block, not a warning. Users cannot override it.
Why block instead of warn:
- Contradicts Time Lock. A never-expiring transaction lets an attacker choose when to execute, bypassing time-based security entirely.
- Users can't make an informed decision. An unlimited approval shows you the spender and the token, so you can judge whether you trust the spender. A durable nonce tells you nothing actionable: the risk depends entirely on what the attacker does after you sign.
- Limited utility for personal wallets. Durable nonces exist primarily for multisig coordination and custodial operations, use cases that don't apply to a personal signing device. If you believe you need durable nonce support, contact engineering@perpetua.watch and we'll evaluate your use case.
Address Table Lookup Resolution
Versioned Solana transactions (v0) can reference accounts via Address Lookup Tables, which hide the actual addresses being interacted with. The watch resolves these lookup tables via RPC before running threat detection, so account-index-based detectors see the same addresses the Solana runtime will use at execution time. Unresolved lookups are flagged.
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
Approval Revocation
Token approvals are a persistent attack surface. A single approve(spender, MAX_UINT256) grants indefinite access to your tokens, and most dApps request exactly that. Cryptograph lets you audit and revoke these approvals directly from your phone, with the watch signing the revocation transaction.
Discovery
The app queries on-chain approval state for each EVM chain (Ethereum, Base, BSC, Arbitrum, Optimism, Polygon, Avalanche). Both standard ERC-20 Approval events and Permit2 Permit events are indexed. Each approval is classified by risk:
| Risk Level | Criteria |
|---|---|
| Critical | Unlimited amount + unverified spender |
| High | Unlimited amount + verified spender |
| Medium | Limited amount + unverified spender |
| Low | Limited amount + verified spender |
Revocation Flow
Revoking an approval is an on-chain transaction: it requires gas and a signature from the watch. The phone builds the unsigned transaction; the watch displays what it's signing and requires a 1.5-second press-and-hold to approve.
Phone Watch │ │ │ 1. Build unsigned revoke TX │ │ (estimate gas, encode calldata) │ │ │ │ 2. Send to watch ──────────────────► │ │ │ 3. Display: token, spender, │ │ amount → 0, network fee │ │ │ │ 4. User holds to approve │ │ (1.5s press-and-hold) │ │ │ 5. Receive signed TX ◄────────────── │ │ Broadcast to network │ │ │ │ 6. Refresh approval list │
Two revocation methods, depending on the approval type:
| Type | On-Chain Call | Target Contract |
|---|---|---|
| ERC-20 | approve(spender, 0) |
The token contract |
| Permit2 | approve(token, spender, 0, 0) |
Canonical Permit2 contract (0x000...22D473, same on all EVM chains) |
Both methods emit standard events that block explorers and tools like revoke.cash recognize. The phone never constructs or modifies the transaction after the watch signs it. It broadcasts the signed bytes as-is.
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
Read the source: Open Source