Secure macOS/iOS account transfer with opt-in iCloud Keychain sync #2

Open
opened 2026-09-01 13:14:27 +00:00 by kayg · 1 comment
Owner

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.swiftAccountID, IMAPEndpoint, and AccountConfig. AccountConfig contains 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.swiftSchema.createV1: SQLite table accounts has columns for exactly those non-secret fields. folders.account_id references accounts with cascade delete; generations, messages, sync state, and seen queue cascade through their folder/generation relationships. error_log.account_id is nullable and has no foreign key. attachment_cache is global content-hash metadata, not account-scoped.
  • Sources/MailternalStore/MailStore.swiftupsertAccount(_:) binds only the AccountConfig fields and uses INSERT ... ON CONFLICT(id) DO UPDATE; fetchAccount, fetchAccounts, and deleteAccount are the corresponding reads/delete. There is no secret column and no password serialization into SQLite.
  • App/Sources/Live/MailternalContainer.swiftMailternalContainer.default, databaseURL, and attachmentsDirectory: the live store is the application-support Mailternal/store.sqlite WAL database plus the content-hash attachment directory. wipeAttachmentFiles() removes attachment files only; it does not clear attachment_cache rows or error_log rows.
  • App/Sources/Support/AppearanceSettings.swiftUserDefaults is 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.swiftKeychainStore uses SecItemAdd, SecItemUpdate, SecItemCopyMatching, and SecItemDelete for a generic-password item. The base query is kSecClassGenericPassword plus the store service and kSecAttrAccount = AccountID.rawValue.
  • The live default service is the KeychainStore.defaultService constant. The current code does not set kSecAttrSynchronizable and does not set kSecAttrAccessGroup; it therefore does not intentionally sync to iCloud or share through a named group.
  • On add, saveToKeychain stores the UTF-8 password and sets kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. On update, it updates only kSecValueData, so it retains the existing item attributes. Reads request only returned data and one match. Deletes use the same class/service/account query and treat errSecItemNotFound as success.
  • If APP_SANDBOX_CONTAINER_ID is present, baseQuery also sets kSecUseDataProtectionKeychain = 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.memory is a process-local, NSLock-protected singleton dictionary keyed by service and account. It is not persisted, not synchronized, and disappears with the process. KeychainStoreError maps missing items, non-UTF-8 data, and Security status failures.
  • App/Sources/Live/LiveMailFacade.swiftKeychainCredentialProvider.password(for:) reads from KeychainStore; LiveMailFacade.restorePersistedAccount, addAccount, and removeAccount coordinate the DB row and secret. addAccount validates over IMAP before saving, deletes other configured accounts, saves the password, then upserts metadata; on metadata-save failure it deletes the newly saved secret. removeAccount stops 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.swiftIMAPCredentialProvider is the engine seam; SyncEngine never persists the password. Sources/MailternalIMAP/IMAPSession.swift holds 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

  • Replacing an account with a different AccountID removes 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.
  • Deleting an account cascades account-owned mailbox data through the schema, but leaves unscoped error-log/cache metadata and relies on the attachment file wipe for bytes. Any transfer/sync design must specify its own remote tombstone and local cleanup semantics rather than assuming deleteAccount is a full erase.

Entitlements/configuration currently present

  • App/Mailternal.entitlements has 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.entitlements has get-task-allow and network client; it has no iCloud or Keychain Sharing entitlement. App/project.yml uses manual signing, enables sandbox for the production target, disables it for Debug, and passes -mock in the Mailternal run/test scheme.

Why the mock validation launch has no previously authenticated real account

  • App/Sources/MailternalApp.swiftMailternalApp.makeFacade() checks ProcessInfo.processInfo.arguments for -mock first and returns a new MockMailFacade. It never constructs LiveMailFacade on that path.
  • App/Sources/Model/AppModel.swiftstart() calls restorePersistedAccount() only when the facade is a LiveMailFacade. The mock therefore never opens MailternalContainer, migrates/reads SQLite, or asks KeychainStore for an item.
  • App/Sources/Mock/MockMailFacade.swiftMockMailFacade starts with .none, holds config in an instance property, performs only scripted checks and delays in addAccount, seeds an in-memory mailbox, and removeAccount just clears that property/state. It has no Security import, no MailStore, no Keychain call, and no account restore path. Its temporary attachment PNG writes are mock rendering artifacts, not credentials.
  • App/UITests/MailternalUITests.swift launches with -mock and signs into the scripted mock form. This explains both the seeded mailbox and the absence of the previously authenticated real account.

