Skip to content

Add the pairing-only iPhone app - #295

Merged
sudoHG merged 4 commits into
mainfrom
sudoHG/t7-ios-pairing-app-522835
Nov 10, 2026
Merged

sudoHG merged 4 commits into
mainfrom
sudoHG/t7-ios-pairing-app-522835

Conversation

@sudoHG

@sudoHG sudoHG commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #289

Summary

  • A pairing-only iPhone app in iOS/, generated with XcodeGen: AskKeyiOSKit (framework) holds the logic and views, the app target AskKeyiOS (com.sudohg.askkey.ios, iOS 17, iPhone only) only starts it, and AskKeyiOSKitTests runs without an app host. The kit links AskKeyDevices from the root package; nothing in Sources/ changed.
  • Welcome checks the Secure Enclave, Face ID or Touch ID, the build's CloudKit configuration and the iCloud account (each failure has its own explanation, including the wireless-data prompt from Stage 1: verify CloudKit and push on a Developer ID Mac and an iPhone #281), asks for notifications, then stores the three key blobs (message and key-agreement keys after first unlock without per-use authentication; approval key when unlocked with biometryCurrentSet) and an empty list in the data protection keychain (this device only, after first unlock, shared access group) and creates the inbox and subscriptions.
  • Pairing: the scanner reads QR codes only and says to scan only the code in Ask Key on the person's own Mac; invalid, foreign, expired, key-conflict and limit codes have their own messages. The pairing screen shows 123 456 only when the messenger reports showingCode (verified challenge matching the QR code and a saved reveal), says in both states that the code appears only there and is never sent in a notification or a message, offers Confirm (approval key, so every confirmation asks for Face ID or Touch ID with no passcode button) and Cancel (a withdrawal before a challenge), and shows success only after the Mac's verified confirmation. After 60 seconds without a challenge it says pairing failed, to cancel on the Mac and not to enter any code there.
  • Paired Macs (name, paired date, last reached / not reached yet, Remove with a confirmation and an unpair notice); the inbox is processed when the app becomes active or a push for its own inbox arrives (a push is only a hint); delivery checks are answered at once with the message key; each answered check is recorded without content (arrival time, record name, push or polling) in the unified log, in local state and on the Paired Macs screen. An unusable identity offers one reset that deletes the keys and the list and, when CloudKit answers within 10 seconds, the inbox.
  • scripts/build-ios.sh (build, test, export), the CI job ios-app, an iPhone section in docs/testing.md, and CJK allowlist entries for the two catalogs and the zh-Hans InfoPlist.strings.

Diff stat

