Split vault: the two-tier store on iOS (Secure Enclave presence key, PIN and Face ID over both tiers, lock-screen calls on the call tier) #6
Loading…
Reference in a new issue
No description provided.
Delete branch "ann/split-vault"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The split vault on iOS, from the Mac session's feat/split-vault (3 commits on master
94c347e5, merges clean onto today's master). AppPresenceKey (K2) is a second Secure Enclave key made with .privateKeyUsage and .userPresence, WhenUnlockedThisDeviceOnly, so the per-account stores open only with Face ID, Touch ID or the passcode; AppDeviceKey (K1) stays ungated and opens only accounts.db once the store is split, which is what a call answered on the lock screen needs (status call, root stays locked, the call connects). setupPin encrypts, splits and binds both keyrings; unlock(pin:) evaluates one LAContext, sends pin, deviceKey, mainDeviceKey and mainKey, splits an unsplit store once after the person's unlock, and from call keeps the call up; biometric unlock sends key and mainKey. A wrong PIN from call is a miss and never opens the app. TackyWire.swift is regenerated from libtacky feat/split-vault, the tree tagged v1.0.4 (storage split, set_passphrase, mainKey/mainDeviceKey, StorageStatus call, StorageInfoReply split/tier/mainSlots/mainHardware). Texts in Localizable.xcstrings; PinGateTests, StoreKeyTests and TackyAPITests extended (the Mac ran the filtered and whole suites on the simulator; the Secure Enclave path is compiled, not run on hardware). Build against libtacky v1.0.4 or later. 🤖 Generated with Claude Codepython3 tools/api/api.py gen --swift, in place of the copy the tiers commit generated from a scratch schema: the requests, replies and events are the same, and what differs is the schema's own wording -- the "Since 1.0.4." the new arguments' and fields' docs begin with,status's doc namingcall, and StorageStatus'scalllisted afterplaintext, where the merged schema has it (soknownand the switch arms move; nothing matches on the order). The tacky submodule pointer is not moved: it would have to name an unpushed libtacky commit. Built and tested against that libtacky:gmake backend -j10 TACKY=<feat/split-vault worktree>into a build/backend of this worktree's own (the main checkout's could not be reused: its CMake caches name the other tree), which bundled the merged Tcl (scripts.zip has tacky_type's PromoteClients and storage's split); then on the iPhone 18 Pro simulator (Xcode 27.1, ad-hoc test signing)gmake test378 tests, 0 failures, andgmake app-iosBUILD SUCCEEDED; swiftlint over every Swift file the branch changes reports the same 11 warnings master has on them, none new. The unit tests answer over FakeTransport, so the backend is linked but not exercised by them; the iOS app's request sequences were replayed against the merged storage module in a scratch Tcl suite instead (setup, a wake into call, the PIN with K2 from call, both migrations, the PIN slot healed after a Face ID split), all as AppEnvironment sends them. Not run: the Secure Enclave .userPresence key on a device (no device here). ff40ee97dbcalltoo, so a wrong one is a miss and never opens the app. A store split by a Face ID unlock has no PIN slot in its main keyring until a PIN unlock adds it, and fromcallunlock(pin:) worked around that: with a call on it sent K2 alone and left the PIN out, so any six digits plus the sheet opened the messages and AppPinPolicy counted them as the right PIN (review of 2026-10-20); with no call on it locked the store first, dropping the call tier's sessions, only to have the PIN checked fromlocked. libtacky feat/split-vault now checks a PIN or passphrase sent fromcallagainst the call tier's keyring whenever data.keys has no slot for it, says "incorrect pin" with the store left incall, and adds the K2-bound main PIN slot from the same request when it is right; so unlock(pin:) drops both branches and sends {pin, deviceKey K1 when the call PIN slot is bound, mainDeviceKey K2, mainKey K2} fromlockedand fromcallalike. Tests: TieredPinGateTests' testAPinUnlockFromCallWithNoMainPinSlotLocksFirstWhenNoCallIsOn becomes testAPinUnlockFromCallWithNoMainPinSlotSendsThePinAndNoLock (the four arguments, no lock), and testAWrongPinFromCallIsRefusedThoughK2WouldOpen is new (the fake store refuses the PIN as storage.tcl does; the result is .refused("incorrect pin"), the store stays in call, the gate stays shut); the fake now answers a refusal through FakeTransport.fail. The backend was rebuilt from that libtacky (gmake backend -j6 TACKY=<feat/split-vault worktree> at 0985cdf3; scripts.zip carries storage.tcl's IsCallKey and redact.tcl's mainDeviceKey). On the iPhone 18 Pro simulator (Xcode 27.1, ad-hoc test signing): PinGateTests, TieredPinGateTests and PinPolicyTests 19 tests, 0 failures; gmake test 379 tests, 0 failures (378 before, the new case); gmake app-ios BUILD SUCCEEDED; swiftlint on the two files reports the same 2 warnings as before, none new. The tacky submodule pointer is not moved (it would name an unpushed libtacky commit). Not run: the Secure Enclave .userPresence path on a device (no device here). 085d982eef