Therefore the -mock launch 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-account is a different path: QALaunch.makeFacade() creates a live facade with a custom QA service using Storage.memory; seedQAAccount can 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

  1. Keep the local SQLite database as the offline cache. Add a versioned CloudKit private database record for non-secret account metadata: stable account ID, display name, email address, username, IMAP endpoint/security, schema version, and transfer/sync tombstone/version fields. Use a private database, never public records.
  2. Store the password (and future refresh tokens, if ever introduced) as a generic-password Keychain item with kSecAttrSynchronizable = true where the target OS and item type support it. Use a versioned service/account namespace so the existing AfterFirstUnlockThisDeviceOnly record is never accidentally treated as a sync record.
  3. Do not upload a password, token, credential blob, or raw credential ciphertext to CloudKit. CloudKit encrypted fields are server-managed field encryption backed by iCloud Keychain, not a reason to put credentials in a record. If a future design needs sensitive non-credential metadata, require app-level encryption and a separate review; the default schema has no credential fields or assets.
  4. On a new device, fetch private metadata, locate the synchronized Keychain item, and require local device authentication before using it. If either side is unavailable, show setup/re-authentication rather than silently downgrading to a server password prompt.

Keychain and entitlement requirements

  • kSecAttrSynchronizable must be true on create/query/update/delete for the sync namespace. Apple documents that updating/deleting a synchronizable item affects all copies, that ThisDeviceOnly accessibility values are incompatible with synchronization, and that synchronizable macOS items use iOS-style access groups. Select a non-ThisDeviceOnly accessibility level only after validating the least-privilege choice on both targets.
  • Do not assume a biometric SecAccessControl policy 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.
  • If the macOS and iOS targets need a common item, enable Keychain Sharing in both signed targets, use one explicit access-group name authorized to the same Apple Developer Team ID, and keep each app’s private group separate for unrelated secrets. Provisioning profiles, bundle IDs, team/application identifiers, and the keychain-access-groups entitlement must agree. Never hard-code an unowned group or accept errSecMissingEntitlement as a fallback.
  • Add only the required iCloud capability/container and CloudKit private-database configuration to both targets. Confirm iCloud account availability, account-change notifications, first-unlock behavior, simulator/device behavior, and production container schema promotion. Current entitlements have none of this configured.

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, CKError retries, 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.

  1. Sender chooses Transfer account, performs local authentication (Touch ID/Face ID/passcode policy), and creates a random one-time transfer ID, an expiry (for example, a few minutes), and an ephemeral key agreement key pair.
  2. QR contains only protocol version, transfer ID, expiry/challenge, ephemeral public-key material, and an opaque local/proximity rendezvous token. It contains no account metadata, password, token, credential bytes, or encrypted credential payload.
  3. Receiver scans the QR, creates its own ephemeral key pair, displays the decoded session/expiry, and confirms the transfer with local authentication. The devices bind a transcript to both ephemeral public keys, the transfer ID, and the challenge. The transport may be a direct local/proximity channel; transport confidentiality is not trusted.
  4. Derive a session key with a modern CryptoKit key-agreement primitive plus HKDF-SHA-256, and send the account metadata and credential payload only as an authenticated-encrypted message (AEAD such as ChaChaPoly/AES-GCM) over that channel. Bind protocol version, account ID, sender/receiver public keys, nonce, and expiry as authenticated context. Do not log, copy to pasteboard, expose in a URL, or include the payload in crash/analytics data.
  5. Both devices show the account identity/endpoint and a short human-verifiable confirmation code derived from the transcript. Sender confirms export and receiver confirms import; require a biometric/passcode gate immediately before reading/writing the credential. Receiver writes the secret to its local Keychain only after authenticated decryption and confirmation, then validates the account over TLS before marking the import complete.
  6. Session state is one-shot: short expiry, cryptographically random nonce/ID, explicit consumed state, transcript binding, and rejection of reused/expired/cancelled IDs. Sender can cancel/revoke a pending session before completion; receiver can reject. A sender cannot force deletion from a malicious receiver after plaintext has been delivered, so the UI must state that revocation is guaranteed only while the session is pending and the receiver is cooperating.
  7. On cancellation, timeout, wrong-device confirmation, transport loss, decryption failure, or failed IMAP validation, erase ephemeral private keys and pending plaintext from memory, mark the session unusable, and leave both existing accounts unchanged. Retry creates a new session/QR; never reuse keys or a consumed token.

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

  • Existing local items remain device-bound and continue working. Do not silently convert them to synchronizable items. On explicit opt-in, read/authenticate the old item, create the versioned sync item, verify a successful read on the same device, and only then consider retiring the old item according to an announced migration policy.
  • Existing account metadata is uploaded only after explicit sync consent. A failed CloudKit write or unavailable iCloud account leaves the local DB/Keychain untouched. Do not create duplicate accounts when a synchronized record arrives; reconcile by stable account ID and require user action for endpoint/credential conflicts.
  • Account removal must delete local secret/data and issue a remote tombstone/delete for the sync namespace after confirmation. Define retention for unscoped local error/cache metadata separately. Disabling sync must stop future writes without deleting the local working account.
  • Manual import must never overwrite an existing account silently. Require a replace/new-account choice, validate the new credential before activation, and preserve the old account until the transaction succeeds.

