Skip to content

Add the Mac device services: identity storage, pairing and messages - #296

Merged
sudoHG merged 5 commits into
mainfrom
sudoHG/t5-mac-device-services-1454d0
Nov 10, 2026
Merged

sudoHG merged 5 commits into
mainfrom
sudoHG/t5-mac-device-services-1454d0

Conversation

@sudoHG

@sudoHG sudoHG commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #287

Summary

  • Vault storage. The Mac's three Secure Enclave key blobs and the paired-device list are App-only items under the App service (device-message-key, device-agreement-key, device-approval-key, paired-devices), each with its own AppKeychainItemKind and conflict message, in AppBoundKeyStore, IsolatedAppKeyStore and every test conformer. Every device helper passes its account explicitly; the private delete no longer defaults to the vault-key account. The list item is the list's serialization followed by an HMAC-SHA256 tag over AskKey paired devices v1\0 and those exact bytes, under HKDF-SHA256(vault key, empty salt, AskKey paired devices key v1\0); the tag is checked before decoding and a list that fails it is refused, never repaired. Each key item is the blob followed by an HMAC-SHA256 tag over AskKey device key blob v1\0, its account name and NUL, and the blob, under HKDF-SHA256(vault key, empty salt, AskKey device key blob key v1\0): any process on the Mac can use a blob it holds, so an identity this library did not store (for example blobs another App planted before the first pairing) is refused and offers the reset.
  • Vault operations (Vault+Devices.swift). Reading the identity and list needs no management session. One PairedDeviceRegistry per storage epoch commits every list change; a save that adds a device (new device ID or new pairing ID) needs the management session and the authorization recorded by authorizeDevicePairing(with:using:) for that device, which the write uses up; ending the management session, or a failed management-session check, drops the authorization. Local erase stops the device services in its quiesce step (epoch closed, stop handler called), then deletes the four items with the other App keychain items inside the existing erase sequence and its recovery; a pending erase journal keeps device storage closed. An identity reset closes the epoch and deletes keys, list and inbox state. The inbox token and the iCloud account it and the list belong to live in device-inbox.json in the library directory. Backup export is unchanged.
  • CloudKit gate and coordinator (Sources/AskKeyAppKit/Devices/). CloudKitGate allows the CloudKit backend only when the Info.plist declares CloudKitContainerIdentifier and the signed entitlements contain that container and the CloudKit service; otherwise Devices reports notConfigured and no CloudKit object exists. DeviceCoordinator creates the identity on the first pairing (Secure Enclave check, keys in memory, account check, and only then the stored blobs), prepares the inbox, shows the QR code, polls about once a second while the sheet is open and its session can still change (also after the add, until the session expires), waits for the iPhone's verified reveal (logged without content), takes the code, asks for system authentication inside the approval signature (cancelled or failed: nothing sent, no attempt, retry without re-entry), and adds the device only with both confirmations. It runs delivery checks, removal with unpair notices, renaming, push hints (fetch only), the iCloud account (devices unreachable after an account change, also one while the App was not running), the unusable-identity reset and local erase. It never exposes the Mac's code.
  • App wiring. AppRuntimeConfiguration.makeDeviceServices (the production executable passes none), AppDelegate+Devices.swift (create, start, stop, register for remote notifications, cancel pairing when management locks or closes), one-line calls from launch and termination, and SessionPolicy.holdIdleExpiry(), which an open pairing sheet holds until its session ends or the sheet closes. AskKeyTestSupport gains FakeDeviceServicesBackend and SimulatedPairingPhone for Add the Devices section to Mac Settings #288.

Diff stat

git diff --stat origin/main: 31 files changed, 3848 insertions(+), 17 deletions(-). Sources: 17 files, +1988/−11 (new: Vault+Devices.swift 405 lines, Devices/ 6 files ≤ 498 lines, AppDelegate+Devices.swift; Vault.swift 588 lines). Tests: 13 files, +1852/−3. Commits: c8a5680 (feature), 06d6b34, d5425fd, 57e32f1, 3c211ae (cross-review fixes). Package.swift: the dependency edges the issue allows. Sources/AskKeyDevices/ is unchanged.

Tests

swift build --build-tests, then swift test --skip-build --filter <suites>, 0 failed:

Suite Tests Notes
DeviceStorageTests (new) 16 round trip across reopening, list and key-item tag vectors with labels written out byte by byte, planted/foreign/moved key items refused, tampered/re-encoded/foreign-key/short items refused and not overwritten, add needs session + authorization (cancelled authentication stores nothing, used up after one add, re-pairing counts as an add, gone after the management session ends or a failed check), management-session renewal by the sheet's check, stale change after removal dropped, no vault-key read/write/delete, reset closes the old registry, local erase stops services before deleting all four items, interrupted erase keeps storage closed, backup export carries no device data, conflict messages
IsolatedDeviceKeyStoreTests (new) 1 isolated Debug run files, 0600, 64 KiB limit
DeviceKeychainNativeTests (new) 3 skipped native file keychain, CI only: App-only ACL, planted permissive item replaced, duplicates report their own kind, vault key untouched
CloudKitGateTests (new) 7 gate decisions without creating a container; this test process is not configured; no backend → notConfigured, nothing stored; Mac key protections explicit
DeviceCoordinatorPairingTests (new) 17 full pairing with Mac confirming first, polling and hold after the add until expiry (also an expiry during a loop turn's poll), no entry before the reveal, mismatched reveal, cancelled/failed authentication then retry, authentication finishing after expiry or Cancel, three wrong codes, Cancel/expiry/second request, iPhone withdrawal, undo after add, management lock, tick without session, no key without account, idle-expiry hold
DeviceCoordinatorDevicesTests (new) 14 no CloudKit request without identity, planted key item refused with reset, an older account answer does not overwrite a newer change, delivery check updates last reached, removal refuses later messages and delivers the notice, rename/remove need the session, stale changes do not apply, account change (live and across relaunch, sign back in), empty list adopts the new account, unusable identity at launch and at runtime with reset, push hints, launch processing and token, local erase
SessionExpiryHoldTests (new) 3
Existing suites touched by the change VaultBootstrapTests 16, LocalVaultLifecycleTests 8, BackupExportTests 9, AgentAccessPolicyTests 23, SharedAgentAccessPolicyTests 20, KeychainInteractionSafetyTests 7 (1 skipped), VaultConfigurationTests 2, AppBoundKeyStoreNativeTests 11 skipped (CI only), AgentAccessPolicyMessageTests 2, AgentAccessPolicyViewModelTests 28, AppLaunchLifecycleTests 3, LocalVaultLifecycleViewModelTests 3, SessionPolicyTests 8, TimedAllowanceSessionPolicyTests 3 — all pass, same counts as before

Final head 3c211ae: the new suites 16 + 1 + 7 + 17 + 14 + 3 = 58 tests, 0 failed (3 native skipped); the existing suites above 0 failed. The coordinator suites ran twice more with no failure.

Not run locally (per AGENTS.md): the full swift test suite, the native keychain tests (CI only) and the desktop flows; CI runs them.

Checks

  • swift build: passes.
  • python3 scripts/check_hygiene.py: Hygiene rules passed: size=0, test-support=0, debug=0, local-path=0, non-ascii-name=0, multica=0, cjk=0.
  • python3 scripts/check_module_deps.py: Module dependency check passed.

Deviations and questions

  • Outside the Scope, approved by the planner. Vault.endManagementSession() gains one line, deviceState.authorize(nil) (commit d5425fd); the issue allows Vault.swift changes only for local erase. Reason: both cross reviewers found that the Vault-level pairing authorization was bound only to the device ID and outlived its management session, so "authorize A → end the management session → begin a new one → add A" succeeded at the Vault layer although the issue requires an authentication obtained for that pairing session. Test: DeviceStorageTests.testAnAuthorizationEndsWithItsManagementSessionEvenWithoutACheckInBetween. Paths checked: locking (VaultViewModel.lock() and Vault.lock()), the App's idle expiry (VaultViewModel.renewManagementSession timer), closing the management window (ManagementSessionLifecycle → VaultViewModel.lock()) and local erase (VaultViewModel.eraseLocalLibrary → lock()) all call endManagementSession(). Not through it: the Vault's own idle expiry inside requireManagementSession() and the session reset in finishLocalErase(); the first is covered by the in-scope fallback (a failed management-session check drops the authorization) and in the App by the App-level timer of the same length, the second by the erase's epoch change, which also drops it.

  • Authentication reason. The App's authentication subprocess accepts only the reasons in ManagementAuthenticationAction, so a new pairing reason would make every real authentication fail. Adding one changes ManagementAuthenticationProcess.swift and needs a translation, both outside this Scope. Pairing therefore uses the management reason (Vault.devicePairingReason == CredentialManagementCopy.manageReason). Handed to Add the Devices section to Mac Settings #288 (Scope and step 7): a dedicated pairing reason in ManagementAuthenticationProcess.swift and AppLanguage.swift with its translation, and Sources/AskKeyAppKit/Devices/ passing it.

  • Strings. The four new conflict messages are English only. Handed to Add the Devices section to Mac Settings #288 (step 7): their Simplified Chinese translations.

  • Account change with devices listed. While another account is signed in and the list is not empty, pairing reports accountUnavailable(.accountChanged) until the person removes the devices or signs back in; with an empty list the new account is adopted.

  • Key-item format not in ADR 0013. The tag on each key item (above) is recorded only in code and tests; ADR 0013 says the blobs are stored like the vault key and gives no item layout. docs/adr is outside this Scope, so recording it is left to the planner.

  • Inbox state location. The token and account live in the library directory (device-inbox.json), which is per storage namespace and goes with a local erase, rather than in UserDefaults.

  • The E2E App does not pass the fake factory yet; Add the Devices section to Mac Settings #288 wires it.

  • Sources/AskKeyDevices/ needed no change.

The Vault stores the Mac's three Secure Enclave key blobs and the
paired-device list as App-only items under the App service (accounts
device-message-key, device-agreement-key, device-approval-key and
paired-devices), each with its own conflict kind and message. The list
item is the list's serialization followed by an HMAC-SHA256 tag under a
key derived from the vault key (ADR 0013); a list that fails
authentication is refused, not repaired. Reading needs no management
session; one registry per storage epoch commits every list change, and a
save that adds a device needs the management session and the pairing
session's authorization for that device. Local erase stops the device
services first, then deletes the four items with the other App keychain
items; an identity reset closes the epoch and deletes keys, list and
inbox state.

AskKeyAppKit gains Devices/: the CloudKit gate (Info.plist key plus the
signed container and CloudKit entitlements), a backend interface with the
real CloudKit backend, and the coordinator. It creates the identity on
the first pairing, runs pairing as the initiator (waiting for the
iPhone's verified reveal, code entry, system authentication inside the
approval signature, add only with both confirmations), delivery checks,
removal, the iCloud account and push hints, and publishes the state the
Devices section will read. An open pairing sheet holds SessionPolicy's
idle expiry. AppRuntimeConfiguration takes an optional device-services
factory, and AskKeyTestSupport provides the fake one.
- Each key item is now the Secure Enclave blob followed by an HMAC-SHA256
  tag over a label, its account and the blob, under a key derived from
  the vault key. Any process on the Mac can use a blob it holds, so an
  identity this library did not store, such as blobs another App planted
  before the first pairing, is refused and offers the reset.
- Only the latest account check applies: an earlier check that answers
  later no longer overwrites an account change. An unrecorded account is
  adopted only while the list is empty, and creating the identity fails
  if its account cannot be recorded.
- After the device is added, the open sheet keeps polling and holding
  idle expiry until its session expires, so a cancellation that undoes
  the pairing is still fetched; the authorization ends with the add.
- A failed management-session check also drops the pairing
  authorization, and the management observer re-registers before it
  awaits the cancellation.
- Tests wait on time-bounded conditions instead of counted yields.
Vault.endManagementSession() now also drops the pairing authorization, so
a new management session never inherits it, whether management was
locked, expired in the App or its window closed. The planner approved
this one line outside the issue's Vault.swift Scope as a security-boundary
fix found in the cross review.
The sheet's polling loop now ends the session's polling and its hold on
idle expiry whenever the session can no longer change after a turn,
including an expiry that passed while that turn polled; before, such a
turn left the loop without releasing the hold. Ending the management
session after the expiry also releases it. An account check whose
services stopped while it read the list no longer applies.
An account check that ended an open pairing session no longer replaces
the messenger when a newer check switched it, and a new session started,
while the cancellation was sent. The foreign-key test case now uses the
real labels under another key.
@luoji-bot

luoji-bot Bot commented Nov 10, 2026

Copy link
Copy Markdown

Cross review summary (Opus 5.5 and GPT-6.1-Sol, xhigh, read-only, two rounds; in round 2 each read the other's round 1 report and reviewed the fixes). No P0 in either round. Round 1: GPT rated the planted identity P1, Opus P2; both independently found it. Round 2: both found one P2 in a round 1 fix (the post-pairing poll loop), now fixed.

Fixed

  • 06d6b34 (round 1):
    • Key items are authenticated: each is the Secure Enclave blob plus an HMAC-SHA256 tag over a label, its account and the blob, under a key derived from the vault key. Any process on the Mac can use a blob it holds, so another App could plant usable blobs before the first pairing and keep copies; such an identity is now refused and offers the reset (GPT P1, Opus P2).
    • Only the latest account check applies, so an earlier, slower check no longer overwrites an account change with .available (GPT P2).
    • After the add, the open sheet keeps polling and holding idle expiry until the session expires, so a cancellation that undoes the pairing is still fetched (ADR 0013 "polling runs about once a second while a pairing sheet is open"; GPT P2).
    • An unrecorded account is adopted only while the list is empty, and identity creation fails if its account cannot be recorded (GPT P3, Opus P3).
    • A failed management-session check drops the pairing authorization; the management observer re-registers before it awaits; tests wait on time-bounded conditions (Opus P3).
  • d5425fd: Vault.endManagementSession() drops the pairing authorization, so a new management session never inherits it (GPT P2, Opus P3). One line outside the issue's Vault.swift Scope, approved by the planner; paths checked in the receipt.
  • 57e32f1 (round 2): a session that expired while a poll-loop turn was polling left the loop without releasing the idle-expiry hold, keeping management from expiring until the sheet closed (Opus P2, GPT F3). The loop now ends polling and the hold after any such turn, and ending management after the expiry releases it too. Tests for both, plus an account check whose services stopped no longer applies.
  • 3c211ae (round 2): an account switch that cancelled the open pairing re-checks before replacing the messenger, so it cannot discard a session started on a newer account's messenger in the meantime (GPT F5). The foreign-key test case uses the real labels.

Not fixed, and why

  • In-flight messenger work after a local erase (GPT P2 round 1, F1 round 2): the Vault epoch and erase journal refuse every local write of the old services. What remains is that a poll already in flight may recreate the erased device's own inbox zone, or send a confirmation it already queued, so the iPhone may list keys that no longer exist. ADR 0013 accepts both: erase removes the inbox on a best-effort basis and does not notify peers, which keep an entry for a device that no longer exists. No trust is created for any live key.
  • The Vault's own idle expiry followed directly by a new management session (GPT F2): it does not pass through endManagementSession(). In the App the App-level timer of the same length ends the session through it, an open sheet's check renews the Vault session every second or cancels the pairing, and a failed check drops the authorization. Extending beginManagementSession() would widen the approved Vault.swift change, which the planner asked not to do; listed in the receipt.
  • No polling after the person closes the success sheet (GPT F4): the ADR requires polling while the sheet is open. A cancellation missed afterwards leaves the one-sided pairing of ADR 0013 step 8, which shows as "not reached yet".
  • Reset after a failed restore cannot remove the inbox (GPT P3): without a usable key there is no device ID; after Migration Assistant that inbox may belong to the source Mac, so not deleting it is the safer outcome.
  • The idle-expiry hold applies to every session policy (Opus P3): the non-management lock does nothing while a management session is open, and VaultViewModel, which owns the instances, is outside the Scope.
  • Remote notifications are registered before any identity exists (Opus P3): APNs registration is not a CloudKit request, and only the maintainer's Local CloudKit build reaches it.
  • No App-level test of the management observer's wiring, and no restart-recovery test of the four deletions (Opus P3): the coordinator entry points and deleteAllLocalVaultKeys are tested directly.

For the planner

  • The key-item tag format is recorded only in code and tests; ADR 0013 gives no key-item layout and docs/adr is outside this Scope.
  • Handed to Add the Devices section to Mac Settings #288: a dedicated pairing authentication reason and the Simplified Chinese conflict messages.

Reports stay outside the repository.

@sudoHG
sudoHG merged commit d295570 into main Nov 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store the Mac's device identity and pairings and coordinate pairing

1 participant