Head 5618999 (commits ccc5824 app, cce62b0 round 1 review fixes, c85c764 round 2 review fixes, 5618999 planner's wording fixes: the scanner says QR code, not pairing code, and the Chinese for "Not reached yet"). git diff --stat origin/main: 38 files changed, about 5,600 insertions. New: iOS/ (project.yml; app target 5 files; kit 15 Swift files and its String Catalog with 92 strings; tests 10 files), scripts/build-ios.sh. Changed: .github/workflows/ci.yml (+42, new job only), docs/testing.md (+19), scripts/check_hygiene.py (+3), .gitignore (+3). Most of the line count is the two .xcstrings files.

Tests

  • bash scripts/build-ios.sh test at 5618999: AskKeyiOSKitTests 58 passed, 0 failed, 0 skipped, 28 screens rendered, both locally (Xcode 27.0, iPhone 17e simulator, iOS 27.0, unsigned, no app host) and in CI ios-app (Xcode 26.6). main has no iPhone tests. The later commits added 5 tests to the 53 listed below: a keychain read failure at launch, a push before launch finishes, a push while running, a build without CloudKitContainerIdentifier creating no transport, and the scanner texts never calling the QR code a pairing code.
    • WelcomeTests 13: each welcome failure (no Secure Enclave; Face ID / Touch ID not enrolled, no passcode, turned off, absent; build without CloudKit; iCloud signed out, restricted, unavailable; key creation; keychain; network with retry keeping the same keys; other iCloud failures and pending subscriptions), no transport and no key before the local checks pass, launch with a stored identity, and a retry after an unreadable keychain never replacing a stored identity.
    • PairingFlowTests 14 + 1: invalid, foreign and expired codes; no code before the challenge; no code until the reveal is saved, with the retry keeping its message ID; the code matches what the Mac expects; confirm signs with the approval key and success waits for the Mac; cancelled and failed Face ID send nothing and can be retried; Cancel without a challenge sends a withdrawal, after the code a cancellation; 60 seconds without a challenge; Mac cancelling; unusable approval key; printing a stage never reveals the code.
    • PairedMacsTests 10: removal and its unpair notice (sent and not sent); delivery-check reply without a prompt, recorded as polling; a delivered notification recorded with its arrival time and cleared; a push for another inbox ignored; the Mac's unpair notice; messages from a removed Mac refused; signed-out account; account change; token stored with the account.
    • IdentityResetTests 5, LocalizationTests 3, PushHintReaderTests 2, ScreenRenderTests 6 (14 screen states × English and Simplified Chinese).
  • swift build (local): Build complete!. No SwiftPM source or manifest changed. CI build-and-test at 5618999: swift test 1853 tests, 3 skipped, 0 failures; Automation tests 288 ran, OK.

Checks

  • bash scripts/build-ios.sh build: simulator build, unsigned, succeeds (no warnings with -quiet). df -h /System/Volumes/Data showed 184 GiB free before building.
  • 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.
  • The build-ios.sh catalog check passes: every entry of both catalogs has English and Simplified Chinese with matching placeholders, every key the kit uses exists, and the app catalog holds push.message.title and push.message.body.
  • CI at 5618999, run 42775267174: build-and-test, basic-ui-flows, ios-app and release-sdk-window-check all success.
  • Rendered screens (each screen state in English and Simplified Chinese, 28 PNGs: welcome, welcome network failure, scanner with the camera unavailable, scanner with an expired code, pairing with no code yet, pairing code, Face ID cancelled, confirmed, no challenge, paired, paired Macs with one Mac, reached with delivery records, empty and signed out, unusable identity): artifact askkey-ios-screens. The maintainer reviews the English and Chinese screens before merge.
  • Not run: bash scripts/build-ios.sh export (needs the maintainer's team and devices; Verify device pairing on real devices in CloudKit Production #290), the app in a simulator (not launched on the maintainer's Mac), desktop E2E (CI). Signing, the Secure Enclave, Face ID, the keychain access group, CloudKit and push are verified on a real iPhone in Verify device pairing on real devices in CloudKit Production #290.

Deviations and questions

The planner accepted deviations 1 and 2 below (the three Scope deviations the cross review listed) on 2026-11-10; no change needed. Reasons: the account check itself needs the CloudKit container, so keys are created in memory first and nothing is stored until it passes; a stored identity exists only after a passed welcome, and the container is created only in a build whose Info.plist names it, which only the signed Ad Hoc export does (CI and tests never reach it: the simulator build has an empty CloudKitContainerIdentifier and no entitlements, the tests use a fake factory, and CloudKitGateTests checks that a bundle without the key creates no transport); the push alert keys must be in the app's main bundle, hence a second catalog and a third allowlist entry.

  1. Two String Catalogs. iOS looks up the AlertMessage subscription's push.message.title and push.message.body in the app's main bundle, so the app target has its own small catalog with those two keys; every UI string is in the kit's catalog, which the hostless tests and renders read. The CJK allowlist therefore has three entries (two catalogs and the strings file) instead of two.
  2. When the container is created. CloudKitDeviceTransport needs the device ID, and CloudKitContainerService is internal, so the welcome flow creates the three Secure Enclave keys in memory after the Secure Enclave, Face ID / Touch ID and CloudKit-configuration checks pass, then creates the transport and checks the account. Nothing is stored until the account check passes, and a failed check discards the in-memory keys. At a later launch with a stored identity (which only a passed welcome creates) the transport is created at launch, in a configured build only. A build "carries the iCloud entitlement" when its Info.plist has a non-empty CloudKitContainerIdentifier, which only build-ios.sh export sets, together with the signed entitlements.
  3. Keychain naming. ADR 0013 fixes the access group and the account names; the iPhone service is com.sudohg.askkey.ios.devices, and the access group reaches the code through the Info.plist key KeychainAccessGroup ($(AppIdentifierPrefix)com.sudohg.askkey.ios.shared), which the export reads back against the signed entitlement.
  4. Export signing. export uses automatic signing with -allowProvisioningUpdates for the team in ASKKEY_APPLE_TEAM_ID and the release-testing method (Ad Hoc's current name), with iCloudContainerEnvironment = Production; it does not take a downloaded profile. If the maintainer prefers manual profiles, that is a small change.
  5. Delivery records are also shown (the last five) in a "Delivery Checks" section of the Paired Macs screen, so Verify device pairing on real devices in CloudKit Production #290 can read the arrival time on the phone; the log uses subsystem com.sudohg.askkey.ios, category Devices.
  6. Notification permission is asked for but a refusal does not block setup. No remote-notification background mode: in stage 2 checks are answered when the app opens or a push arrives while it runs.
  7. No app icon: the Scope lists no asset catalog. Follow-up if wanted before Verify device pairing on real devices in CloudKit Production #290.
  8. The empty list tells the person to open Settings → Devices → Pair a Device on the Mac (Chinese: “设置”、“设备”、“配对设备”); Add the Devices section to Mac Settings #288 should use the same labels.
  9. Sources/AskKeyDevices/: no changes. The module maps a network failure of the account check (CKError 3 before the wireless-data prompt is answered) to temporarilyUnavailable, so the welcome's "iCloud unavailable" text also gives the connection and wireless-data guidance instead of the module being changed.

Review

Two rounds of cross review (Opus 5.5 and GPT-6.1-Sol, xhigh, read-only; round 2 swapped reports): no P0 or P1; every must-fix item fixed in cce62b0 and c85c764. Summary: #295 (comment)

The app pairs an iPhone with a Mac as ADR 0013 specifies: the welcome
screen checks the Secure Enclave, Face ID or Touch ID and the iCloud
account, explains the wireless-data prompt, asks for notifications, then
creates the three keys with the iPhone's protection and the inbox. The
scanner reads QR codes only and says to scan only the code in Ask Key on
the person's own Mac. The pairing screen shows the code only once the
messenger reports it, after a verified challenge and a saved reveal, says
that the code appears only there, and confirms with the approval key.
Paired Macs can be removed; delivery checks are answered when the app
opens or a push arrives, and each one is recorded without content.

AskKeyiOSKit holds the logic and views; the app target only starts it.
The tests run without an app host, with fakes for keys, keychain and
transport and a second messenger as the Mac, and render every screen
state in English and Simplified Chinese. scripts/build-ios.sh generates
the project with XcodeGen, builds and tests for the simulator, and
exports the maintainer's Ad Hoc build; CI runs it in the ios-app job.
- A keychain read that fails at launch shows the welcome screen's
  keychain message with Try Again instead of offering the reset.
- Pushes that arrive before the identity is restored (opening a
  notification cold-launches the app) are kept and become hints once it
  is, so the delivery check is recorded as found through the push.
- A pairing request that finishes after Cancel is cancelled only when no
  newer pairing replaced it.
- When the account check cannot reach iCloud, for example before the
  wireless-data prompt is answered, the welcome and list texts give the
  connection and wireless-data guidance.
- build-ios.sh picks a simulator whose runtime the selected Xcode supports.
- A pairing request that finishes after Cancel is also cancelled when a
  newer pairing sheet is open but has not sent its own request yet.
- Test that a build without CloudKitContainerIdentifier never creates a
  transport.
@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 the executor's response). No P0 or P1 in either round. Round 2: Opus found nothing left to fix; GPT kept one P2 on a round 1 fix (a late cancel skipped in the reverse order), now fixed. Focus as asked for risk:security: when and where the code is shown, keychain access control, the approval key authenticating every time, never acting on notification text, camera and network permission explanations.

Checked and found correct by both reviewers

  • The code appears only on the pairing screen and only when the messenger reports showingCode (a verified challenge whose card matches the QR code, and a reveal CloudKit saved; a failed save is retried with the same message ID). Both states say the code appears only there and is never sent in a notification or a message. The code is shown with Text(verbatim:), has no accessibility or test identifier, is never logged, and PairingCodeDisplay's description and mirror are redacted.
  • Keychain items use the data protection keychain, AfterFirstUnlockThisDeviceOnly, not synchronizable, in the shared access group; an update also rewrites the accessibility. Secure Enclave protections match ADR 0013 (message and key-agreement keys after first unlock without per-use authentication; approval key when unlocked with biometryCurrentSet).
  • Every confirmation uses a new LAContext with an empty fallback title, invalidated afterwards; a cancelled or failed prompt sends nothing and can be retried (the test asks three times and sends once).
  • A push is only a hint: only CKQueryNotifications for this device's own zone and subscriptions are read; no text, category or action is used; no banner in front.
  • The camera reads QR codes only; camera, Face ID and wireless-data explanations exist in English and Chinese. No container is created on the launch path of a build without CloudKitContainerIdentifier, which only the Ad Hoc export sets.

Fixed

  • cce62b0 (round 1):
    • A keychain read that fails at launch shows the keychain message with Try Again instead of offering the reset (GPT P2).
    • Pushes that arrive before the identity is restored, as when opening a notification cold-launches the app, are kept and become hints, so Verify device pairing on real devices in CloudKit Production #290 sees the delivery check recorded as found through the push (GPT P2, Opus P2).
    • A request that finishes after Cancel is cancelled unless a newer pairing replaced it (GPT P2).
    • When the account check cannot reach iCloud (CKError 3 before the wireless-data prompt is answered, which the module maps to temporarilyUnavailable), the welcome and list texts give the connection and Settings → Ask Key → Wireless Data guidance, without changing Sources/AskKeyDevices/ (Opus P2).
    • build-ios.sh picks a simulator whose runtime major version the selected Xcode supports (Opus P2, unconfirmed risk).
    • New tests: keychain read failure at launch, a push before launch finishes, a push while running.
  • c85c764 (round 2):
    • The late cancel now also covers the reverse order (a newer sheet opened but not yet scanning), using isSessionOpen (GPT P2; the one-line form Opus suggested).
    • A regression test that a bundle without CloudKitContainerIdentifier never creates a transport, as the planner asked.

Not fixed, and why

  • The 80 GiB check also runs in CI: the repository rule, the same check run-e2e.sh runs in CI; ios-app passed on macos-latest. Both reviewers accepted it in round 2.
  • Scope deviations disclosed in the PR body (keys in memory and the container before the account check; the transport at a later launch with a stored identity; three CJK allowlist entries): accepted by the planner; see the PR body.

For #290 (real iPhone, CloudKit Production)

  1. Whether the notification-opened callback arrives before or after the identity is restored on a cold launch (the fix covers both).
  2. On a Chinese-market iPhone, which welcome message appears before the wireless-data prompt is answered.
  3. build-ios.sh export: automatic signing, aps-environment = production, the Production container environment and the keychain access group in the signed entitlements.

Tests after the fixes: bash scripts/build-ios.sh test 57 passed, 0 failed, 28 screens rendered. bash scripts/build-ios.sh build, swift build, hygiene and the module check pass.

"Pairing code" means only the six digits the iPhone shows. The scanner's
instruction and its invalid, expired, foreign and key-conflict messages
now speak of the QR code in English and Chinese, and the Chinese for
"Not reached yet" reads as the Mac not having reached this iPhone yet.
A test keeps the scanner texts from calling the QR code a pairing code.
@sudoHG
sudoHG merged commit dc0bca5 into main Nov 10, 2026
4 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.

Add the pairing-only iPhone app

1 participant