Skip to content

Tell a paired phone when its saved key is unreadable - #1066

Merged
arul28 merged 1 commit into
mainfrom
ade/pairing-gate-credential-state
Aug 10, 2026
Merged

Tell a paired phone when its saved key is unreadable#1066
arul28 merged 1 commit into
mainfrom
ade/pairing-gate-credential-state

Conversation

@arul28

@arul28 arul28 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

A paired iPhone whose saved key cannot be read reported as never paired, so the accountless gate re-presented the same connect sheet the user had just completed — with nothing said about why.

The bug

hasPairedHost answered two different questions with one boolean:

guard let profile = activeHostProfile ?? loadProfile(), profile.authKind == "paired" else { return false }
return tokenForProfile(profile) != nil   // ← a Keychain fault is indistinguishable from "no pairing"

MobileAccessGateView then does if hasPairedHost { onContinue() } else { presentedSheet = .pairMachine }. A user whose Keychain entry is missing, cleared, or unreadable pairs successfully, watches the sheet confirm the machine, taps continue — and gets the pairing sheet again. Forever. They have already done the thing it is asking for, so it reads as the app ignoring them.

This is the same user-facing class as the token_unreadable work in #1019. The accountless gate simply never got that treatment.

The fix

syncPairedHostCredentialState separates the two states:

state meaning what the user is told
notPaired no saved pairing "Continue without an account" → pair
ready pairing + readable credential continue into the app
credentialUnreadable pairing saved, credential unreadable named as a fault, with "Pair again"

hasPairedHost is now defined as pairedHostCredentialState.isUsable, so every existing gate keeps its exact behaviour — an unusable credential still blocks entry, because without the secret the phone genuinely cannot use that pairing. Only the words change. "Pair again" is the existing repair vocabulary on this platform (laneOfflineAction), not a new affordance.

Test

The classifier is pure and takes the credential lookup as a parameter, because the branch that matters is only reachable with a real Keychain fault. The test drives it with a persisted paired profile and a lookup returning nil — standing in for the errSecMissingEntitlement (-34018) read failure — and asserts the state is credentialUnreadable, is not notPaired, and still gates entry.

How this was found

Diagnosing why a simulator build could pair over LAN and forget on relaunch. Evidence gathered:

  • The pairing record persists correctlyade.sync.hostProfile and hostProfiles are written on pair and survive relaunch (dumped from the app container before and after).
  • Every keychain call from the process fails with -34018 "Client has neither application-identifier nor keychain-access-groups entitlements".
  • codesign -d --entitlements :- returns an empty dict for every local build variant, because iOS Simulator builds here carry no entitlements.
  • That simulator's keychain already holds 6 items under access group VQ372F39G6.com.ade.ios from an earlier properly-entitled build — same code path, works when entitled.

So the thing that blocked local verification was an unentitled build, not a product defect, and real devices are unaffected by that symptom. But it surfaced this gate, which is real: a device whose Keychain entry goes bad gets the unexplained loop. That is what this PR fixes.

MobileTrustResetPolicy is not implicated — its flag stays unset precisely because clearAllConnectionTokens() returns false on the entitlement error, and it returns before its delete loop, so it deletes nothing.

Verification

1306 tests, 13 failing cases — byte-identical to the clean-main baseline.

🤖 Generated with Claude Code

Greptile Summary

The PR separates an absent pairing from a saved pairing whose credential cannot be read, while preserving the existing access gate for unusable credentials.

  • Adds a three-state paired-host credential classifier and derives hasPairedHost from its usability.
  • Shows an explicit credential-fault explanation and “Pair again” action in the accountless access gate.
  • Adds unit coverage for absent, readable, unreadable, and non-paired profile states.
  • Registers the new implementation and test files with the iOS project.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/ios/ADE/Services/PairedHostCredentialState.swift Introduces the pure three-state classifier and retains .ready as the sole usable state.
