diff --git a/FrameworkTests/SettingsPortTests.swift b/FrameworkTests/SettingsPortTests.swift index 1c5392a6..0b0d62bb 100644 --- a/FrameworkTests/SettingsPortTests.swift +++ b/FrameworkTests/SettingsPortTests.swift @@ -110,6 +110,42 @@ struct SettingsPortTests { #expect(defaults.bool(forKey: SettingsKey.settingsSyncEnabled)) } + @Test func newVaultCredentialStorageRequiresBothSwitchesAndProAccess() throws { + let domain = "VaultCredentialStorage.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + for iCloudEnabled in [false, true] { + for vaultEnabled in [false, true] { + for canUseVaultSync in [false, true] { + defaults.set(iCloudEnabled, forKey: SettingsKey.iCloudSyncEnabled) + defaults.set(vaultEnabled, forKey: SettingsKey.vaultSyncEnabled) + let expectedKey: KeyStorage = + iCloudEnabled && vaultEnabled && canUseVaultSync ? .iCloud : .thisDevice + let expectedPassword: PasswordStorage = + iCloudEnabled && vaultEnabled && canUseVaultSync ? .iCloud : .thisDevice + + #expect( + NewVaultCredentialStorage.keyStorage( + defaults: defaults, + canUseVaultSync: canUseVaultSync) == expectedKey) + #expect( + NewVaultCredentialStorage.passwordStorage( + defaults: defaults, + canUseVaultSync: canUseVaultSync) == expectedPassword) + } + } + } + } + + @Test func newVaultCredentialStorageUsesEnabledDefaultsWhenPreferencesAreMissing() throws { + let domain = "VaultCredentialStorageDefaults.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + + #expect(NewVaultCredentialStorage.keyStorage(defaults: defaults, canUseVaultSync: true) == .iCloud) + #expect(NewVaultCredentialStorage.passwordStorage(defaults: defaults, canUseVaultSync: true) == .iCloud) + } + @Test func rolesTakePrecedenceOverLastSelectedModelAndDeletedModelsFallBack() { let provider = ModelProvider(name: "Test", models: [AIModel(id: "role"), AIModel(id: "last")]) let role = AIModelRef(modelProviderID: provider.id, modelID: "role") diff --git a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift index 8a928e1c..599422f7 100644 --- a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift @@ -7,7 +7,7 @@ import struct TermindDomain.Host public final class SSHImportModel { public var items: [SSHImportItem] = [] public var expanded = Set(SSHImportCategory.allCases) - public var storage: PasswordStorage = .iCloud + public var storage: PasswordStorage public var importColor: TagColor = .none public var hostGroupID: HostGroup.ID? public var hostTags: [String] = [] @@ -36,10 +36,16 @@ public final class SSHImportModel { var keyID: UUID? } - public init(library: HostLibraryModel, reader: any SSHImportReading, defaultUsername: String = "") { + public init( + library: HostLibraryModel, + reader: any SSHImportReading, + defaultUsername: String = "", + storage: PasswordStorage = .iCloud + ) { self.defaultUsername = defaultUsername self.library = library self.reader = reader + self.storage = storage } public func scan(_ url: URL, knownHostsOnly: Bool) async { diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index 7a1be4aa..6864f61b 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -68,3 +68,34 @@ public nonisolated enum SettingsKey { if !vault && !ai { defaults.set(true, forKey: vaultSyncEnabled) } } } + +public nonisolated enum NewVaultCredentialStorage { + public static func keyStorage( + defaults: UserDefaults = .standard, + canUseVaultSync: Bool + ) -> KeyStorage { + vaultSyncIsAvailable(defaults: defaults, canUseVaultSync: canUseVaultSync) + ? .iCloud : .thisDevice + } + + public static func passwordStorage( + defaults: UserDefaults = .standard, + canUseVaultSync: Bool + ) -> PasswordStorage { + vaultSyncIsAvailable(defaults: defaults, canUseVaultSync: canUseVaultSync) + ? .iCloud : .thisDevice + } + + private static func vaultSyncIsAvailable( + defaults: UserDefaults, + canUseVaultSync: Bool + ) -> Bool { + let iCloudEnabled = + defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool + ?? SettingsKey.defaultICloudSyncEnabled + let vaultEnabled = + defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool + ?? SettingsKey.defaultVaultSyncEnabled + return iCloudEnabled && vaultEnabled && canUseVaultSync + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 01a13203..5d741cc4 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -106,7 +106,8 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed. - Saved iCloud Passwords and software Keys cannot be moved to device-only storage: editors disable that destination and application writes reject it before touching Keychain. New credentials may start device-only, and device-only credentials - may move to iCloud. + may move to iCloud. New Key and Identity storage pickers default to iCloud only while the master iCloud switch, Vault + selection and Pro access are all enabled; otherwise they default to this device. Existing credentials keep their storage. - Editors whose saved Password or software Private Key is unavailable on the current device preserve its reference and disable secret replacement, removal, passphrase and storage changes while still allowing public metadata edits. Key metadata-only saves may update the SSH certificate after validating it against the saved public key, without reading or diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 39af4e41..9c5240b8 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -68,6 +68,7 @@ final class MobileAppModel { let liveActivities: MobileLiveActivityController @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] private let defaults: UserDefaults? + private let settingsDefaults: UserDefaults private let snippetTimestampFallback: Date private let background: any MobileBackgroundTaskManaging private let workspaceConnectionServices: MobileWorkspaceConnectionServices @@ -139,7 +140,10 @@ final class MobileAppModel { accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String ?? "F6SUNDBBN9.com.akinokaede.termind") let settingsDefaults = - memoryOnly ? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! : defaults ?? .standard + memoryOnly + ? defaults ?? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! + : defaults ?? .standard + self.settingsDefaults = settingsDefaults SettingsKey.migrateSyncPreferences(in: settingsDefaults) let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults) try settingsPersistence.prepare() @@ -264,6 +268,18 @@ final class MobileAppModel { loadRecentWorkspaces() } + var newVaultKeyStorage: KeyStorage { + NewVaultCredentialStorage.keyStorage( + defaults: settingsDefaults, + canUseVaultSync: pro.canUse(.vaultSync)) + } + + var newVaultPasswordStorage: PasswordStorage { + NewVaultCredentialStorage.passwordStorage( + defaults: settingsDefaults, + canUseVaultSync: pro.canUse(.vaultSync)) + } + func open( hostID: UUID, kind: ConnectionHistoryEntry.Kind, initialPath: String? = nil diff --git a/TFI/Application/FeatureModels/MobileKeyEditorModel.swift b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift index ee6010ed..bda06a6a 100644 --- a/TFI/Application/FeatureModels/MobileKeyEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift @@ -39,12 +39,17 @@ final class MobileKeyEditorModel: Identifiable { private var originalPrivateKey = "" private var didLoadPrivateKey: Bool - init(mode: Mode, credentials: CredentialLibraryModel, key: SSHKeyRef? = nil) { + init( + mode: Mode, + credentials: CredentialLibraryModel, + key: SSHKeyRef? = nil, + defaultStorage: KeyStorage = .iCloud + ) { self.mode = mode self.credentials = credentials original = key label = key?.label ?? "" - storage = key?.storage ?? .iCloud + storage = key?.storage ?? defaultStorage certificate = key?.certificate ?? "" colorTag = key?.colorTag ?? .none let availability = key.map { credentials.keyAvailability(for: $0) } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 9f4cb6dd..9980350d 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -128,13 +128,19 @@ struct MobileHostEditor: View { switch route { case .hostIdentity: MobileIdentityEditor( - app: app, identity: Identity(name: "", username: "") + app: app, + identity: Identity( + name: "", username: "", + passwordStorage: app.newVaultPasswordStorage) ) { identity in draft = MobileHostEditorPolicy.selectIdentity(identity, current: draft) } case .proxyIdentity: MobileIdentityEditor( - app: app, identity: Identity(name: "", username: ""), + app: app, + identity: Identity( + name: "", username: "", + passwordStorage: app.newVaultPasswordStorage), configuration: .proxy ) { identity in draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: draft.proxy) diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index a25f08ae..d66634c4 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -76,14 +76,20 @@ struct MobileCredentialsView: View { .toolbar { Menu { Button("Generate Key", systemImage: "key") { - keyEditor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) + keyEditor = MobileKeyEditorModel( + mode: .generate, credentials: app.credentials, + defaultStorage: app.newVaultKeyStorage) } Button("Import Key", systemImage: "square.and.arrow.down") { - keyEditor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) + keyEditor = MobileKeyEditorModel( + mode: .importKey, credentials: app.credentials, + defaultStorage: app.newVaultKeyStorage) } Divider() Button("New Identity", systemImage: "person.crop.circle.badge.plus") { - editing = Identity(name: "", username: "") + editing = Identity( + name: "", username: "", + passwordStorage: app.newVaultPasswordStorage) } } label: { Label("Add", systemImage: "plus").labelStyle(.iconOnly) diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 61c8c30e..62e8f617 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -10,6 +10,26 @@ import Testing @Suite @MainActor struct MobileVaultFeatureTests { + @Test func appDefaultsNewCredentialsToThisDeviceWhenVaultSyncIsOff() throws { + let domain = "MobileVaultCredentialDefaults.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled) + let app = try MobileAppModel(inMemory: true, defaults: defaults) + defer { app.outbox.stop() } + + #expect(app.newVaultKeyStorage == .thisDevice) + #expect(app.newVaultPasswordStorage == .thisDevice) + let generator = MobileKeyEditorModel( + mode: .generate, + credentials: app.credentials, + defaultStorage: app.newVaultKeyStorage) + #expect(generator.storage == .thisDevice) + + generator.storage = .iCloud + #expect(generator.storage == .iCloud) + } + @Test func hostMenuSeparatesConnectionEditingAndDeletionActions() { #expect( MobileHostMenuPolicy.sections == [ diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index f9609e8a..c86dac26 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -20,6 +20,7 @@ struct AppDependencies { let metricsPreferences: (any MetricsConfigurationStoring)? let mcpServerSecrets: any SecretStoring let logging: any ApplicationLogManaging + let settingsDefaults: UserDefaults init( hostRepository: any HostRepository, outboxRunner: OutboxRunner?, @@ -31,7 +32,8 @@ struct AppDependencies { metricsSync: MetricsSyncCoordinator? = nil, metricsPreferences: (any MetricsConfigurationStoring)? = nil, mcpServerSecrets: any SecretStoring = InMemorySecretStore(), - logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared + logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared, + settingsDefaults: UserDefaults = .standard ) { self.hostRepository = hostRepository self.outboxRunner = outboxRunner @@ -44,14 +46,18 @@ struct AppDependencies { self.metricsPreferences = metricsPreferences self.mcpServerSecrets = mcpServerSecrets self.logging = logging + self.settingsDefaults = settingsDefaults } static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self { - Self( + let defaults = UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! + SettingsKey.migrateSyncPreferences(in: defaults) + return Self( hostRepository: InMemoryHostRepository( snapshot: HostLibrarySnapshot(hosts: hosts, groups: groups) ), - outboxRunner: nil + outboxRunner: nil, + settingsDefaults: defaults ) } } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index ff89713c..5f586765 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -109,13 +109,28 @@ final class AppModel { /// and a run ending is not a reason to stop a deploy. private let agentBackgroundCommands: AgentBackgroundCommands private let outboxRunner: OutboxRunner? + private let settingsDefaults: UserDefaults private var windows: [ObjectIdentifier: WeakWindow] = [:] private var deletedAgentConversationIDs = Set() /// Conversations already handed to the summarizer. Titling is once per /// conversation, and every run re-arms the hook that would do it. func makeSSHImportModel() -> SSHImportModel { - SSHImportModel(library: hosts, reader: SSHImportReader()) + SSHImportModel( + library: hosts, reader: SSHImportReader(), + storage: newVaultPasswordStorage) + } + + var newVaultKeyStorage: KeyStorage { + NewVaultCredentialStorage.keyStorage( + defaults: settingsDefaults, + canUseVaultSync: proAccess.canUse(.vaultSync)) + } + + var newVaultPasswordStorage: PasswordStorage { + NewVaultCredentialStorage.passwordStorage( + defaults: settingsDefaults, + canUseVaultSync: proAccess.canUse(.vaultSync)) } init( @@ -139,6 +154,7 @@ final class AppModel { ) { self.windowSizeStore = windowSizeStore let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups) + settingsDefaults = dependencies.settingsDefaults logging = dependencies.logging let externalAuthentication = ExternalAuthCoordinator( logger: logging.makeLogger(label: "termind.external-mcp")) diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 9964379c..90225269 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -124,7 +124,8 @@ struct TermindApp: App { vaultSync: vaultSync, settingsSync: settingsSync, metricsSync: metricsSync, metricsPreferences: metricsPersistence, mcpServerSecrets: secrets, - logging: logging + logging: logging, + settingsDefaults: .standard ), proAccess: proAccess, appReview: .live(), diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index db986443..fcf9b4ff 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -233,7 +233,10 @@ struct HostEditorView: View { private func destination(_ route: Route) -> some View { switch route { case .identity: - NewIdentitySheet(back: closeNestedEditor) { identity, passwordUpdate in + NewIdentitySheet( + defaultPasswordStorage: app.newVaultPasswordStorage, + back: closeNestedEditor + ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) draft.identityID = identity.id } @@ -243,7 +246,11 @@ struct HostEditorView: View { draft.groupID = group.id } case .proxyIdentity: - NewIdentitySheet(configuration: .proxy, back: closeNestedEditor) { + NewIdentitySheet( + configuration: .proxy, + defaultPasswordStorage: app.newVaultPasswordStorage, + back: closeNestedEditor + ) { identity, passwordUpdate in try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate) draft.proxy?.identityID = identity.id diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index e8ff28e0..3b287a90 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -324,7 +324,9 @@ struct KeychainImportDetail: View { var body: some View { SoftwareKeyEditor( title: "Import Key", symbol: "square.and.arrow.down", - close: window.closeVaultWork, save: save + close: window.closeVaultWork, + defaultStorage: app.newVaultKeyStorage, + save: save ) } @@ -390,6 +392,7 @@ private struct SoftwareKeyEditor: View { init( existing: SSHKeyRef? = nil, title: LocalizedStringKey, symbol: String, close: @escaping () -> Void, + defaultStorage: KeyStorage = .iCloud, loadPrivateKey: (() throws -> String)? = nil, saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil, save: @@ -406,7 +409,7 @@ private struct SoftwareKeyEditor: View { self.save = save _name = State(initialValue: existing?.label ?? "") _colorTag = State(initialValue: existing?.colorTag ?? .none) - _storage = State(initialValue: existing?.storage ?? .iCloud) + _storage = State(initialValue: existing?.storage ?? defaultStorage) _certificate = State(initialValue: existing?.certificate ?? "") _isLoading = State(initialValue: loadPrivateKey != nil) } @@ -759,7 +762,10 @@ struct KeychainGenerateDetail: View { @Environment(WindowModel.self) private var window var body: some View { - GenerateKeySheet(generate: generate, close: window.closeVaultWork) + GenerateKeySheet( + defaultStorage: app.newVaultKeyStorage, + generate: generate, + close: window.closeVaultWork) } private func generate( @@ -828,7 +834,7 @@ private struct GenerateKeySheet: View { @State private var label = "" @State private var colorTag = TagColor.none - @State private var storage = KeyStorage.iCloud + @State private var storage: KeyStorage @State private var kind = Kind.ed25519 @State private var curveBits = 256 @State private var rsaBits = 3072 @@ -846,6 +852,20 @@ private struct GenerateKeySheet: View { .secureEnclave, ] + init( + defaultStorage: KeyStorage = .iCloud, + generate: + @escaping ( + String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int, + KeyStorage, Bool + ) async throws -> SSHKeyRef, + close: @escaping () -> Void + ) { + self.generate = generate + self.close = close + _storage = State(initialValue: defaultStorage) + } + private var generation: SSHKeyMaterial.Generation { if storage == .secureEnclave { return .ecdsa(bits: 256) } return switch kind { diff --git a/TFM/Features/Keychain/NewIdentitySheet.swift b/TFM/Features/Keychain/NewIdentitySheet.swift index de05ba82..2deccc07 100644 --- a/TFM/Features/Keychain/NewIdentitySheet.swift +++ b/TFM/Features/Keychain/NewIdentitySheet.swift @@ -75,6 +75,7 @@ struct NewIdentitySheet: View { init( identity: Identity? = nil, configuration: IdentityEditorConfiguration = .standard, + defaultPasswordStorage: PasswordStorage = .iCloud, back: (() -> Void)? = nil, create: @escaping (Identity, IdentityPasswordUpdate) throws -> Void, close: (() -> Void)? = nil @@ -86,7 +87,7 @@ struct NewIdentitySheet: View { requestedBack = back _name = State(initialValue: identity?.name ?? "") _username = State(initialValue: identity?.username ?? "") - _passwordStorage = State(initialValue: identity?.passwordStorage ?? .iCloud) + _passwordStorage = State(initialValue: identity?.passwordStorage ?? defaultPasswordStorage) _keyID = State(initialValue: identity?.keyID) _colorTag = State(initialValue: identity?.colorTag ?? .none) } diff --git a/TFM/Features/Vault/VaultView.swift b/TFM/Features/Vault/VaultView.swift index 18af6eea..23541160 100644 --- a/TFM/Features/Vault/VaultView.swift +++ b/TFM/Features/Vault/VaultView.swift @@ -53,6 +53,7 @@ struct VaultWorkDetailView: View { case .identityEditor(let id): NewIdentitySheet( identity: id.flatMap(app.hostLibrary.identity), + defaultPasswordStorage: app.newVaultPasswordStorage, create: { identity, password in try app.hostLibrary.upsert(identity, passwordUpdate: password) window.selectVaultItem(.identity(identity.id), on: .keychain) diff --git a/TFMTests/SSHImportTests.swift b/TFMTests/SSHImportTests.swift index 12fdf034..88fe7a87 100644 --- a/TFMTests/SSHImportTests.swift +++ b/TFMTests/SSHImportTests.swift @@ -27,6 +27,26 @@ struct SSHImportTests { return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())" } + @Test + func appDefaultsNewCredentialsAndSSHImportToThisMacWhenVaultSyncIsOff() throws { + let domain = "SSHImportDefaults.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + defaults.set(false, forKey: SettingsKey.vaultSyncEnabled) + let hosts = HostLibraryModel() + let app = AppModel( + hosts: hosts, + dependencies: AppDependencies( + hostRepository: InMemoryHostRepository(), + outboxRunner: nil, + settingsDefaults: defaults)) + defer { app.shutdownForTermination() } + + #expect(app.newVaultKeyStorage == .thisDevice) + #expect(app.newVaultPasswordStorage == .thisDevice) + #expect(app.makeSSHImportModel().storage == .thisDevice) + } + @Test func hashedKeysMatchPortsAndSurvivePersistenceWithoutHosts() throws { let entry = KnownHostEntry(hostname: hash("[2001:db8::1]:2222"), fingerprint: "SHA256:key")