Skip to content

[fix] SDKS-5172 Defensive recovery for EncryptorError after iCloud device migration#167

Merged
george-bafaloukas-forgerock merged 2 commits into
developfrom
SDKS-5172-se-migration-recovery
Jun 24, 2026
Merged

[fix] SDKS-5172 Defensive recovery for EncryptorError after iCloud device migration#167
george-bafaloukas-forgerock merged 2 commits into
developfrom
SDKS-5172-se-migration-recovery

Conversation

@george-bafaloukas-forgerock

Copy link
Copy Markdown
Contributor

JIRA Ticket

JIRA "[TRIAGE] OIDC authentication where the app returns: "Authorization error: The operation couldn't be completed. (PingStorage.EncryptorError error 1)""

Description

After an iCloud backup / Quick Start device migration, the OIDC token ciphertext migrates to the new device but the Secure Enclave private key does not. This causes every call to OidcClient.token() to fail permanently with EncryptorError.failedToDecrypt. The fix has two complementary parts: (1) catch the decryption failure at the token() call site, delete the unreadable ciphertext, and fall through to re-authentication; (2) set kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly on SecItemAdd so that newly written tokens are device-bound and will not migrate in the first place. Both changes ship forward-only.

Phases completed:

  • Phase 1 — Defensive recovery in OidcClient.token(): adds inner do/catch let error as EncryptorError — deletes corrupted ciphertext, falls through to re-auth; adds import PingStorage
  • Phase 2 — Keychain.save() split into separate deleteQuery (primary key only) and addQuery with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly to prevent iCloud backup migration on new saves
  • Phase 3 — Tests: ThrowingMockStorage, testTokenRecoveryOnDecryptionFailure, testSavedItemHasDeviceOnlyAccessibility, testUpgradePathDoesNotThrowDuplicateItem (mandatory upgrade-path test verifying no errSecDuplicateItem on first save after upgrade)
  • Phase 4 — revoke() hardened: try? on config.storage.get() replaced with explicit do/catch so the documented workaround actually clears the corrupted keychain entry

Note: endSession(signOff:) has a similar swallow pattern (known deferred — no impact on the authentication lockout scenario). The ordering test testTokenRecoveryDeletesStorageBeforeReAuth was not implemented (production code order is clearly correct; follow-up if needed).

Checklist:

  • I ran all unit tests and they pass
  • I added test case coverage for my changes

…vice migration

After an iCloud backup / Quick Start device migration, the OIDC token ciphertext
migrates to the new device but the Secure Enclave private key does not, causing every
OidcClient.token() call to fail permanently with EncryptorError.failedToDecrypt.

Phases:
- Phase 1: Inner do/catch in token() catches EncryptorError, deletes corrupted token, falls through to re-auth
- Phase 2: Keychain.save() split queries — delete by primary key only, add with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- Phase 3: Tests — ThrowingMockStorage, testTokenRecoveryOnDecryptionFailure, testSavedItemHasDeviceOnlyAccessibility, testUpgradePathDoesNotThrowDuplicateItem
- Phase 4: revoke() hardened — try? storage.get() replaced with explicit do/catch to actually clear corrupted token on EncryptorError

Refs: SDKS-5172
CHANGELOG: entry added for SDKS-5172.

@vahancouver vahancouver left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix addresses the reported issue. However there might be other edge cases.
Please see my inline comments.

Plus we we could add tests for those cases

  • No test for delete() throwing during recovery (ThrowingMock has no
    throwOnDelete flag, so the infinite loop is untestable)
  • No test for non-EncryptorError from storage.get()
  • No test for revoke() with a transient storage error
  • No test verifying the old token survives a failed save() (which would catch
    the data loss regression)

Comment thread Storage/Storage/KeychainStorage.swift
Comment thread Oidc/Oidc/OidcClient.swift
Comment thread Oidc/Oidc/OidcClient.swift Outdated
Comment thread Oidc/Oidc/OidcClient.swift Outdated
…en deletion

Addresses Vahan's review on PR #167:
- Keychain.save() now encrypts BEFORE deleting the existing item, so a failed
  encrypt (e.g. SE key transiently unavailable) never leaves the slot empty.
- token() recovery surfaces a failure if delete() cannot clear the corrupted
  token, instead of falling through to re-auth and looping forever.
- token() recovery now also handles DecodingError (corrupt/incompatible payload),
  not just EncryptorError; transient errors still propagate without deletion.
- revoke() only deletes on EncryptorError/DecodingError; transient read errors
  (e.g. errSecInteractionNotAllowed while locked) leave a valid token intact.

Tests:
- testExistingTokenSurvivesFailedSave (data-loss regression guard)
- testTokenRecoveryFailsWhenDeleteFails (infinite-loop guard)
- testTokenDoesNotRecoverOnTransientStorageError
- testRevokeLeavesTokenIntactOnTransientStorageError
- ThrowingMock gains throwOnDelete + configurable getError

Refs: SDKS-5172
@george-bafaloukas-forgerock

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @vahancouver — all four points were valid (two were regressions this PR introduced). Addressed in 73798ee:

Comment Fix
save() delete-before-encrypt data loss Encrypt first, then delete, then add — slot is never left empty on a failed encrypt
Infinite loop when delete() fails Return .failure instead of falling through to re-auth
revoke() catch-all deletes valid tokens Scope deletion to EncryptorError/DecodingError; transient errors leave the token intact
non-EncryptorError from get() misclassified Recover from DecodingError too; transient errors still propagate without deletion

Added all four tests you suggested:

  • testExistingTokenSurvivesFailedSave (data-loss regression guard)
  • testTokenRecoveryFailsWhenDeleteFails (infinite-loop guard)
  • testTokenDoesNotRecoverOnTransientStorageError
  • testRevokeLeavesTokenIntactOnTransientStorageError

ThrowingMock gained a throwOnDelete flag and a configurable getError to drive these. Full run: 57/57 OidcTests + 6/6 KeychainStorageTests green. Ready for another look.

@vahancouver vahancouver left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@spetrov spetrov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

Comment thread Oidc/Oidc/OidcClient.swift
@george-bafaloukas-forgerock george-bafaloukas-forgerock merged commit 11cbe4a into develop Jun 24, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants