Secure macOS/iOS account transfer with opt-in iCloud Keychain sync #2
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
Add a secure account-transfer design for the macOS and future iOS clients. This issue is design and implementation acceptance criteria only; it does not implement synchronization or transfer.
Boring secure default: keep the existing local, device-bound Keychain item as the default and offer the one-time, end-to-end-encrypted manual handoff as the first transfer feature. Make same-iCloud-account automatic sync an explicit opt-in only after the Apple entitlement/API questions below are answered on real signed macOS and iOS builds.
Source-grounded storage audit (current implementation)
Account metadata and local data
Sources/MailternalInterfaces/Models.swift—AccountID,IMAPEndpoint, andAccountConfig.AccountConfigcontains only the display name, email address, username, and IMAP host/port/security. Its documentation explicitly says the password is not part of this value.Sources/MailternalStore/Schema.swift—Schema.createV1: SQLite tableaccountshas columns for exactly those non-secret fields.folders.account_idreferencesaccountswith cascade delete; generations, messages, sync state, and seen queue cascade through their folder/generation relationships.error_log.account_idis nullable and has no foreign key.attachment_cacheis global content-hash metadata, not account-scoped.Sources/MailternalStore/MailStore.swift—upsertAccount(_:)binds only theAccountConfigfields and usesINSERT ... ON CONFLICT(id) DO UPDATE;fetchAccount,fetchAccounts, anddeleteAccountare the corresponding reads/delete. There is no secret column and no password serialization into SQLite.App/Sources/Live/MailternalContainer.swift—MailternalContainer.default,databaseURL, andattachmentsDirectory: the live store is the application-supportMailternal/store.sqliteWAL database plus the content-hash attachment directory.wipeAttachmentFiles()removes attachment files only; it does not clearattachment_cacherows orerror_logrows.App/Sources/Support/AppearanceSettings.swift—UserDefaultsis used only for appearance/list preferences (mailternal.appearance.*keys). No account metadata or credential is stored in defaults. The window frame autosave is also unrelated to account identity.Secret storage and access attributes
App/Sources/Support/KeychainStore.swift—KeychainStoreusesSecItemAdd,SecItemUpdate,SecItemCopyMatching, andSecItemDeletefor a generic-password item. The base query iskSecClassGenericPasswordplus the store service andkSecAttrAccount = AccountID.rawValue.KeychainStore.defaultServiceconstant. The current code does not setkSecAttrSynchronizableand does not setkSecAttrAccessGroup; it therefore does not intentionally sync to iCloud or share through a named group.saveToKeychainstores the UTF-8 password and setskSecAttrAccessibletokSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. On update, it updates onlykSecValueData, so it retains the existing item attributes. Reads request only returned data and one match. Deletes use the same class/service/account query and treaterrSecItemNotFoundas success.APP_SANDBOX_CONTAINER_IDis present,baseQueryalso setskSecUseDataProtectionKeychain = true. The production target enables App Sandbox; the Debug configuration overrides App Sandbox and uses its separate debug entitlement file. The source makes the Data Protection choice conditional on that runtime environment variable rather than unconditionally adding it.KeychainStore.Storage.memoryis a process-local,NSLock-protected singleton dictionary keyed by service and account. It is not persisted, not synchronized, and disappears with the process.KeychainStoreErrormaps missing items, non-UTF-8 data, and Security status failures.App/Sources/Live/LiveMailFacade.swift—KeychainCredentialProvider.password(for:)reads fromKeychainStore;LiveMailFacade.restorePersistedAccount,addAccount, andremoveAccountcoordinate the DB row and secret.addAccountvalidates over IMAP before saving, deletes other configured accounts, saves the password, then upserts metadata; on metadata-save failure it deletes the newly saved secret.removeAccountstops the engine, deletes the current secret and DB row, clears UI state, and wipes attachment files. The DB delete is best-effort and attachment/cache/error-log cleanup is not a complete database purge.Sources/MailternalSync/Sync.swift—IMAPCredentialProvideris the engine seam;SyncEnginenever persists the password.Sources/MailternalIMAP/IMAPSession.swiftholds the password only for the session and authenticates with AUTH=PLAIN or LOGIN after TLS; the password is documented as never logged.Update/deletion edge behavior to preserve or deliberately change
AccountIDremoves old Keychain items and account rows before saving the new item. Updating the same ID is an in-place metadata update and a Keychain data update.deleteAccountis a full erase.Entitlements/configuration currently present
App/Mailternal.entitlementshas App Sandbox, user-selected read/write, and network client. It has no iCloud/CloudKit entitlement and no Keychain Sharing access-group entitlement.App/Mailternal-Debug.entitlementshas get-task-allow and network client; it has no iCloud or Keychain Sharing entitlement.App/project.ymluses manual signing, enables sandbox for the production target, disables it for Debug, and passes-mockin the Mailternal run/test scheme.Why the mock validation launch has no previously authenticated real account
App/Sources/MailternalApp.swift—MailternalApp.makeFacade()checksProcessInfo.processInfo.argumentsfor-mockfirst and returns a newMockMailFacade. It never constructsLiveMailFacadeon that path.App/Sources/Model/AppModel.swift—start()callsrestorePersistedAccount()only when the facade is aLiveMailFacade. The mock therefore never opensMailternalContainer, migrates/reads SQLite, or asksKeychainStorefor an item.App/Sources/Mock/MockMailFacade.swift—MockMailFacadestarts with.none, holdsconfigin an instance property, performs only scripted checks and delays inaddAccount, seeds an in-memory mailbox, andremoveAccountjust clears that property/state. It has no Security import, noMailStore, no Keychain call, and no account restore path. Its temporary attachment PNG writes are mock rendering artifacts, not credentials.App/UITests/MailternalUITests.swiftlaunches with-mockand signs into the scripted mock form. This explains both the seeded mailbox and the absence of the previously authenticated real account.Therefore the
-mocklaunch cannot read, update, delete, or overwrite the real Keychain password or real account row: the selected facade has no code path to either. The assertion is source-level isolation, not a claim that a separate non-mock launch is harmless. In particular, Debug-qa-accountis a different path:QALaunch.makeFacade()creates a live facade with a custom QA service usingStorage.memory;seedQAAccountcan write QA metadata to the selected container, but its password is not put in the real Keychain. Keep this distinction explicit in documentation and tests.Track A — same iCloud account, opt-in automatic sync
Proposed split of data
kSecAttrSynchronizable = truewhere the target OS and item type support it. Use a versioned service/account namespace so the existingAfterFirstUnlockThisDeviceOnlyrecord is never accidentally treated as a sync record.Keychain and entitlement requirements
kSecAttrSynchronizablemust be true on create/query/update/delete for the sync namespace. Apple documents that updating/deleting a synchronizable item affects all copies, thatThisDeviceOnlyaccessibility values are incompatible with synchronization, and that synchronizable macOS items use iOS-style access groups. Select a non-ThisDeviceOnlyaccessibility level only after validating the least-privilege choice on both targets.SecAccessControlpolicy can be carried with a synchronizable password. Gate export/use with LocalAuthentication on each device and verify the supported Security combination in a signed-device spike.keychain-access-groupsentitlement must agree. Never hard-code an unowned group or accepterrSecMissingEntitlementas a fallback.CloudKit comparison and limits
CloudKit is appropriate for structured, versioned, private metadata and change notifications, but it is network-dependent and has limited offline caching. It gives the app records/zones and server-side conflict machinery; it is not a replacement for the local SQLite cache or the Keychain. iCloud-synchronizable Keychain is the credential channel and is protected by the user’s iCloud Keychain/device security model. These stores have different availability, conflict, deletion, and recovery behavior and must not be conflated.
Handle no iCloud account, disabled iCloud Keychain, account changes,
CKErrorretries, conflicts, zone deletion, and user-keychain-reset errors without deleting the only local credential. Never put raw/plaintext credentials in CloudKit, in CloudKit assets, in public/shared databases, or in logs/analytics.Track B — cross-account/manual handoff
This is the recommended first cross-device implementation because it does not depend on either device sharing an Apple/iCloud account or on CloudKit.
Threat model and UX requirements
Protect against QR shoulder-surf/replay, transport eavesdropping or tampering, a wrong nearby device, a malicious relay/server, stale CloudKit records, lost devices, iCloud-account changes, and accidental export to the wrong account. QR/public values and transport metadata are assumed observable. E2E authentication, transcript binding, expiry, one-shot state, explicit two-device confirmation, and local authentication protect the credential in transit. Device compromise and a receiver who intentionally retains an already imported password are out of scope; explain that residual trust in the receiver is unavoidable.
The UI must show the source and destination account/endpoint, expiry, and matching short code before either side commits. Make export/import opt-in and visible in account settings; never auto-export after sign-in. Use accessible success/failure/cancel states, no secret in error text, and an explicit “Cancel transfer” action. Offline manual transfer should work over the selected direct channel; automatic sync should remain read-only/local-cache safe while offline.
Migration and lifecycle
Test plan
ThisDeviceOnlynamespaces, access-group failures, first-unlock/device-lock behavior, update propagation, delete propagation, and no cross-app/team access. Run on two real devices with the same iCloud account; test no iCloud account and iCloud Keychain reset/account-change paths.-mocktests and add a regression assertion that mock mode never constructs the live facade or touches Security/store paths.Acceptance criteria
Apple references / decisions requiring confirmation
kSecAttrSynchronizable— sync semantics, update/delete all copies, noThisDeviceOnly, access-group restrictions, and no persistent references.kSecAttrAccessible— accessibility restrictions for macOS and synchronizable items.Before coding, confirm the exact supported
kSecAttrAccessible/kSecAttrSynchronizable/Data Protection/LocalAuthentication combination on the deployment targets, common macOS+iOS access-group provisioning, iCloud account-change behavior, CloudKit private-zone recovery, and the selected proximity transport API.Acceptance note: transferred account metadata (iCloud/QR/Forgejo transfer) MUST preserve AccountLinkID unchanged. It is the cross-device account identity and is distinct from each device's local AccountID/database key; importing metadata must not generate a replacement UUID.