feat(ios-keychain): opt-in iCloud Keychain sync for SSH keys (#52)

Reddit-reported friction: every iOS device needed its own SSH key
because Scarf hardcoded
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
kSecAttrSynchronizable=false on every Keychain write. Pairing iPhone
+ iPad meant onboarding twice and editing authorized_keys per device.

Add an opt-in toggle in System tab → Security:

- New SSHKeyICloudPreference (UserDefaults wrapper, default false so
  existing installs see no change on update).
- KeychainSSHKeyStore.writeBundle now consults the preference: when
  on, items use kSecAttrAccessibleAfterFirstUnlock (no ThisDeviceOnly
  suffix — required for iCloud Keychain sync) +
  kSecAttrSynchronizable=true.
- All read / list / delete queries unconditionally pass
  kSecAttrSynchronizable=kSecAttrSynchronizableAny so they match
  items regardless of sync state. Without this a flipped write would
  orphan items at the next read.
- Public migrateAllItems(toICloudSync:) reads every stored bundle,
  deletes with Any, re-saves with target attributes. Idempotent.

System tab Security section toggle:
- Live migration on flip with a "Updating Keychain..." progress row.
- Failure path reverts the toggle + surfaces the error inline rather
  than silently leaving the state inconsistent.
- Footer copy explains the tradeoff (E2EE via iCloud Keychain;
  Advanced Data Protection keeps encryption keys on device).

Out of scope: per-server-key sync override (M9 multi-server keys
all sync or none); in-app key export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 13:53:06 +02:00
parent f9a288ac6c
commit afb1356b27
3 changed files with 222 additions and 28 deletions
+68
View File
@@ -138,6 +138,13 @@ private struct SystemTab: View {
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
/// Mirror of `SSHKeyICloudPreference.isEnabled` drives the iCloud
/// Keychain sync toggle (issue #52). Initial value is read on view
/// init so the toggle reflects today's preference before the user
/// taps anything; flipping triggers `migrateAllItems(toICloudSync:)`.
@State private var iCloudSyncEnabled: Bool = SSHKeyICloudPreference.isEnabled
@State private var iCloudMigrationInFlight = false
@State private var iCloudMigrationError: String?
var body: some View {
List {
@@ -178,6 +185,67 @@ private struct SystemTab: View {
.listRowBackground(ScarfColor.backgroundSecondary)
}
Section {
Toggle(isOn: $iCloudSyncEnabled) {
HStack(spacing: 10) {
Image(systemName: "key.icloud.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Sync SSH key with iCloud Keychain")
Text(iCloudSyncEnabled
? "Synced — your other Apple devices with iCloud Keychain will see this key."
: "This device only — generate a separate key on each device.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.tint(ScarfColor.accent)
.disabled(iCloudMigrationInFlight)
.onChange(of: iCloudSyncEnabled) { _, newValue in
Task {
iCloudMigrationInFlight = true
iCloudMigrationError = nil
defer { iCloudMigrationInFlight = false }
do {
try await KeychainSSHKeyStore().migrateAllItems(toICloudSync: newValue)
} catch {
// Revert the toggle on failure so the UI
// reflects what's actually in the Keychain;
// surface the error inline so the user can
// retry / report. Keychain failures here are
// rare (typically `errSecDuplicateItem` if a
// prior migration was interrupted the
// delete-with-Any in writeBundle prevents
// that, but we still belt-and-brace).
iCloudMigrationError = error.localizedDescription
iCloudSyncEnabled = !newValue
SSHKeyICloudPreference.isEnabled = !newValue
}
}
}
if iCloudMigrationInFlight {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Updating Keychain…")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
if let err = iCloudMigrationError {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(ScarfColor.warning)
}
} header: {
Text("Security")
} footer: {
Text("End-to-end encrypted via iCloud Keychain. With Advanced Data Protection on, the encryption keys never leave your devices. Toggle off to keep the key device-only — each new device must onboard separately.")
.font(.caption)
}
.listRowBackground(ScarfColor.backgroundSecondary)
Section {
Button {
Task {