Skip to content

KM-17456: Fix Mac Catalyst reconnect keychain race - #370

Merged
kp-diego-trevisan merged 2 commits into
masterfrom
fix/mac-catalyst-reconnect-keychain-race
Jul 24, 2026
Merged

KM-17456: Fix Mac Catalyst reconnect keychain race#370
kp-diego-trevisan merged 2 commits into
masterfrom
fix/mac-catalyst-reconnect-keychain-race

Conversation

@kp-diego-trevisan

@kp-diego-trevisan kp-diego-trevisan commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Changes Keychain.set(password:for:) to update the keychain item in place via SecItemUpdate (falling back to SecItemAdd only when the item doesn't exist yet), instead of deleting the item and re-adding it.

Why

The previous delete-then-add approach had two problems on the VPN reconnect path:

  • Stale persistent reference. Delete + add creates a new keychain item with a new persistent reference, invalidating the passwordReference already baked into the saved NEVPNProtocolConfiguration. On reconnect the tunnel could no longer resolve the credential → auth failure. SecItemUpdate preserves item identity, so the reference stays valid.
  • Read gap. With a keychain access group shared between the app and the Network Extension, a concurrent read landing between the delete and the add saw errSecItemNotFound. Updating in place is atomic and removes that window.

This is the standard update-in-place pattern used by Apple's own keychain sample code.

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 1m 51s —— View job


Review — PR #370: Fix Mac Catalyst reconnect keychain race

  • Read changed file (Keychain.swift)
  • Review diff against origin/master
  • Analyze call sites & impact (KeychainStore, VPN profiles)
  • Post review feedback

Verdict: LGTM. This is the correct fix and it's implemented cleanly. Switching set(password:for:) from delete-then-add to SecItemUpdate (with an SecItemAdd fallback) is exactly the standard update-in-place pattern, and the rationale in the description holds up against the code.

I traced the impact to confirm the "stale persistent reference" claim: IKEv2Profile.generatedProtocol (IKEv2Profile.swift:227-243) and PIATunnelProfile (PIATunnelProfile.swift:329-336) both bake passwordReference into the saved NEVPNProtocolConfiguration. On an OS-driven reconnect the persisted config is reused without regenerating, so a ref invalidated by delete-then-add would break auth. SecItemUpdate preserves item identity, so the ref stays valid. ✅

What I like

  • Core fix is right. SecItemUpdate preserves the persistent reference and is atomic, closing both the stale-ref and the shared-access-group read-gap windows described in the ticket.
  • Nice incremental hardening. The new guard let data = password.data(using: .utf8) (line 85) is an improvement over the old code, which silently passed a possibly-nil kSecValueData into SecItemAdd.
  • Correct match query. kSecAttrAccessible is only in attributesToUpdate, not the match query, so the update finds the item regardless of its current accessibility and then normalizes it. Good.

Suggestions (all minor / optional)

  1. Residual add-path race (errSecDuplicateItem). The errSecItemNotFound → SecItemAdd fallback still has a narrow TOCTOU window on a first-time concurrent write — a losing racer's SecItemAdd returns errSecDuplicateItem, which hits default: and throws. Left an inline comment with a retry-the-update suggestion. Narrow edge; only matters if set(password:) can be called concurrently for a brand-new account.
  2. Test coverage. No test accompanies the fix. Since the whole point is ref stability across re-sets, a regression test would be valuable and cheap — e.g. set → capture passwordReferenceset again with a new value → assert the reference bytes are unchanged and password(for:) returns the new value. PIALibraryTests already exercises the keychain (see DIPTokenKeychainTests.swift), so there's a home for it. Fix this →
  3. Consistency (informational, not for this PR). The other setters in this file — set(username:), set(publicUsername:), setTokenData, and the DIP token/relation/favorites setters — still use delete-then-add. Scoping the fix to password is fine since that's the value referenced by the persisted VPN config, but if any of those are read across the app/extension boundary they could exhibit the same read-gap. Worth a follow-up ticket if so.

Threading / memory / security

  • No retain-cycle or memory concerns (value-type dictionaries, no captures).
  • Keychain calls remain synchronous on the caller's thread as before — unchanged by this PR.
  • No secrets logged; credentials stay in the Keychain. kSecAttrAccessibleAfterFirstUnlock is preserved. ✅

Suggested test flows

  • Mac Catalyst reconnect (the ticket's scenario): connect → let it sit → trigger an OS-driven reconnect after a token/password refresh; confirm no auth failure.
  • IKEv2 and OpenVPN (WG uses a different credential path) reconnect after a VPN-token rotation, since both read passwordReference.
  • Fresh login / logout / re-login (exercises the errSecItemNotFound → SecItemAdd fallback).
  • DIP region connect (the dipPassword() branch bypasses passwordReference, so should be unaffected — worth a sanity check).

Comment on lines +103 to 113
case errSecItemNotFound:
// No existing item — add a fresh one.
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
query[kSecValueData as String] = data
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError.add
}
default:
throw KeychainError.add
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — residual add-path race / errSecDuplicateItem. The SecItemUpdate path is now atomic, which nicely closes the read gap for the common (item-exists) case. But the errSecItemNotFoundSecItemAdd fallback reintroduces a small TOCTOU window: if two callers race on a first-time write, both can see errSecItemNotFound from the update, and the second SecItemAdd returns errSecDuplicateItem, which falls into default: and throws KeychainError.add.

Consider handling the duplicate by retrying the update, so a concurrent first-write resolves instead of failing:

case errSecItemNotFound:
    query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
    query[kSecValueData as String] = data
    let addStatus = SecItemAdd(query as CFDictionary, nil)
    switch addStatus {
    case errSecSuccess:
        break
    case errSecDuplicateItem:
        // Lost an add race — the item now exists, so update in place.
        query.removeValue(forKey: kSecAttrAccessible as String)
        query.removeValue(forKey: kSecValueData as String)
        guard SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) == errSecSuccess else {
            throw KeychainError.add
        }
    default:
        throw KeychainError.add
    }