apps/ios/ADE/Services/SyncService.swift Exposes the detailed credential state and preserves the previous boolean gating semantics through isUsable.
apps/ios/ADE/Views/Account/MobileAccessGateView.swift Distinguishes an unreadable saved credential from first-time pairing in the gate’s messaging and repair action.
apps/ios/ADE/App/ContentView.swift Supplies the detailed credential-fault state to the mobile access gate.
apps/ios/ADETests/PairedHostCredentialStateTests.swift Covers classifier behavior for missing profiles, readable credentials, failed lookups, and non-pairing authentication.
apps/ios/ADE.xcodeproj/project.pbxproj Adds the classifier and its tests to their corresponding Xcode targets.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Load active or saved host profile] --> B{Paired profile exists?}
  B -- No --> C[notPaired]
  B -- Yes --> D{Credential lookup succeeds?}
  D -- Yes --> E[ready]
  D -- No --> F[credentialUnreadable]
  C --> G[Offer initial pairing]
  E --> H[Continue into app]
  F --> I[Explain saved-key fault]
  I --> J[Offer Pair again]
Loading

Reviews (3): Last reviewed commit: "fix(ios): tell a paired phone its saved ..." | Re-trigger Greptile

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Aug 10, 2026 7:15am

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@arul28, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a7fc10f-339a-441f-a718-5832ff39005e

📥 Commits

Reviewing files that changed from the base of the PR and between 6df1bb6 and d56fc63.

⛔ Files ignored due to path filters (1)
  • apps/ios/ADE.xcodeproj/project.pbxproj is excluded by !**/*.xcodeproj/project.pbxproj
📒 Files selected for processing (5)
  • apps/ios/ADE/App/ContentView.swift
  • apps/ios/ADE/Services/PairedHostCredentialState.swift
  • apps/ios/ADE/Services/SyncService.swift
  • apps/ios/ADE/Views/Account/MobileAccessGateView.swift
  • apps/ios/ADETests/PairedHostCredentialStateTests.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@arul28
arul28 force-pushed the ade/pairing-gate-credential-state branch from b96a3b1 to 85282fa Compare August 10, 2026 07:00
`hasPairedHost` answered two different questions with one boolean: "has this
phone ever been paired" and "can this phone read the credential for the
pairing it has". A Keychain entry that is missing, cleared out from under the
app, or unreadable made a paired phone report as never-paired.

What that looks like to a user: they pair, the sheet confirms the machine,
and the accountless gate then re-presents the same connect sheet they just
finished — with nothing said about why. They have already done the step it is
asking for, so the app reads as ignoring them. It is the same user-facing
class as the token_unreadable work in #1019; the accountless gate simply
never got that treatment.

`syncPairedHostCredentialState` now separates `notPaired` from
`credentialUnreadable`, and `hasPairedHost` is defined as its `isUsable` case
so every existing gate keeps its exact behaviour — an unusable credential
still blocks entry, because without the secret the phone genuinely cannot use
the pairing. Only the words change: the gate names the fault and offers "Pair
again", which is the existing repair vocabulary on this platform
(`laneOfflineAction`), rather than the unexplained loop.

The classifier is pure and takes the credential lookup as a parameter,
because the branch that matters is only reachable with a real Keychain fault.
The test drives it directly with a persisted paired profile and a lookup that
returns nil — standing in for the `errSecMissingEntitlement` (-34018) read
failure — and asserts the state is `credentialUnreadable`, is not
`notPaired`, and still gates entry.

Found while diagnosing why a simulator build could pair over LAN and forget
on relaunch. That turned out to be an unentitled build with no Keychain
access at all, not a product defect — but it surfaced this gate, which is
real and does affect a device whose Keychain entry goes bad.

1306 tests, same 13 pre-existing failing cases as clean main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arul28
arul28 force-pushed the ade/pairing-gate-credential-state branch from 85282fa to d56fc63 Compare August 10, 2026 07:15
@arul28
arul28 merged commit 2a1d87e into main Aug 10, 2026
37 checks passed
@arul28
arul28 deleted the ade/pairing-gate-credential-state branch August 10, 2026 07:30
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.

1 participant