Test plan

  • Signed macOS/iOS integration tests query exact Keychain attributes and verify synchronized versus ThisDeviceOnly namespaces, 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.
  • CloudKit tests use a private container and assert the schema has metadata/version/tombstone fields only: no credential field, asset, plaintext, or raw credential ciphertext. Exercise offline queueing, retry/backoff, conflicts, zone-not-found/user-keychain-reset, account changes, and remote delete.
  • Manual-transfer unit tests cover QR serialization (credential fields are impossible), key agreement/HKDF/AEAD round trips, wrong key, modified transcript, wrong confirmation code, expiry, cancellation, replay/duplicate consume, transport interruption, and memory cleanup. Fuzz the QR/parser and cap message sizes.
  • UI tests cover both-device confirmation, biometric success/cancel/failure, wrong-device rejection, accessibility labels, expiry/cancel messaging, and the no-secret-in-logs/error-text invariant. Validate direct offline transfer with Wi-Fi/cloud unavailable.
  • Migration tests start from the current local SQLite plus device-bound Keychain shape, opt in/out, restart both devices, update/delete an account, and prove no duplicate rows or orphaned sync records. Keep the existing -mock tests and add a regression assertion that mock mode never constructs the live facade or touches Security/store paths.

Acceptance criteria

  • Existing local storage behavior is preserved by default: metadata in SQLite, secret in the local device-bound Keychain, no credential in SQLite/UserDefaults/CloudKit.
  • Same-iCloud sync is explicit opt-in, uses private CloudKit metadata plus a supported iCloud-synchronizable Keychain item, has signed macOS/iOS entitlement/access-group validation, and handles offline/account-reset/delete cases without losing the local credential.
  • Manual handoff QR contains only ephemeral session/public-key material; credentials travel only after two-device confirmation in an authenticated E2E-encrypted session, with short expiry, replay protection, biometric UX, cancel/revoke-pending behavior, and local Keychain storage on the receiver.
  • Raw/plaintext credentials and credential-bearing assets/records never enter CloudKit, QR, logs, analytics, clipboard, URLs, or crash reports.
  • Migration, conflict, failure, revocation-limit, accessibility, and two-device integration tests pass; no existing account can be replaced or deleted silently.
  • Apple API/entitlement uncertainties are recorded as explicit go/no-go decisions before release; unsupported sync combinations fail closed to local storage/manual transfer.

Apple references / decisions requiring confirmation

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.

