← Back to Docs

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:

Key Lifecycle

Derived private keys are never cached. Each signing operation follows this flow:

  1. Retrieve encrypted mnemonic from Keychain
  2. Decrypt using Secure Enclave key (see below)
  3. Derive the chain-specific private key
  4. Sign the transaction
  5. 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

Signing Authorization

Before any signature is produced, the watch enforces three independent checks. All three must pass.

Transaction Request
        │
        ▼
┌───────────────────────┐
│  1. Passcode Check    │  ← Keychain probe: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
│                       │     Blocks if no watch passcode configured
└───────────────────────┘
        │ ✓
        ▼
┌───────────────────────┐
│  2. On-Wrist Check    │  ← LAContext.deviceOwnerAuthenticationWithWristDetection
│                       │     Blocks if watch locked OR off-wrist
└───────────────────────┘
        │ ✓
        ▼
┌───────────────────────┐
│  3. Time Lock         │  ← Configurable delay (1/3/7 days) + spend limits
│                       │     Blocks if exceeds limit or pending setting change
└───────────────────────┘
        │ ✓
        ▼
    Sign Transaction

Layer 1: Passcode Enforcement

Attempts to store a test item in Keychain with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. If the device has no passcode, the operation fails and signing is blocked.

Without a passcode, an attacker with physical access to an unlocked watch could sign arbitrary transactions.

Layer 2: On-Wrist Authorization

Uses LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithWristDetection) before every signature. This blocks signing if:

This prevents "grab watch off nightstand" attacks. Even with physical possession, an attacker must know the passcode and put the watch on their wrist (which triggers a lock if it was on the victim's wrist).

Layer 3: Time Lock

Configurable anti-coercion protection:

Layer 4: Location Lock (Optional)

Geographic awareness for additional protection:

Reduces attack surface when traveling. Even if coerced away from home, attacker is limited to the Away Limit.

Security Properties

Property Guarantee
No signing without passcode Enforced by Keychain probe
No signing while locked Enforced by LAContext
No signing while off-wrist Enforced by wrist detection policy
Time Lock enforced on-watch Phone cannot bypass; checked before sign()
Location Lock enforced on-watch Geofence check happens on watch, not phone
Setting changes require delay Cannot disable Time Lock or raise limits instantly

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

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
Important: A 6-digit PIN alone provides limited protection against offline attacks if an attacker obtains your Recovery Sheet. Use a longer PIN for high-value wallets, and always store your Recovery Sheet securely.

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:

Verified vs. Unverified Contracts

The watch checks contracts against an embedded registry that can only change with an app update:

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:

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


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