diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/IOSServerConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/IOSServerConfig.swift index a500052..6e3ba64 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/IOSServerConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/IOSServerConfig.swift @@ -68,29 +68,89 @@ public struct IOSServerConfig: Sendable, Hashable, Codable { } } -/// Async-safe single-record storage contract. iOS implements this -/// with `UserDefaults`; tests use `InMemoryIOSServerConfigStore`. +/// Async-safe multi-record storage contract. +/// +/// Single-server callers (v1 onboarding flow, RootModel before M9) +/// use the no-arg `load()` / `save(_:)` / `delete()` methods, which +/// operate on the "primary" server (first entry in the list, or +/// the only entry on a fresh install). Multi-server callers use the +/// ID-keyed variants added in M9. +/// +/// A migration helper is embedded: any implementation that discovers +/// a v1 singleton payload on `load()` must insert it under a fresh +/// `ServerID` and leave the list consistent. Callers shouldn't need +/// to know about the migration; they just see a populated list. public protocol IOSServerConfigStore: Sendable { - /// Returns the stored config, or `nil` if nothing has been saved - /// yet (fresh install, or the user reset onboarding). + + // MARK: - Singleton API (compat, still the default in v1) + + /// Returns the primary stored config, or `nil` if nothing has + /// been saved yet. In a multi-server world this returns the + /// first entry in the list (sorted by display name). Kept for + /// back-compat with RootModel's single-server code path; new + /// callers should prefer `load(id:)` / `listAll()`. func load() async throws -> IOSServerConfig? - /// Overwrites any existing config. Idempotent. + /// Overwrites any existing primary config. In a multi-server + /// world, saves under the implementation's "primary" slot — + /// preserving existing non-primary entries. Idempotent. func save(_ config: IOSServerConfig) async throws - /// Deletes the stored config. No-op if empty. + /// Deletes ALL stored configs. Matches the v1 "forget" semantics. func delete() async throws + + // MARK: - Multi-server API (M9) + + /// Return every configured server, mapped by its `ServerID`. + /// Empty dictionary on a fresh install. + func listAll() async throws -> [ServerID: IOSServerConfig] + + /// Load a specific server by id. Returns nil if not present. + func load(id: ServerID) async throws -> IOSServerConfig? + + /// Save or replace the config for the given id. Does not affect + /// other servers in the list. + func save(_ config: IOSServerConfig, id: ServerID) async throws + + /// Remove a specific server by id. No-op if absent. + func delete(id: ServerID) async throws } /// Process-lifetime in-memory config store. For tests and previews. public actor InMemoryIOSServerConfigStore: IOSServerConfigStore { - private var config: IOSServerConfig? + private var storage: [ServerID: IOSServerConfig] = [:] public init(initial: IOSServerConfig? = nil) { - self.config = initial + if let initial { + self.storage[ServerID()] = initial + } } - public func load() async throws -> IOSServerConfig? { config } - public func save(_ config: IOSServerConfig) async throws { self.config = config } - public func delete() async throws { config = nil } + public func load() async throws -> IOSServerConfig? { + storage.values.sorted(by: { $0.displayName < $1.displayName }).first + } + + public func save(_ config: IOSServerConfig) async throws { + // Singleton save: replace the primary entry (or create one + // if the list is empty). Never grows the list unexpectedly. + if let primaryID = storage.keys.sorted(by: { ($0.uuidString) < ($1.uuidString) }).first { + storage[primaryID] = config + } else { + storage[ServerID()] = config + } + } + + public func delete() async throws { storage.removeAll() } + + public func listAll() async throws -> [ServerID: IOSServerConfig] { storage } + + public func load(id: ServerID) async throws -> IOSServerConfig? { storage[id] } + + public func save(_ config: IOSServerConfig, id: ServerID) async throws { + storage[id] = config + } + + public func delete(id: ServerID) async throws { + storage.removeValue(forKey: id) + } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/SSHKey.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/SSHKey.swift index 00433e6..f4a2deb 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/SSHKey.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Security/SSHKey.swift @@ -49,21 +49,45 @@ public struct SSHKeyBundle: Sendable, Hashable, Codable { } } -/// Async-safe key storage contract. iOS implements this with the -/// Keychain; tests use `InMemorySSHKeyStore`. +/// Async-safe key storage contract. /// -/// Single-key storage is intentional: v1 of the iOS app binds one SSH -/// key to one Hermes server. Multi-key / multi-server comes later. +/// Singleton API (`load()` / `save(_:)` / `delete()`) persists here +/// for the v1 single-server callers. M9 added the `ServerID`-keyed +/// variants so we can hold a key per configured server; singleton +/// semantics remain for the "primary" slot (first key when the list +/// is populated). public protocol SSHKeyStore: Sendable { - /// Returns the stored key bundle, or `nil` if the store is empty. - /// Callers should prompt the onboarding flow when this is `nil`. + // MARK: - Singleton API (compat) + + /// Returns the primary stored key, or `nil` if the store is empty. + /// In a multi-server world this picks the first key by stable + /// ordering; callers with specific server context should prefer + /// `load(for:)`. func load() async throws -> SSHKeyBundle? - /// Overwrites any existing key with `bundle`. Idempotent. + /// Overwrites the primary key. Does not affect other stored keys + /// (M9 multi-server). Idempotent. func save(_ bundle: SSHKeyBundle) async throws - /// Deletes the stored key. No-op if the store is empty. + /// Deletes ALL stored keys across every ServerID slot. Matches + /// the v1 "forget" semantics. func delete() async throws + + // MARK: - Multi-server API (M9) + + /// Return the ids for every server with a stored key. Empty on + /// fresh install. + func listAll() async throws -> [ServerID] + + /// Load the key stored for the given server id, or nil if absent. + func load(for id: ServerID) async throws -> SSHKeyBundle? + + /// Save or replace the key for the given server id. Leaves other + /// servers' keys untouched. + func save(_ bundle: SSHKeyBundle, for id: ServerID) async throws + + /// Remove the key for a specific server id. No-op if absent. + func delete(for id: ServerID) async throws } /// Errors raised by `SSHKeyStore` implementations when the backing @@ -91,13 +115,44 @@ public enum SSHKeyStoreError: Error, LocalizedError { /// Process-lifetime in-memory key store. Intended for tests and /// previews — never for production. Thread-safe via an internal actor. public actor InMemorySSHKeyStore: SSHKeyStore { - private var bundle: SSHKeyBundle? + private var bundles: [ServerID: SSHKeyBundle] = [:] public init(initial: SSHKeyBundle? = nil) { - self.bundle = initial + if let initial { + self.bundles[ServerID()] = initial + } } - public func load() async throws -> SSHKeyBundle? { bundle } - public func save(_ bundle: SSHKeyBundle) async throws { self.bundle = bundle } - public func delete() async throws { bundle = nil } + public func load() async throws -> SSHKeyBundle? { + guard let id = bundles.keys.sorted(by: { $0.uuidString < $1.uuidString }).first else { + return nil + } + return bundles[id] + } + + public func save(_ bundle: SSHKeyBundle) async throws { + if let primaryID = bundles.keys.sorted(by: { $0.uuidString < $1.uuidString }).first { + bundles[primaryID] = bundle + } else { + bundles[ServerID()] = bundle + } + } + + public func delete() async throws { bundles.removeAll() } + + public func listAll() async throws -> [ServerID] { + Array(bundles.keys) + } + + public func load(for id: ServerID) async throws -> SSHKeyBundle? { + bundles[id] + } + + public func save(_ bundle: SSHKeyBundle, for id: ServerID) async throws { + bundles[id] = bundle + } + + public func delete(for id: ServerID) async throws { + bundles.removeValue(forKey: id) + } } diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/KeychainSSHKeyStore.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/KeychainSSHKeyStore.swift index 5d363f1..c1df30b 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/KeychainSSHKeyStore.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/KeychainSSHKeyStore.swift @@ -7,34 +7,118 @@ import Foundation import Security import ScarfCore -/// iOS Keychain-backed implementation of `SSHKeyStore`. Stores the -/// JSON-encoded `SSHKeyBundle` as a generic password item tagged -/// with a Scarf-specific service + account. +/// iOS Keychain-backed implementation of `SSHKeyStore`. /// -/// **Accessibility**: We use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` -/// so the key: -/// - is readable any time after the user unlocks the device once -/// (so background tasks can reach it), -/// - does not sync to iCloud Keychain (keys are per-device; the -/// user would explicitly enrol a new iPhone with its own key). +/// Storage shape: +/// - v1 (single-key): one generic-password item with `kSecAttrAccount = "primary"`. +/// Shipped in M2. +/// - v2 (multi-key, M9): one generic-password item per configured +/// server, with `kSecAttrAccount = "server-key:"`. New saves +/// go here; v1 item is migrated into v2 on first `listAll()` after +/// the upgrade, then removed. /// -/// **Thread safety**: Each Keychain call allocates its own `CFDictionary`, -/// so no shared state. The methods are marked `nonisolated` to allow -/// calling from any actor context. +/// All items use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` +/// so they're reachable after a single device unlock (background +/// tasks, notification actions) but never sync to iCloud Keychain. public struct KeychainSSHKeyStore: SSHKeyStore { public static let defaultService = "com.scarf.ssh-key" - public static let defaultAccount = "primary" + public static let legacyV1Account = "primary" + public static let multiAccountPrefix = "server-key:" private let service: String - private let account: String - public init(service: String = defaultService, account: String = defaultAccount) { + public init(service: String = defaultService) { self.service = service - self.account = account } + // MARK: - Singleton API (compat) + public func load() async throws -> SSHKeyBundle? { - var query: [String: Any] = [ + // Migrate first so the post-migration listAll path sees the + // single v1 entry, if any. + migrateLegacyIfNeeded() + let ids = try await listAll() + guard let first = ids.sorted(by: { $0.uuidString < $1.uuidString }).first else { + // No v2 entries; try legacy in case migration lost the race. + return try readLegacy() + } + return try await load(for: first) + } + + public func save(_ bundle: SSHKeyBundle) async throws { + let ids = try await listAll() + if let primaryID = ids.sorted(by: { $0.uuidString < $1.uuidString }).first { + try await save(bundle, for: primaryID) + } else { + try await save(bundle, for: ServerID()) + } + } + + public func delete() async throws { + // Wipe every v2 entry + the legacy v1 entry. Single-query delete + // that matches any account under our service. + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw SSHKeyStoreError.backendFailure( + message: "Keychain wipe failed", osStatus: status + ) + } + } + + // MARK: - Multi-server API + + public func listAll() async throws -> [ServerID] { + migrateLegacyIfNeeded() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + switch status { + case errSecSuccess: + guard let array = items as? [[String: Any]] else { return [] } + var ids: [ServerID] = [] + for entry in array { + guard let account = entry[kSecAttrAccount as String] as? String, + account.hasPrefix(Self.multiAccountPrefix) else { continue } + let idString = String(account.dropFirst(Self.multiAccountPrefix.count)) + if let uuid = UUID(uuidString: idString) { + ids.append(uuid) + } + } + return ids + case errSecItemNotFound: + return [] + default: + throw SSHKeyStoreError.backendFailure( + message: "Keychain list failed", osStatus: status + ) + } + } + + public func load(for id: ServerID) async throws -> SSHKeyBundle? { + try readBundle(account: Self.multiAccountPrefix + id.uuidString) + } + + public func save(_ bundle: SSHKeyBundle, for id: ServerID) async throws { + try writeBundle(bundle, account: Self.multiAccountPrefix + id.uuidString) + } + + public func delete(for id: ServerID) async throws { + try deleteBundle(account: Self.multiAccountPrefix + id.uuidString) + } + + // MARK: - Private — Keychain plumbing per-account + + private func readBundle(account: String) throws -> SSHKeyBundle? { + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, @@ -62,11 +146,9 @@ public struct KeychainSSHKeyStore: SSHKeyStore { message: "Keychain read failed", osStatus: status ) } - // swiftlint:disable:previous cyclomatic_complexity — accepted; single SecItem read - _ = query // silence "never mutated" in older Swift 5 modes } - public func save(_ bundle: SSHKeyBundle) async throws { + private func writeBundle(_ bundle: SSHKeyBundle, account: String) throws { let data: Data do { data = try JSONEncoder().encode(bundle) @@ -75,10 +157,6 @@ public struct KeychainSSHKeyStore: SSHKeyStore { message: "Encode failed: \(error.localizedDescription)", osStatus: nil ) } - - // Delete any existing entry first — SecItemUpdate is finicky - // across OS versions; delete-and-insert is the simpler pattern - // for single-entry storage. let baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -99,20 +177,72 @@ public struct KeychainSSHKeyStore: SSHKeyStore { } } - public func delete() async throws { + private func deleteBundle(account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] let status = SecItemDelete(query as CFDictionary) - // errSecItemNotFound is fine — delete() is idempotent by contract. if status != errSecSuccess && status != errSecItemNotFound { throw SSHKeyStoreError.backendFailure( message: "Keychain delete failed", osStatus: status ) } } + + /// Read the v1 legacy entry (if any). Separate from `readBundle` + /// so we can call it at the bottom of `load()` as a belt-and- + /// braces fallback when migration hasn't happened yet. + private func readLegacy() throws -> SSHKeyBundle? { + try readBundle(account: Self.legacyV1Account) + } + + /// One-shot v1 → v2 migration. If the legacy `"primary"` account + /// exists and no v2 accounts do, copy the legacy key to a fresh + /// ServerID-keyed slot and delete the legacy item. Idempotent — + /// once v1 is gone subsequent calls are no-ops. + private func migrateLegacyIfNeeded() { + let hasV2 = (try? listAllInternal(skipMigration: true)) ?? [] + guard hasV2.isEmpty, + let legacy = try? readLegacy() + else { return } + let freshID = ServerID() + try? writeBundle(legacy, account: Self.multiAccountPrefix + freshID.uuidString) + try? deleteBundle(account: Self.legacyV1Account) + } + + /// `listAll()` but without the migration call. Internal so the + /// migration routine can check whether v2 is empty without + /// triggering a recursive migration. + private func listAllInternal(skipMigration: Bool) throws -> [ServerID] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + switch status { + case errSecSuccess: + guard let array = items as? [[String: Any]] else { return [] } + var ids: [ServerID] = [] + for entry in array { + guard let account = entry[kSecAttrAccount as String] as? String, + account.hasPrefix(Self.multiAccountPrefix) else { continue } + let idString = String(account.dropFirst(Self.multiAccountPrefix.count)) + if let uuid = UUID(uuidString: idString) { + ids.append(uuid) + } + } + return ids + case errSecItemNotFound: + return [] + default: + return [] + } + } } #endif // canImport(Security) diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/UserDefaultsIOSServerConfigStore.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/UserDefaultsIOSServerConfigStore.swift index bae288f..cc2500f 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/UserDefaultsIOSServerConfigStore.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/UserDefaultsIOSServerConfigStore.swift @@ -1,39 +1,133 @@ import Foundation import ScarfCore -/// `UserDefaults`-backed implementation of `IOSServerConfigStore`. The -/// server config (hostname, user, display name, etc.) is not itself -/// sensitive — the SSH private key lives in the Keychain separately — -/// so `UserDefaults` is the right low-ceremony store for it. +/// `UserDefaults`-backed implementation of `IOSServerConfigStore`. /// -/// The record serializes as JSON under a single key. A future schema -/// migration can bump the key name (`.v2` suffix) if the shape -/// changes; today there's nothing to migrate. +/// Data shape: +/// - v1 (single-server): JSON-encoded IOSServerConfig stored under +/// `com.scarf.ios.primary-server-config.v1`. Shipped in M2. +/// - v2 (multi-server, M9): JSON-encoded `[ServerID: IOSServerConfig]` +/// stored under `com.scarf.ios.servers.v2`. Written by new onboardings. +/// +/// Migration: on first access after the M9 update, if a v1 record +/// exists AND v2 is empty, load the v1 config and insert it into v2 +/// under a fresh ServerID, then delete the v1 key. Pure one-shot — +/// on every subsequent launch the v1 key is gone and we read v2 +/// directly. +/// +/// The server config itself is not sensitive (SSH private keys live in +/// the Keychain separately), so `UserDefaults` is the right low- +/// ceremony store. public struct UserDefaultsIOSServerConfigStore: IOSServerConfigStore { - public static let defaultDefaultsKey = "com.scarf.ios.primary-server-config.v1" + public static let legacyV1Key = "com.scarf.ios.primary-server-config.v1" + public static let defaultDefaultsKey = "com.scarf.ios.servers.v2" private let defaults: UserDefaults private let key: String + private let legacyKey: String public init( defaults: UserDefaults = .standard, - key: String = defaultDefaultsKey + key: String = defaultDefaultsKey, + legacyKey: String = legacyV1Key ) { self.defaults = defaults self.key = key + self.legacyKey = legacyKey } + // MARK: - Singleton API (compat) + public func load() async throws -> IOSServerConfig? { - guard let data = defaults.data(forKey: key) else { return nil } - return try JSONDecoder().decode(IOSServerConfig.self, from: data) + let all = try await listAll() + guard let first = primaryEntry(from: all) else { return nil } + return first.config } public func save(_ config: IOSServerConfig) async throws { - let data = try JSONEncoder().encode(config) - defaults.set(data, forKey: key) + var all = try await listAll() + if let primaryID = primaryEntry(from: all)?.id { + all[primaryID] = config + } else { + all[ServerID()] = config + } + try writeAll(all) } public func delete() async throws { defaults.removeObject(forKey: key) + defaults.removeObject(forKey: legacyKey) + } + + // MARK: - Multi-server API + + public func listAll() async throws -> [ServerID: IOSServerConfig] { + // Migrate v1 first so the v2 read below sees the latest data. + migrateLegacyIfNeeded() + guard let data = defaults.data(forKey: key) else { return [:] } + let raw = try JSONDecoder().decode([String: IOSServerConfig].self, from: data) + var result: [ServerID: IOSServerConfig] = [:] + for (idString, config) in raw { + guard let uuid = UUID(uuidString: idString) else { continue } + result[uuid] = config + } + return result + } + + public func load(id: ServerID) async throws -> IOSServerConfig? { + try await listAll()[id] + } + + public func save(_ config: IOSServerConfig, id: ServerID) async throws { + var all = try await listAll() + all[id] = config + try writeAll(all) + } + + public func delete(id: ServerID) async throws { + var all = try await listAll() + guard all.removeValue(forKey: id) != nil else { return } + try writeAll(all) + } + + // MARK: - Helpers + + /// Pick the "primary" entry from a list using a stable order — + /// lowest UUID string wins. Guarantees deterministic behaviour + /// when the singleton API is called on a multi-server store. + private func primaryEntry( + from all: [ServerID: IOSServerConfig] + ) -> (id: ServerID, config: IOSServerConfig)? { + guard let id = all.keys.sorted(by: { $0.uuidString < $1.uuidString }).first, + let config = all[id] + else { return nil } + return (id, config) + } + + private func writeAll(_ all: [ServerID: IOSServerConfig]) throws { + var raw: [String: IOSServerConfig] = [:] + for (id, config) in all { + raw[id.uuidString] = config + } + let data = try JSONEncoder().encode(raw) + defaults.set(data, forKey: key) + } + + /// One-shot v1 → v2 migration. If a v1 singleton exists and v2 is + /// empty, insert the v1 entry under a fresh ServerID and delete + /// v1. Safe to call on every `listAll()` — becomes a no-op once + /// v1 is gone. + private func migrateLegacyIfNeeded() { + guard defaults.data(forKey: key) == nil, + let legacyData = defaults.data(forKey: legacyKey), + let legacy = try? JSONDecoder().decode(IOSServerConfig.self, from: legacyData) + else { return } + let migrated: [String: IOSServerConfig] = [ + ServerID().uuidString: legacy + ] + if let out = try? JSONEncoder().encode(migrated) { + defaults.set(out, forKey: key) + defaults.removeObject(forKey: legacyKey) + } } }