M9 #1: multi-server storage (UserDefaults + Keychain) with migration

Pass-1 revealed that iOS should hold more than one server (users
want to hop between a home server and a work server from a single
app). Storage was the first block: v1 stored exactly one config
under a fixed key and one Keychain item under account "primary".

Extend both stores with ID-keyed methods while keeping the v1
singleton API for back-compat during the transition:

- IOSServerConfigStore: add listAll, load(id:), save(_🆔),
  delete(id:). Singleton load/save/delete now operates on the
  "primary" entry (lowest UUID by string sort) — deterministic, no
  surprise mutation of other servers when a singleton caller saves.
- SSHKeyStore: same treatment. Keychain accounts for v2 entries are
  `"server-key:<UUID>"`.

Migration is one-shot and embedded in `listAll()` on both stores:

- UserDefaults: if the v1 key `com.scarf.ios.primary-server-config.v1`
  is present AND v2 key `com.scarf.ios.servers.v2` is empty, load
  the v1 config, insert under a fresh ServerID in v2, delete v1.
  Idempotent — no-op once v1 is gone.
- Keychain: if no `server-key:*` accounts exist AND the legacy
  `"primary"` account does, copy the bundle to a fresh ServerID
  slot and delete the legacy item.

Both migrations preserve the v1 single-server experience: a user
who updates the app without re-onboarding still sees exactly one
configured server on first launch of the new version, with the
same SSH key and the same host details. No data loss.

InMemory stores updated to match (dictionary-keyed internally).
Mac + iOS schemes both build clean; ScarfCore swift build green.
Callers (RootModel, OnboardingViewModel, ChatController,
ScarfIOSApp transport factory) still use the singleton API and
will migrate to ID-keyed in 3.2-3.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:49:26 +02:00
parent 92fba712f8
commit aafd9643a4
4 changed files with 402 additions and 63 deletions
@@ -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)
}
}
@@ -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)
}
}
@@ -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:<UUID>"`. 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)
@@ -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)
}
}
}