This is a genuinely narrow edge and may not be worth it if set(password:) is never called concurrently for a brand-new account — your call. Also worth noting: SecItemUpdate updates all items matching the query, so if duplicate entries for the same account ever existed, the old delete-then-add collapsed them to one, whereas this keeps them all. Not a concern in normal operation.

@kpkb-1f8e9813897fe9831983e89f7143

Copy link
Copy Markdown

KB review — 3fedfda

No findings changes on this PR relative to the default branch. ✅

View in KB

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 54s —— View job


Follow-up review — commit aa1eafcc

  • Read updated Keychain.swift
  • Verify errSecDuplicateItem handling (point 1)
  • Assess test-coverage rationale (point 2)
  • Acknowledge follow-up scope (point 3)

Thanks for the thorough follow-up, @kp-diego-trevisan. I re-read the updated set(password:for:) and checked your claims. Still LGTM — the new commit resolves point 1 correctly and your reasoning on 2 and 3 holds up.

1. errSecDuplicateItem retry — correct. Keychain.swift:103-121 now does exactly the right thing: on errSecItemNotFound it tries SecItemAdd, and if that loses a first-write race (errSecDuplicateItem) it strips kSecValueData/kSecAttrAccessible back out of the match query and retries SecItemUpdate with attributesToUpdate. The item-identity guarantee is preserved on the recovery path too, so the persistent reference stays valid even for a racer that lost the initial add. Clean.

One tiny, non-blocking observation: the errSecDuplicateItem branch has no explicit return, but since it's the tail of the switch/function the fall-through is a normal return — behaviorally fine, just slightly less symmetric than the other arms that return explicitly. Not worth a change.

2. No regression test — rationale accepted. I verified the constraint you described: DIPTokenKeychainTests (and its methods) are listed under skippedTests in TestPlans/PIA-VPN-iOS.xctestplan:48-53, confirming keychain tests are structurally skipped in this target. My original suggestion assumed PIALibraryTests could exercise the real keychain in CI; your errSecMissingEntitlement (-34018) finding — no host app, no keychain-access-group entitlement — explains why it can't, and why a new persistent-ref test would either be dead-skipped (false comfort) or break CI. Agreed that making it runnable is a separate infra change (host app target with keychain entitlements), out of scope here. Fair call.

3. Scoping to password — agreed, follow-up ticket is the right move. Confirming the reasoning: password is the value baked into the persisted NEVPNProtocolConfiguration via passwordReference, so it's the one with the stale-ref failure mode on reconnect. The other setters (set(username:), set(publicUsername:), setTokenData, DIP/favorites) share the delete-then-add pattern but aren't referenced by a persisted OS config, so the fix is genuinely lower-priority for them. Tracking the read-gap question for those in a follow-up ticket is the right disposition.

Nothing further from me — the fix is complete and correct as it stands.
· fix/mac-catalyst-reconnect-keychain-race

@kp-diego-trevisan
kp-diego-trevisan merged commit ae9450a into master Jul 24, 2026
3 checks passed
@kp-diego-trevisan
kp-diego-trevisan deleted the fix/mac-catalyst-reconnect-keychain-race branch July 24, 2026 13:24
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.

3 participants