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

Merged
fmodf merged 3 commits from ann/split-vault into master 2026-10-24 20:15:41 +00:00 AGit
Owner

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 Code

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 Code](https://claude.com/claude-code)
The store is split in two tiers, so that nothing on the phone opens the messages without the person: accounts.db (credentials, settings) stays under the keyring AppDeviceKey (K1) opens without anyone, which a call answered on the lock screen needs, and every account's own store (history, roster, OMEMO, the attachments' keys) goes under libtacky 1.0.4's second keyring, whose slots take the new AnotherIMShared/AppPresenceKey (K2): 32 bytes ECIES-sealed, as AppDeviceKey's are, under a Secure Enclave P-256 key made with .privateKeyUsage and .userPresence, items WhenUnlockedThisDeviceOnly, opened only through an LAContext the person evaluated (authenticate, then load/loadOrCreate(context:) passing it as the enclave's authenticationContext, so one sheet; a context not seen evaluated is refused before the enclave is asked; the simulator's software path, and the DEBUG-only softwareKeyForTests switch the tests use, since Xcode 27.1's simulator reports an enclave). AppEnvironment: setupPin asks once (.deviceOwnerAuthentication) before anything is written, then encrypt -pin, split {pin, mainKey, mainDeviceKey}, set_pin {pin, deviceKey K1, mainDeviceKey K2}, set_device_key {key K1, mainKey K2}; unlock(pin:) asks once and sends unlock {pin, deviceKey, mainDeviceKey, mainKey}, from locked or from call (libtacky keeps a running call on its session, so "A call is on" is now said only for an unsplit store a wake opened), and returns a PinUnlockResult so a cancelled or failed sheet is no PIN miss for AppPinPolicy; a store split by a biometric unlock has no main PIN slot until the next PIN unlock, so from call with no call on it is locked first and the call tier checks the PIN, and with a call on K2 alone opens it as Face ID does; unlockWithBiometrics sends unlock {key K1, mainKey K2} from the same context; an unsplit store (info.split false) is split right after the person's unlock, never on a wake, with the PIN screen showing "Updating your vault…" over the busy keyboard, and a failed split leaves it one tier to be tried again; a phone with no passcode cannot ask for the person and keeps one tier. unlockInBackground/unlockToAnswer stay K1-only and land in call, which resolveRootState keeps at .locked while isStoreOpen (now StorageStatus.isReadable) lets settings and conn through; relockAfterBackgroundWake locks call as it locked unlocked (not while the person's unlock runs); .storageUnlocked (now carrying tier, XMPPEvent.swift) acts only on main; storeOpened runs again from call to unlocked for the full sessions; push remove_target waits for the whole store, the push module being per account; resetStore and the storage-generation reset forget K2 too. TackyAPI gains storageUnlock(pin:deviceKey:mainDeviceKey:mainKey:), storageUnlock(deviceKey:mainKey:), storageSplit, storageSetPassphrase, storageSetPin(_:deviceKey:mainDeviceKey:), storageSetDeviceKey(_:mainKey:); StorageLockInfo (Wire.swift) reads split, tier, mainSlots, mainHardware (an older backend's reply reads as one tier) and canUnlockWithBiometrics wants a device slot in both keyrings. Generated/TackyWire.swift is regenerated (tools/api/api.py gen --swift) from libtacky develop's schema with the 1.0.4 storage additions applied in a scratch copy (split, set_passphrase, mainKey/mainDeviceKey, StorageStatus call, the StorageInfoReply fields, Unlocked's tier, Split); the integrator regenerates from the merged libtacky, and the tacky submodule pointer is unchanged. project.yml keeps AppPresenceKey out of the notification extension and rewords NSFaceIDUsageDescription; Localizable.xcstrings (English only) changes PinCode.msg1/msg2, Security.biometrics.footer.available and Security.autolock.footer and adds the PinCode.presence.* refusals, PinCode.splitting and Security.presence.footer.software/noPasscode, which Settings → Security shows when the key is not the enclave's or the phone has no passcode; README.md and TACKY-MIGRATION.md §8.2 describe the tiers. Tests: TieredPinGateTests (11: a VoIP wake sends unlock {key} only, lands in call with the root .locked and isStoreOpen, and never splits; a PIN unlock sends both main keys and a split store gets no split; an unsplit store is split after the person's PIN unlock and after a biometric one with K2 alone; no passcode keeps one tier; a cancelled sheet sends nothing; a biometric unlock from call sends {key, mainKey} and no lock; the no-main-PIN-slot relock; call is relocked on coming to the front; the setup's request order and shapes), PresenceKeyTests (4: made once and read back, refused without an evaluated context, apart from K1, the software flag), TackyAPITests (4 new request and reply shapes). On the iPhone 18 Pro simulator (Xcode 27.1, ad-hoc test signing): the filtered PinGate/TieredPinGate/PinPolicy/DeviceKey/PresenceKey/TackyAPI run 51 tests, 0 failures; gmake test 378 tests, 0 failures (359 on master); gmake app-ios BUILD SUCCEEDED; swiftlint reports nothing new on the changed files beyond TackyAPITests' existing type_body_length. Not run: the enclave's .userPresence path on a device (no device here; compiled, not run), in particular that a context evaluated with .deviceOwnerAuthenticationWithBiometrics satisfies the key's .userPresence, and the whole flow against a libtacky with split, which is built in parallel (the tests answer as storage.tcl is designed to). a4264d8062
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated/TackyWire.swift is regenerated from the merged libtacky (feat/split-vault at ab04d98c, the two-tier store's storage and call-state halves together) with python3 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 naming call, and StorageStatus's call listed after plaintext, where the merged schema has it (so known and 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 test 378 tests, 0 failures, and gmake app-ios BUILD 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). ff40ee97db
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PIN typed on the lock screen always goes with the unlock, from call too, 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 from call unlock(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 from locked. libtacky feat/split-vault now checks a PIN or passphrase sent from call against the call tier's keyring whenever data.keys has no slot for it, says "incorrect pin" with the store left in call, 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} from locked and from call alike. 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fmodf merged commit fd20eb0cc3 into master 2026-10-24 20:15:41 +00:00
fmodf referenced this pull request from a commit 2026-10-24 21:13:45 +00:00
The backend is libtacky v1.1.1 (29faa3d9), up from 9b8a62c7 (v1.0.2-3), and TackyWire.swift is generated from its schema (`tools/api/api.py gen --swift`). Master's TackyWire.swift was generated from ab04d98c (v1.0.3-7, the split-vault merge, #6) while the submodule stayed at 9b8a62c7, which has no `storage split`, no `call` status and no main-tier keys: on a phone with Face ID or a passcode, setupPin encrypted the store and then failed at `storage split`, and the unlock that splits an unsplit store failed the same way. v1.1.1 contains ab04d98c, and the split-vault calls (storage split, unlock, set_pin, set_device_key, info) have the same signatures in both, so the app's code is unchanged. The wire changes since ab04d98c: `stickers send` answers the stored row's timestamp (Int64), not ""; OTR's SMP is reworked (`smp_answer` goes, `accept`, `decline` and `smp_respond` come, OtrState gains `pending`, OtrStateReply and OtrStateEvent gain `fingerprint_changed`, `otr verify` takes the `fingerprint`); `chats mute` takes `mentions`, `omemo fingerprint` takes a `jid`, `mood set` takes `expires` and `specific`, all optional; User Activity (XEP-0108) and User Tune (XEP-0118) are new. Optional fields come too: a file received under JET says `name_unauthenticated`, a bookmark carries `password`, `room_state`, `room_error` and `room_reason`, Message gains `reply_kind` and `error`. The app calls none of the changed signatures and uses no OTR. The backend's build files change in Makefile, deps.mk and platforms/ios.mk (tacky-crypto's sources in subdirectories now count), so the first `gmake backend` rebuilds the Rust archives. The build number is 90. Not built with v1.1.1 yet; the tests were not run.
Sign in to join this conversation.
No reviewers
No labels
done
fixed
returned
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
another-im/anotherim-swift!6
No description provided.