## 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`, and `AccountConfig`. `AccountConfig` contains 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 table `accounts` has columns for exactly those non-secret fields. `folders.account_id` references `accounts` with cascade delete; generations, messages, sync state, and seen queue cascade through their folder/generation relationships. `error_log.account_id` is nullable and has no foreign key. `attachment_cache` is global content-hash metadata, not account-scoped. * `Sources/MailternalStore/MailStore.swift` — `upsertAccount(_:)` binds only the `AccountConfig` fields and uses `INSERT ... ON CONFLICT(id) DO UPDATE`; `fetchAccount`, `fetchAccounts`, and `deleteAccount` are the corresponding reads/delete. There is no secret column and no password serialization into SQLite. * `App/Sources/Live/MailternalContainer.swift` — `MailternalContainer.default`, `databaseURL`, and `attachmentsDirectory`: the live store is the application-support `Mailternal/store.sqlite` WAL database plus the content-hash attachment directory. `wipeAttachmentFiles()` removes attachment files only; it does not clear `attachment_cache` rows or `error_log` rows. * `App/Sources/Support/AppearanceSettings.swift` — `UserDefaults` is 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` — `KeychainStore` uses `SecItemAdd`, `SecItemUpdate`, `SecItemCopyMatching`, and `SecItemDelete` for a generic-password item. The base query is `kSecClassGenericPassword` plus the store service and `kSecAttrAccount = AccountID.rawValue`. * The live default service is the `KeychainStore.defaultService` constant. The current code does **not** set `kSecAttrSynchronizable` and does **not** set `kSecAttrAccessGroup`; it therefore does not intentionally sync to iCloud or share through a named group. * On add, `saveToKeychain` stores the UTF-8 password and sets `kSecAttrAccessible` to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. On update, it updates only `kSecValueData`, so it retains the existing item attributes. Reads request only returned data and one match. Deletes use the same class/service/account query and treat `errSecItemNotFound` as success. * If `APP_SANDBOX_CONTAINER_ID` is present, `baseQuery` also sets `kSecUseDataProtectionKeychain = 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.memory` is a process-local, `NSLock`-protected singleton dictionary keyed by service and account. It is not persisted, not synchronized, and disappears with the process. `KeychainStoreError` maps missing items, non-UTF-8 data, and Security status failures. * `App/Sources/Live/LiveMailFacade.swift` — `KeychainCredentialProvider.password(for:)` reads from `KeychainStore`; `LiveMailFacade.restorePersistedAccount`, `addAccount`, and `removeAccount` coordinate the DB row and secret. `addAccount` validates over IMAP before saving, deletes other configured accounts, saves the password, then upserts metadata; on metadata-save failure it deletes the newly saved secret. `removeAccount` stops 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` — `IMAPCredentialProvider` is the engine seam; `SyncEngine` never persists the password. `Sources/MailternalIMAP/IMAPSession.swift` holds 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 * Replacing an account with a different `AccountID` removes 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. * Deleting an account cascades account-owned mailbox data through the schema, but leaves unscoped error-log/cache metadata and relies on the attachment file wipe for bytes. Any transfer/sync design must specify its own remote tombstone and local cleanup semantics rather than assuming `deleteAccount` is a full erase. ### Entitlements/configuration currently present * `App/Mailternal.entitlements` has 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.entitlements` has get-task-allow and network client; it has no iCloud or Keychain Sharing entitlement. `App/project.yml` uses manual signing, enables sandbox for the production target, disables it for Debug, and passes `-mock` in the Mailternal run/test scheme. ## Why the mock validation launch has no previously authenticated real account * `App/Sources/MailternalApp.swift` — `MailternalApp.makeFacade()` checks `ProcessInfo.processInfo.arguments` for `-mock` first and returns a new `MockMailFacade`. It never constructs `LiveMailFacade` on that path. * `App/Sources/Model/AppModel.swift` — `start()` calls `restorePersistedAccount()` only when the facade is a `LiveMailFacade`. The mock therefore never opens `MailternalContainer`, migrates/reads SQLite, or asks `KeychainStore` for an item. * `App/Sources/Mock/MockMailFacade.swift` — `MockMailFacade` starts with `.none`, holds `config` in an instance property, performs only scripted checks and delays in `addAccount`, seeds an in-memory mailbox, and `removeAccount` just clears that property/state. It has no Security import, no `MailStore`, no Keychain call, and no account restore path. Its temporary attachment PNG writes are mock rendering artifacts, not credentials. * `App/UITests/MailternalUITests.swift` launches with `-mock` and signs into the scripted mock form. This explains both the seeded mailbox and the absence of the previously authenticated real account. Therefore the `-mock` launch 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-account` is a different path: `QALaunch.makeFacade()` creates a live facade with a custom QA service using `Storage.memory`; `seedQAAccount` can 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 1. Keep the local SQLite database as the offline cache. Add a versioned CloudKit **private database** record for non-secret account metadata: stable account ID, display name, email address, username, IMAP endpoint/security, schema version, and transfer/sync tombstone/version fields. Use a private database, never public records. 2. Store the password (and future refresh tokens, if ever introduced) as a generic-password Keychain item with `kSecAttrSynchronizable = true` where the target OS and item type support it. Use a versioned service/account namespace so the existing `AfterFirstUnlockThisDeviceOnly` record is never accidentally treated as a sync record. 3. Do not upload a password, token, credential blob, or raw credential ciphertext to CloudKit. CloudKit encrypted fields are server-managed field encryption backed by iCloud Keychain, not a reason to put credentials in a record. If a future design needs sensitive non-credential metadata, require app-level encryption and a separate review; the default schema has no credential fields or assets. 4. On a new device, fetch private metadata, locate the synchronized Keychain item, and require local device authentication before using it. If either side is unavailable, show setup/re-authentication rather than silently downgrading to a server password prompt. ### Keychain and entitlement requirements * `kSecAttrSynchronizable` must be true on create/query/update/delete for the sync namespace. Apple documents that updating/deleting a synchronizable item affects all copies, that `ThisDeviceOnly` accessibility values are incompatible with synchronization, and that synchronizable macOS items use iOS-style access groups. Select a non-`ThisDeviceOnly` accessibility level only after validating the least-privilege choice on both targets. * Do not assume a biometric `SecAccessControl` policy 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. * If the macOS and iOS targets need a common item, enable Keychain Sharing in both signed targets, use one explicit access-group name authorized to the same Apple Developer Team ID, and keep each app’s private group separate for unrelated secrets. Provisioning profiles, bundle IDs, team/application identifiers, and the `keychain-access-groups` entitlement must agree. Never hard-code an unowned group or accept `errSecMissingEntitlement` as a fallback. * Add only the required iCloud capability/container and CloudKit private-database configuration to both targets. Confirm iCloud account availability, account-change notifications, first-unlock behavior, simulator/device behavior, and production container schema promotion. Current entitlements have none of this configured. ### 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, `CKError` retries, 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. 1. Sender chooses **Transfer account**, performs local authentication (Touch ID/Face ID/passcode policy), and creates a random one-time transfer ID, an expiry (for example, a few minutes), and an ephemeral key agreement key pair. 2. QR contains **only** protocol version, transfer ID, expiry/challenge, ephemeral public-key material, and an opaque local/proximity rendezvous token. It contains no account metadata, password, token, credential bytes, or encrypted credential payload. 3. Receiver scans the QR, creates its own ephemeral key pair, displays the decoded session/expiry, and confirms the transfer with local authentication. The devices bind a transcript to both ephemeral public keys, the transfer ID, and the challenge. The transport may be a direct local/proximity channel; transport confidentiality is not trusted. 4. Derive a session key with a modern CryptoKit key-agreement primitive plus HKDF-SHA-256, and send the account metadata and credential payload only as an authenticated-encrypted message (AEAD such as ChaChaPoly/AES-GCM) over that channel. Bind protocol version, account ID, sender/receiver public keys, nonce, and expiry as authenticated context. Do not log, copy to pasteboard, expose in a URL, or include the payload in crash/analytics data. 5. Both devices show the account identity/endpoint and a short human-verifiable confirmation code derived from the transcript. Sender confirms export and receiver confirms import; require a biometric/passcode gate immediately before reading/writing the credential. Receiver writes the secret to its **local** Keychain only after authenticated decryption and confirmation, then validates the account over TLS before marking the import complete. 6. Session state is one-shot: short expiry, cryptographically random nonce/ID, explicit consumed state, transcript binding, and rejection of reused/expired/cancelled IDs. Sender can cancel/revoke a pending session before completion; receiver can reject. A sender cannot force deletion from a malicious receiver after plaintext has been delivered, so the UI must state that revocation is guaranteed only while the session is pending and the receiver is cooperating. 7. On cancellation, timeout, wrong-device confirmation, transport loss, decryption failure, or failed IMAP validation, erase ephemeral private keys and pending plaintext from memory, mark the session unusable, and leave both existing accounts unchanged. Retry creates a new session/QR; never reuse keys or a consumed token. ## 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 * Existing local items remain device-bound and continue working. Do not silently convert them to synchronizable items. On explicit opt-in, read/authenticate the old item, create the versioned sync item, verify a successful read on the same device, and only then consider retiring the old item according to an announced migration policy. * Existing account metadata is uploaded only after explicit sync consent. A failed CloudKit write or unavailable iCloud account leaves the local DB/Keychain untouched. Do not create duplicate accounts when a synchronized record arrives; reconcile by stable account ID and require user action for endpoint/credential conflicts. * Account removal must delete local secret/data and issue a remote tombstone/delete for the sync namespace after confirmation. Define retention for unscoped local error/cache metadata separately. Disabling sync must stop future writes without deleting the local working account. * Manual import must never overwrite an existing account silently. Require a replace/new-account choice, validate the new credential before activation, and preserve the old account until the transaction succeeds. ## Test plan * Signed macOS/iOS integration tests query exact Keychain attributes and verify synchronized versus `ThisDeviceOnly` namespaces, 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. * CloudKit tests use a private container and assert the schema has metadata/version/tombstone fields only: no credential field, asset, plaintext, or raw credential ciphertext. Exercise offline queueing, retry/backoff, conflicts, zone-not-found/user-keychain-reset, account changes, and remote delete. * Manual-transfer unit tests cover QR serialization (credential fields are impossible), key agreement/HKDF/AEAD round trips, wrong key, modified transcript, wrong confirmation code, expiry, cancellation, replay/duplicate consume, transport interruption, and memory cleanup. Fuzz the QR/parser and cap message sizes. * UI tests cover both-device confirmation, biometric success/cancel/failure, wrong-device rejection, accessibility labels, expiry/cancel messaging, and the no-secret-in-logs/error-text invariant. Validate direct offline transfer with Wi-Fi/cloud unavailable. * Migration tests start from the current local SQLite plus device-bound Keychain shape, opt in/out, restart both devices, update/delete an account, and prove no duplicate rows or orphaned sync records. Keep the existing `-mock` tests and add a regression assertion that mock mode never constructs the live facade or touches Security/store paths. ## Acceptance criteria - [ ] Existing local storage behavior is preserved by default: metadata in SQLite, secret in the local device-bound Keychain, no credential in SQLite/UserDefaults/CloudKit. - [ ] Same-iCloud sync is explicit opt-in, uses private CloudKit metadata plus a supported iCloud-synchronizable Keychain item, has signed macOS/iOS entitlement/access-group validation, and handles offline/account-reset/delete cases without losing the local credential. - [ ] Manual handoff QR contains only ephemeral session/public-key material; credentials travel only after two-device confirmation in an authenticated E2E-encrypted session, with short expiry, replay protection, biometric UX, cancel/revoke-pending behavior, and local Keychain storage on the receiver. - [ ] Raw/plaintext credentials and credential-bearing assets/records never enter CloudKit, QR, logs, analytics, clipboard, URLs, or crash reports. - [ ] Migration, conflict, failure, revocation-limit, accessibility, and two-device integration tests pass; no existing account can be replaced or deleted silently. - [ ] Apple API/entitlement uncertainties are recorded as explicit go/no-go decisions before release; unsupported sync combinations fail closed to local storage/manual transfer. ## Apple references / decisions requiring confirmation * [Security: `kSecAttrSynchronizable`](https://developer.apple.com/documentation/security/ksecattrsynchronizable) — sync semantics, update/delete all copies, no `ThisDeviceOnly`, access-group restrictions, and no persistent references. * [Security: sharing access to Keychain items among apps](https://developer.apple.com/documentation/security/sharing-access-to-keychain-items-among-a-collection-of-apps) — team/provisioning/access-group requirements for macOS Data Protection/iOS-style items. * [Security: `kSecAttrAccessible`](https://developer.apple.com/documentation/security/ksecattraccessible) — accessibility restrictions for macOS and synchronizable items. * [CloudKit overview](https://developer.apple.com/documentation/cloudkit), [enabling CloudKit](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app), and [CloudKit user-data encryption](https://developer.apple.com/documentation/cloudkit/encrypting-user-data) — private records, iCloud capability/container setup, network/offline limits, encrypted-field behavior, and keychain-reset handling. 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.
Author
Owner

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.

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.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/mailternal#2
No description provided.