diff --git a/Termind/Core/Models/Identity.swift b/Termind/Core/Models/Identity.swift index cec38e6e..e86292be 100644 --- a/Termind/Core/Models/Identity.swift +++ b/Termind/Core/Models/Identity.swift @@ -1,5 +1,26 @@ import Foundation +nonisolated enum PasswordStorage: String, Codable, Hashable, Sendable, CaseIterable, Identifiable { + case thisDevice + case iCloudKeychain + + var id: String { rawValue } + + var displayName: String { + switch self { + case .thisDevice: String(localized: "This Mac") + case .iCloudKeychain: String(localized: "iCloud") + } + } + + var symbol: String { + switch self { + case .thisDevice: "lock.laptopcomputer" + case .iCloudKeychain: "icloud.fill" + } + } +} + /// How an Identity edit changes the password stored behind `credentialRef`. /// /// `nil` could only mean one of "keep" and "remove", which made it impossible @@ -12,6 +33,13 @@ nonisolated enum IdentityPasswordUpdate: Hashable, Sendable { case remove } +nonisolated enum SecretAvailability: Equatable, Sendable { + case notConfigured + case available + case missing + case failed(String) +} + /// A named way of logging in: a username plus a *reference* to the secret. /// /// Split out from `Host` because the same login is usually reused across a whole @@ -35,6 +63,9 @@ nonisolated struct Identity: Identifiable, Hashable, Sendable { var username: String /// Where to look the password up. Empty means this identity has no password. var credentialRef: String = "" + /// The secret itself never enters SwiftData. This value only selects the + /// Data Protection Keychain class used to resolve `credentialRef`. + var passwordStorage: PasswordStorage = .iCloudKeychain /// One of the entries on the Keys list. Storing the id rather than the key /// means renaming or rotating a key never touches the identities that use /// it. `nil` means this identity has no key. diff --git a/Termind/Core/Models/Snippet.swift b/Termind/Core/Models/Snippet.swift index e7eaa222..ca884d6b 100644 --- a/Termind/Core/Models/Snippet.swift +++ b/Termind/Core/Models/Snippet.swift @@ -5,7 +5,7 @@ import Foundation #endif nonisolated enum KeyStorage: String, Codable, Hashable, Sendable, CaseIterable, Identifiable { - case localKeychain + case thisDevice case iCloudKeychain case secureEnclave @@ -13,7 +13,7 @@ nonisolated enum KeyStorage: String, Codable, Hashable, Sendable, CaseIterable, var displayName: String { switch self { - case .localKeychain: + case .thisDevice: #if os(macOS) String(localized: "This Mac") #elseif os(iOS) @@ -30,7 +30,7 @@ nonisolated enum KeyStorage: String, Codable, Hashable, Sendable, CaseIterable, var symbol: String { switch self { - case .localKeychain: "lock.laptopcomputer" + case .thisDevice: "lock.laptopcomputer" case .iCloudKeychain: "icloud.fill" case .secureEnclave: "checkmark.shield.fill" } @@ -104,12 +104,15 @@ nonisolated struct SSHKeyRef: Identifiable, Hashable, Sendable { var label: String var algorithm: SSHKeyAlgorithm = .ed25519 var publicKey: String = "" + /// Optional OpenSSH user certificate. Certificates are public metadata; + /// the private key remains behind `credentialRef` in Keychain. + var certificate: String = "" var fingerprint: String = "" var credentialRef: String = "" - var storage: KeyStorage = .localKeychain + var storage: KeyStorage = .iCloudKeychain var keySize: Int? var hasPassphrase = false - /// Saving a passphrase is always explicit; new keys default to asking when used. + /// Legacy migration marker. New writes never retain a passphrase. var storesPassphrase = false var requiresUserPresence = false var createdAt: Date = Date(timeIntervalSince1970: 0) diff --git a/Termind/Core/Persistence/HostPersistence.swift b/Termind/Core/Persistence/HostPersistence.swift index 7ef9119d..29c2891c 100644 --- a/Termind/Core/Persistence/HostPersistence.swift +++ b/Termind/Core/Persistence/HostPersistence.swift @@ -148,91 +148,165 @@ final class PersistentSnippet { } } -@Model -final class PersistentIdentity { - @Attribute(.unique) var id: UUID = UUID() - var name = "" - var username = "" - var credentialRef = "" - var keyID: UUID? - var notes = "" - var lastUsedAt: Date? - var sortIndex = 0.0 +// The V3 and V4 versions of these two models intentionally live in their +// schema namespaces. Reusing the live model type for an older VersionedSchema +// changes that older schema's checksum whenever a field is added and makes a +// staged migration impossible. +enum TermindSchemaV3Models { + @Model + final class PersistentIdentity { + @Attribute(.unique) var id: UUID = UUID() + var name = "" + var username = "" + var credentialRef = "" + var keyID: UUID? + var notes = "" + var lastUsedAt: Date? + var sortIndex = 0.0 - init(_ value: Identity) { - id = value.id - apply(value) + init(_ value: Identity) { + id = value.id + name = value.name + username = value.username + credentialRef = value.credentialRef + keyID = value.keyID + notes = value.notes + lastUsedAt = value.lastUsedAt + sortIndex = value.sortIndex + } } - func apply(_ value: Identity) { - name = value.name - username = value.username - credentialRef = value.credentialRef - keyID = value.keyID - notes = value.notes - lastUsedAt = value.lastUsedAt - sortIndex = value.sortIndex - } + @Model + final class PersistentSSHKey { + @Attribute(.unique) var id: UUID = UUID() + var label = "" + var algorithm = SSHKeyAlgorithm.ed25519.rawValue + var publicKey = "" + var fingerprint = "" + var credentialRef = "" + var storage = "local" + "Keychain" + var keySize: Int? + var hasPassphrase = false + var storesPassphrase = false + var requiresUserPresence = false + var createdAt = Date(timeIntervalSince1970: 0) + var sortIndex = 0.0 - var value: Identity { - Identity( - id: id, name: name, username: username, credentialRef: credentialRef, - keyID: keyID, notes: notes, lastUsedAt: lastUsedAt, sortIndex: sortIndex - ) + init(_ value: SSHKeyRef) { + id = value.id + label = value.label + algorithm = value.algorithm.rawValue + publicKey = value.publicKey + fingerprint = value.fingerprint + credentialRef = value.credentialRef + storage = value.storage.rawValue + keySize = value.keySize + hasPassphrase = value.hasPassphrase + storesPassphrase = value.storesPassphrase + requiresUserPresence = value.requiresUserPresence + createdAt = value.createdAt + sortIndex = value.sortIndex + } } } -@Model -final class PersistentSSHKey { - @Attribute(.unique) var id: UUID = UUID() - var label = "" - var algorithm = SSHKeyAlgorithm.ed25519.rawValue - var publicKey = "" - var fingerprint = "" - var credentialRef = "" - var storage = KeyStorage.localKeychain.rawValue - var keySize: Int? - var hasPassphrase = false - var storesPassphrase = false - var requiresUserPresence = false - var createdAt = Date(timeIntervalSince1970: 0) - var sortIndex = 0.0 +enum TermindSchemaV4Models { + @Model + final class PersistentIdentity { + @Attribute(.unique) var id: UUID = UUID() + var name = "" + var username = "" + var credentialRef = "" + /// Rows created by older versions must continue to resolve the + /// local-only Keychain item they already own. + var passwordStorage = PasswordStorage.thisDevice.rawValue + var keyID: UUID? + var notes = "" + var lastUsedAt: Date? + var sortIndex = 0.0 - init(_ value: SSHKeyRef) { - id = value.id - apply(value) + init(_ value: Identity) { + id = value.id + apply(value) + } + + func apply(_ value: Identity) { + name = value.name + username = value.username + credentialRef = value.credentialRef + passwordStorage = value.passwordStorage.rawValue + keyID = value.keyID + notes = value.notes + lastUsedAt = value.lastUsedAt + sortIndex = value.sortIndex + } + + var value: Identity { + Identity( + id: id, name: name, username: username, credentialRef: credentialRef, + passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .thisDevice, + keyID: keyID, notes: notes, lastUsedAt: lastUsedAt, sortIndex: sortIndex + ) + } } - func apply(_ value: SSHKeyRef) { - label = value.label - algorithm = value.algorithm.rawValue - publicKey = value.publicKey - fingerprint = value.fingerprint - credentialRef = value.credentialRef - storage = value.storage.rawValue - keySize = value.keySize - hasPassphrase = value.hasPassphrase - storesPassphrase = value.storesPassphrase - requiresUserPresence = value.requiresUserPresence - createdAt = value.createdAt - sortIndex = value.sortIndex - } + @Model + final class PersistentSSHKey { + @Attribute(.unique) var id: UUID = UUID() + var label = "" + var algorithm = SSHKeyAlgorithm.ed25519.rawValue + var publicKey = "" + var certificate = "" + var fingerprint = "" + var credentialRef = "" + var storage = KeyStorage.thisDevice.rawValue + var keySize: Int? + var hasPassphrase = false + var storesPassphrase = false + var requiresUserPresence = false + var createdAt = Date(timeIntervalSince1970: 0) + var sortIndex = 0.0 - var value: SSHKeyRef { - SSHKeyRef( - id: id, label: label, - algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519, - publicKey: publicKey, fingerprint: fingerprint, - credentialRef: credentialRef, - storage: KeyStorage(rawValue: storage) ?? .localKeychain, - keySize: keySize, hasPassphrase: hasPassphrase, - storesPassphrase: storesPassphrase, - requiresUserPresence: requiresUserPresence, createdAt: createdAt, - sortIndex: sortIndex - ) + init(_ value: SSHKeyRef) { + id = value.id + apply(value) + } + + func apply(_ value: SSHKeyRef) { + label = value.label + algorithm = value.algorithm.rawValue + publicKey = value.publicKey + certificate = value.certificate + fingerprint = value.fingerprint + credentialRef = value.credentialRef + storage = value.storage.rawValue + keySize = value.keySize + hasPassphrase = value.hasPassphrase + storesPassphrase = value.storesPassphrase + requiresUserPresence = value.requiresUserPresence + createdAt = value.createdAt + sortIndex = value.sortIndex + } + + var value: SSHKeyRef { + SSHKeyRef( + id: id, label: label, + algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519, + publicKey: publicKey, certificate: certificate, fingerprint: fingerprint, + credentialRef: credentialRef, + storage: KeyStorage(rawValue: storage) ?? .thisDevice, + keySize: keySize, hasPassphrase: hasPassphrase, + storesPassphrase: storesPassphrase, + requiresUserPresence: requiresUserPresence, createdAt: createdAt, + sortIndex: sortIndex + ) + } } } +typealias PersistentIdentity = TermindSchemaV4Models.PersistentIdentity +typealias PersistentSSHKey = TermindSchemaV4Models.PersistentSSHKey + @Model final class PersistentKnownHost { @Attribute(.unique) var id: UUID = UUID() @@ -306,7 +380,8 @@ enum TermindSchemaV1: VersionedSchema { static var models: [any PersistentModel.Type] { [ PersistentHost.self, PersistentHostGroup.self, - PersistentIdentity.self, PersistentSSHKey.self, + TermindSchemaV3Models.PersistentIdentity.self, + TermindSchemaV3Models.PersistentSSHKey.self, PersistentKnownHost.self, PersistentSnippetPackage.self, PersistentSnippet.self, PersistentPortForwardingRule.self, ] @@ -331,9 +406,22 @@ enum TermindSchemaV3: VersionedSchema { } } +enum TermindSchemaV4: VersionedSchema { + static let versionIdentifier = Schema.Version(4, 0, 0) + static var models: [any PersistentModel.Type] { + TermindSchemaV3.models.filter { + $0 != TermindSchemaV3Models.PersistentIdentity.self + && $0 != TermindSchemaV3Models.PersistentSSHKey.self + } + [PersistentIdentity.self, PersistentSSHKey.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self] + [ + TermindSchemaV1.self, TermindSchemaV2.self, + TermindSchemaV3.self, TermindSchemaV4.self, + ] } static var stages: [MigrationStage] { @@ -345,6 +433,23 @@ enum TermindMigrationPlan: SchemaMigrationPlan { .lightweight( fromVersion: TermindSchemaV2.self, toVersion: TermindSchemaV3.self + ), + .custom( + fromVersion: TermindSchemaV3.self, + toVersion: TermindSchemaV4.self, + willMigrate: nil, + didMigrate: { context in + let legacyStorage = "local" + "Keychain" + let keys = try context.fetch(FetchDescriptor()) + for key in keys where key.storage == legacyStorage { + key.storage = KeyStorage.thisDevice.rawValue + } + let identities = try context.fetch(FetchDescriptor()) + for identity in identities where identity.passwordStorage == legacyStorage { + identity.passwordStorage = PasswordStorage.thisDevice.rawValue + } + try context.save() + } ) ] } @@ -352,7 +457,7 @@ enum TermindMigrationPlan: SchemaMigrationPlan { @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV3.self) + static let schema = Schema(versionedSchema: TermindSchemaV4.self) let context: ModelContext diff --git a/Termind/Core/SSH/SSHConnectionJourney.swift b/Termind/Core/SSH/SSHConnectionJourney.swift index 24e6070a..8d1fe1bc 100644 --- a/Termind/Core/SSH/SSHConnectionJourney.swift +++ b/Termind/Core/SSH/SSHConnectionJourney.swift @@ -39,6 +39,7 @@ nonisolated struct SSHAuthenticationSubmission: Sendable { var password: String? var passphrase: String? var keyID: SSHKeyRef.ID? + var identityID: Identity.ID? = nil } /// UI-facing state for one SSH attempt. The transport emits facts; this model @@ -64,6 +65,11 @@ final class SSHConnectionJourney { case hostKey } + enum LocalRequirement: Equatable { + case identity + case passphrase + } + struct Checkpoint: Equatable { var endpointIndex: Int var kind: CheckpointKind @@ -84,7 +90,7 @@ final class SSHConnectionJourney { private(set) var completedEndpointIndex = 0 private(set) var checkpoint: Checkpoint? private(set) var completedCheckpoints: [Int: CheckpointKind] = [:] - private(set) var waitsForLocalIdentity = false + private(set) var localRequirement: LocalRequirement? private(set) var logs: [LogEntry] = [] var showsLogs = false @@ -92,6 +98,8 @@ final class SSHConnectionJourney { var isPresented: Bool { phase != .hidden } var destination: SSHConnectionEndpoint? { endpoints.last } + var waitsForLocalIdentity: Bool { localRequirement == .identity } + var waitsForLocalPassphrase: Bool { localRequirement == .passphrase } func begin( host: Host, route: [Host], purpose: SSHConnectionPurpose, @@ -129,7 +137,7 @@ final class SSHConnectionJourney { completedEndpointIndex = retainedCompletedEndpoint checkpoint = nil completedCheckpoints = retainedCheckpoints - waitsForLocalIdentity = false + localRequirement = nil logs = retainedLogs showsLogs = retainedLogVisibility let action = mode == .reconnection ? "Reconnecting " : "Starting " @@ -192,7 +200,7 @@ final class SSHConnectionJourney { activeEndpointIndex = 0 completedEndpointIndex = 0 checkpoint = nil - waitsForLocalIdentity = true + localRequirement = .identity phase = .waiting log( endpoint: endpoints[index].name, @@ -200,6 +208,19 @@ final class SSHConnectionJourney { ) } + func waitForLocalPassphrase(at hostID: Host.ID, failure: String? = nil) { + guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return } + activeEndpointIndex = 0 + completedEndpointIndex = 0 + checkpoint = nil + localRequirement = .passphrase + phase = .waiting + log( + endpoint: endpoints[index].name, + message: failure ?? String(localized: "Waiting for a Passphrase") + ) + } + func waitForHostKey(at hostID: Host.ID, fingerprint: String) { wait( at: hostID, kind: .hostKey, @@ -210,7 +231,7 @@ final class SSHConnectionJourney { func resume() { guard phase == .waiting else { return } phase = .connecting - waitsForLocalIdentity = false + localRequirement = nil if let checkpoint { log(endpoint: endpoints[checkpoint.endpointIndex].name, message: String(localized: "Continuing connection")) completedCheckpoints[checkpoint.endpointIndex] = checkpoint.kind @@ -221,7 +242,7 @@ final class SSHConnectionJourney { func succeed() { guard !endpoints.isEmpty else { return } checkpoint = nil - waitsForLocalIdentity = false + localRequirement = nil completedEndpointIndex = endpoints.count - 1 activeEndpointIndex = endpoints.count - 1 phase = .succeeded @@ -237,7 +258,7 @@ final class SSHConnectionJourney { func fail(_ message: String, at hostID: Host.ID? = nil) { completionTask?.cancel() checkpoint = nil - waitsForLocalIdentity = false + localRequirement = nil if let hostID, let index = endpoints.firstIndex(where: { $0.hostID == hostID }) { @@ -254,7 +275,7 @@ final class SSHConnectionJourney { completionTask?.cancel() checkpoint = nil completedCheckpoints = [:] - waitsForLocalIdentity = false + localRequirement = nil phase = .hidden } @@ -274,7 +295,7 @@ final class SSHConnectionJourney { guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return } activeEndpointIndex = index checkpoint = Checkpoint(endpointIndex: index, kind: kind) - waitsForLocalIdentity = false + localRequirement = nil phase = .waiting log(endpoint: endpoints[index].name, message: message) } diff --git a/Termind/Core/SSH/SSHSessionActor.swift b/Termind/Core/SSH/SSHSessionActor.swift index e33dcab2..fc72707c 100644 --- a/Termind/Core/SSH/SSHSessionActor.swift +++ b/Termind/Core/SSH/SSHSessionActor.swift @@ -36,13 +36,15 @@ nonisolated enum SSHCredentials: Sendable { nonisolated extension StoredSSHKeyCredential { func sshCredential( - publicKey: String, passphraseOverride: String? = nil + publicKey: String, certificate: String = "", passphraseOverride: String? = nil ) -> SSHCredentials { switch self { case .software(let secret): .privateKey( privateKey: secret.privateKey, - publicKey: publicKey.isEmpty ? nil : publicKey, + publicKey: certificate.isEmpty + ? (publicKey.isEmpty ? nil : publicKey) + : certificate, passphrase: passphraseOverride ?? secret.passphrase ) case .secureEnclave(let credential): diff --git a/Termind/Core/SSH/SSHUserCertificate.swift b/Termind/Core/SSH/SSHUserCertificate.swift new file mode 100644 index 00000000..78096133 --- /dev/null +++ b/Termind/Core/SSH/SSHUserCertificate.swift @@ -0,0 +1,166 @@ +import Foundation + +nonisolated enum SSHUserCertificateError: LocalizedError, Equatable { + case invalidFormat + case unsupportedAlgorithm(String) + case hostCertificate + case keyMismatch + + var errorDescription: String? { + switch self { + case .invalidFormat: + String(localized: "The certificate is not a valid OpenSSH user certificate.") + case .unsupportedAlgorithm(let algorithm): + String(localized: "Unsupported SSH certificate algorithm: \(algorithm)") + case .hostCertificate: + String(localized: "Host certificates cannot be used for user authentication.") + case .keyMismatch: + String(localized: "The certificate does not belong to this private key.") + } + } +} + +nonisolated struct SSHUserCertificate: Hashable, Sendable { + var authorizedKey: String + var algorithm: SSHKeyAlgorithm + var publicKeyBlob: Data + var serial: UInt64 + var keyID: String + var validAfter: Date + var validBefore: Date? + + var validityWarning: String? { + let now = Date() + if now < validAfter { + return String(localized: "This certificate is not valid yet.") + } + if let validBefore, now >= validBefore { + return String(localized: "This certificate has expired.") + } + return nil + } + + static func parse(_ text: String, matching publicKey: String? = nil) throws -> Self { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = normalized.split(whereSeparator: \Character.isWhitespace) + guard fields.count >= 2, + let blob = Data(base64Encoded: String(fields[1])) + else { throw SSHUserCertificateError.invalidFormat } + + var reader = SSHWireReader(data: blob) + guard let certificateMethodData = reader.readField(), + let certificateMethod = String(data: certificateMethodData, encoding: .utf8), + certificateMethod == String(fields[0]), + certificateMethod.hasSuffix("-cert-v01@openssh.com"), + reader.readField() != nil + else { throw SSHUserCertificateError.invalidFormat } + + let algorithm: SSHKeyAlgorithm + let plainMethod: String + let keyFields: [Data] + switch certificateMethod { + case "ssh-ed25519-cert-v01@openssh.com": + algorithm = .ed25519 + plainMethod = SSHKeyAlgorithm.ed25519.rawValue + keyFields = try reader.readFields(count: 1) + case "ecdsa-sha2-nistp256-cert-v01@openssh.com": + algorithm = .ecdsa256 + plainMethod = SSHKeyAlgorithm.ecdsa256.rawValue + keyFields = try reader.readFields(count: 2) + case "ecdsa-sha2-nistp384-cert-v01@openssh.com": + algorithm = .ecdsa384 + plainMethod = SSHKeyAlgorithm.ecdsa384.rawValue + keyFields = try reader.readFields(count: 2) + case "ecdsa-sha2-nistp521-cert-v01@openssh.com": + algorithm = .ecdsa521 + plainMethod = SSHKeyAlgorithm.ecdsa521.rawValue + keyFields = try reader.readFields(count: 2) + case "ssh-rsa-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com": + algorithm = .rsa + plainMethod = SSHKeyAlgorithm.rsa.rawValue + keyFields = try reader.readFields(count: 2) + default: + throw SSHUserCertificateError.unsupportedAlgorithm(certificateMethod) + } + + guard let serial = reader.readUInt64(), let type = reader.readUInt32(), + let keyIDData = reader.readField(), + reader.readField() != nil, + let validAfter = reader.readUInt64(), let validBefore = reader.readUInt64(), + reader.readField() != nil, reader.readField() != nil, + reader.readField() != nil, reader.readField() != nil, + reader.readField() != nil, reader.isAtEnd + else { throw SSHUserCertificateError.invalidFormat } + guard type == 1 else { + if type == 2 { throw SSHUserCertificateError.hostCertificate } + throw SSHUserCertificateError.invalidFormat + } + + let publicBlob = SSHWireWriter.field(Data(plainMethod.utf8)) + + keyFields.reduce(into: Data()) { $0.append(SSHWireWriter.field($1)) } + if let publicKey { + let publicFields = publicKey.split(whereSeparator: \Character.isWhitespace) + guard publicFields.count >= 2, + let expected = Data(base64Encoded: String(publicFields[1])), + expected == publicBlob + else { throw SSHUserCertificateError.keyMismatch } + } + + return Self( + authorizedKey: normalized, algorithm: algorithm, publicKeyBlob: publicBlob, + serial: serial, keyID: String(data: keyIDData, encoding: .utf8) ?? "", + validAfter: Date(timeIntervalSince1970: TimeInterval(validAfter)), + validBefore: validBefore == UInt64.max + ? nil : Date(timeIntervalSince1970: TimeInterval(validBefore)) + ) + } +} + +private nonisolated struct SSHWireReader { + let data: Data + var offset = 0 + var isAtEnd: Bool { offset == data.count } + + mutating func readUInt32() -> UInt32? { + guard offset + 4 <= data.count else { return nil } + let value = data[offset..<(offset + 4)].reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + offset += 4 + return value + } + + mutating func readUInt64() -> UInt64? { + guard offset + 8 <= data.count else { return nil } + let value = data[offset..<(offset + 8)].reduce(UInt64(0)) { + ($0 << 8) | UInt64($1) + } + offset += 8 + return value + } + + mutating func readField() -> Data? { + guard let length = readUInt32(), offset + Int(length) <= data.count + else { return nil } + defer { offset += Int(length) } + return data[offset..<(offset + Int(length))] + } + + mutating func readFields(count: Int) throws -> [Data] { + var result: [Data] = [] + for _ in 0.. Data { + var length = UInt32(data.count).bigEndian + return withUnsafeBytes(of: &length) { Data($0) } + data + } +} diff --git a/Termind/Core/Security/KeychainSecretStore.swift b/Termind/Core/Security/KeychainSecretStore.swift index 8148e923..1defffa1 100644 --- a/Termind/Core/Security/KeychainSecretStore.swift +++ b/Termind/Core/Security/KeychainSecretStore.swift @@ -37,11 +37,17 @@ nonisolated struct KeychainSecretStore: Sendable { self.accessGroup = accessGroup } + /// Local-only compatibility entry point used by AI and MCP secrets. func string(for account: String) throws -> String? { + try string(for: account, storage: .thisDevice) + } + + func string(for account: String, storage: PasswordStorage) throws -> String? { try validateStringReference(account) guard let data = try data( - for: account, service: Self.passwordService, synchronizable: false + for: account, service: Self.passwordService, + synchronizable: storage == .iCloudKeychain ) else { return nil } guard let value = String(data: data, encoding: .utf8) else { @@ -50,11 +56,19 @@ nonisolated struct KeychainSecretStore: Sendable { return value } + /// Local-only compatibility entry point used by AI and MCP secrets. func setString(_ value: String?, for account: String) throws { + try setString(value, for: account, storage: .thisDevice) + } + + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { try validateStringReference(account) try setData( value?.data(using: .utf8), for: account, service: Self.passwordService, - synchronizable: false, accessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + synchronizable: storage == .iCloudKeychain, + accessible: storage == .iCloudKeychain + ? kSecAttrAccessibleWhenUnlocked + : kSecAttrAccessibleWhenUnlockedThisDeviceOnly ) } @@ -69,7 +83,7 @@ nonisolated struct KeychainSecretStore: Sendable { ) else { return nil } switch storage { - case .localKeychain, .iCloudKeychain: + case .thisDevice, .iCloudKeychain: return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)) case .secureEnclave: guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } @@ -83,7 +97,7 @@ nonisolated struct KeychainSecretStore: Sendable { try validate(account, prefix: "termind.key.") let data: Data? switch (value, storage) { - case (.software(let secret), .localKeychain), (.software(let secret), .iCloudKeychain): + case (.software(let secret), .thisDevice), (.software(let secret), .iCloudKeychain): data = try JSONEncoder().encode(secret) case (.secureEnclave(let credential), .secureEnclave): data = credential.dataRepresentation @@ -101,10 +115,15 @@ nonisolated struct KeychainSecretStore: Sendable { ) } - func removePassword(_ account: String) throws { + func removePassword( + _ account: String, storage: PasswordStorage = .thisDevice + ) throws { guard !account.isEmpty else { return } try validate(account, prefix: "termind.password.") - try remove(account, service: Self.passwordService, synchronizable: false) + try remove( + account, service: Self.passwordService, + synchronizable: storage == .iCloudKeychain + ) } func removeKeyCredential(_ account: String, storage: KeyStorage) throws { @@ -178,7 +197,7 @@ nonisolated struct KeychainSecretStore: Sendable { private func service(for storage: KeyStorage) -> String { switch storage { - case .localKeychain, .iCloudKeychain: Self.softwareKeyService + case .thisDevice, .iCloudKeychain: Self.softwareKeyService case .secureEnclave: Self.enclaveService } } diff --git a/Termind/Core/Stores/HostStore+Demo.swift b/Termind/Core/Stores/HostStore+Demo.swift index d3ce8f28..7f84589d 100644 --- a/Termind/Core/Stores/HostStore+Demo.swift +++ b/Termind/Core/Stores/HostStore+Demo.swift @@ -22,7 +22,7 @@ extension HostStore { label: "Work (no sync)", algorithm: .ecdsa256, fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY", credentialRef: "termind.key.work", - storage: .localKeychain + storage: .thisDevice ) // Built before the hosts, because a host now references its login by id. diff --git a/Termind/Core/Stores/HostStore.swift b/Termind/Core/Stores/HostStore.swift index fa16ce29..72481295 100644 --- a/Termind/Core/Stores/HostStore.swift +++ b/Termind/Core/Stores/HostStore.swift @@ -76,6 +76,62 @@ final class HostStore { self.keychain = keychain } + /// Passphrases saved by older builds are removed from the Keychain in place. + /// `storesPassphrase` doubles as a durable retry marker: a failed item remains + /// true and is attempted again on the next launch. + private func clearLegacySavedKeyPassphrases() { + let snapshot = keys + var changed = false + var migrationFailure: String? + + for index in keys.indices where keys[index].storesPassphrase { + let key = keys[index] + do { + guard let credential = try keyCredential(for: key) else { + keys[index].storesPassphrase = false + changed = true + continue + } + switch credential { + case .software(let secret): + if secret.passphrase != nil { + try keychain.setKeyCredential( + .software( + SSHPrivateKeySecret( + privateKey: secret.privateKey, passphrase: nil + ) + ), + for: key.credentialRef, storage: key.storage + ) + } + keys[index].storesPassphrase = false + changed = true + case .secureEnclave: + keys[index].storesPassphrase = false + changed = true + } + } catch { + migrationFailure = migrationFailure ?? error.localizedDescription + } + } + + if changed, !persistCore() { + keys = snapshot + } + if let migrationFailure { storageError = migrationFailure } + } + + private static func withoutSavedPassphrase( + _ credential: StoredSSHKeyCredential + ) -> StoredSSHKeyCredential { + switch credential { + case .software(let secret): + .software(SSHPrivateKeySecret(privateKey: secret.privateKey, passphrase: nil)) + case .secureEnclave: + credential + } + } + convenience init(modelContext: ModelContext, keychain: KeychainSecretStore = KeychainSecretStore()) { self.init(keychain: keychain) let persistence = HostPersistence(context: modelContext) @@ -94,6 +150,7 @@ final class HostStore { snippets = loaded.snippets.sorted { $0.sortIndex < $1.sortIndex } snippetPackages = loaded.snippetPackages portForwardings = loaded.portForwardings + clearLegacySavedKeyPassphrases() } catch { storageError = error.localizedDescription } @@ -287,8 +344,11 @@ final class HostStore { guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return String(localized: "No username") } - let hasUsableKeyReference = identity.keyID.flatMap { key($0) } != nil - guard identity.usesPassword || hasUsableKeyReference else { + let hasAvailablePassword = passwordAvailability(for: identity) == .available + let hasUsableKey = identity.keyID.flatMap { key($0) }.map { + keyAvailability(for: $0) == .available + } == true + guard hasAvailablePassword || hasUsableKey else { return String(localized: "No saved credential") } let knownHosts = knownHosts(for: host) @@ -449,7 +509,7 @@ final class HostStore { { credentials.append( stored.sshCredential( - publicKey: key.publicKey, + publicKey: key.publicKey, certificate: key.certificate, passphraseOverride: submission.passphrase ) ) @@ -499,7 +559,9 @@ final class HostStore { requiredPassphraseKeyID = key.id } else { credentials.append( - credential.sshCredential(publicKey: key.publicKey) + credential.sshCredential( + publicKey: key.publicKey, certificate: key.certificate + ) ) } } else { @@ -944,20 +1006,32 @@ final class HostStore { /// which prevents a cleared Identity from making its former Keychain item /// unreachable. func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { - let previousReference = - self.identity(identity.id)?.credentialRef - ?? identity.credentialRef + let previousIdentity = self.identity(identity.id) + let previousReference = previousIdentity?.credentialRef ?? identity.credentialRef + let previousStorage = previousIdentity?.passwordStorage ?? identity.passwordStorage var updated = identity let oldPassword = previousReference.isEmpty - ? nil : try keychain.string(for: previousReference) + ? nil : try keychain.string(for: previousReference, storage: previousStorage) let identitySnapshot = identities let hostSnapshot = hosts + var wroteDestination = false do { switch passwordUpdate { case .keep: updated.credentialRef = previousReference + if previousStorage != updated.passwordStorage, !previousReference.isEmpty { + guard let oldPassword else { + throw TermindSSHError.library( + String(localized: "The saved password is unavailable on this Mac. Enter it again before changing storage.") + ) + } + try keychain.setString( + oldPassword, for: previousReference, storage: updated.passwordStorage + ) + wroteDestination = true + } case .replace(let password): guard !password.isEmpty else { throw TermindSSHError.library( @@ -968,12 +1042,10 @@ final class HostStore { previousReference.isEmpty ? "termind.password.\(UUID().uuidString)" : previousReference - try keychain.setString(password, for: reference) + try keychain.setString(password, for: reference, storage: updated.passwordStorage) + wroteDestination = true updated.credentialRef = reference case .remove: - if !previousReference.isEmpty { - try keychain.removePassword(previousReference) - } updated.credentialRef = "" } storageError = nil @@ -986,10 +1058,16 @@ final class HostStore { identities = identitySnapshot hosts = hostSnapshot do { - if let oldPassword { - try keychain.setString(oldPassword, for: previousReference) - } else if !updated.credentialRef.isEmpty { - try keychain.removePassword(updated.credentialRef) + if wroteDestination { + if updated.passwordStorage == previousStorage, let oldPassword { + try keychain.setString( + oldPassword, for: previousReference, storage: previousStorage + ) + } else if !updated.credentialRef.isEmpty { + try keychain.removePassword( + updated.credentialRef, storage: updated.passwordStorage + ) + } } } catch { storageError = String( @@ -1000,6 +1078,21 @@ final class HostStore { storageError ?? String(localized: "The identity could not be saved.") ) } + + let removesPrevious = + !previousReference.isEmpty + && (updated.credentialRef.isEmpty || previousStorage != updated.passwordStorage) + if removesPrevious { + do { + try keychain.removePassword(previousReference, storage: previousStorage) + } catch { + // The newly committed destination stays authoritative. Retaining + // the source is safer than rolling a successful save backward. + storageError = String( + localized: "The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" + ) + } + } } /// The password behind an identity's `credentialRef`, loaded from Keychain. @@ -1017,7 +1110,22 @@ final class HostStore { /// must remain distinguishable from an Identity that has no password. func passwordCredential(for identity: Identity) throws -> String? { guard identity.usesPassword else { return nil } - return try keychain.string(for: identity.credentialRef) + return try keychain.string( + for: identity.credentialRef, storage: identity.passwordStorage + ) + } + + func passwordAvailability(for identity: Identity) -> SecretAvailability { + guard identity.usesPassword else { return .notConfigured } + do { + return try passwordCredential(for: identity) == nil ? .missing : .available + } catch { + return .failed(error.localizedDescription) + } + } + + func isProxyEligibleOnThisDevice(_ identity: Identity) -> Bool { + identity.isProxyEligible && passwordAvailability(for: identity) == .available } /// Removes an identity, along with the password it was the only way to @@ -1030,8 +1138,12 @@ final class HostStore { do { password = identity.credentialRef.isEmpty - ? nil : try keychain.string(for: identity.credentialRef) - try keychain.removePassword(identity.credentialRef) + ? nil : try keychain.string( + for: identity.credentialRef, storage: identity.passwordStorage + ) + try keychain.removePassword( + identity.credentialRef, storage: identity.passwordStorage + ) } catch { storageError = error.localizedDescription return false @@ -1050,7 +1162,12 @@ final class HostStore { identities = identitySnapshot hosts = hostSnapshot if let password { - do { try keychain.setString(password, for: identity.credentialRef) } catch { + do { + try keychain.setString( + password, for: identity.credentialRef, + storage: identity.passwordStorage + ) + } catch { storageError = String( localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" ) @@ -1063,11 +1180,33 @@ final class HostStore { func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { let previousKey = self.key(key.id) - let previousCredential = try previousKey.flatMap { try keyCredential(for: $0) } + if key.storage == .secureEnclave, !key.certificate.isEmpty { + throw TermindSSHError.library( + String(localized: "Secure Enclave keys cannot store an SSH certificate.") + ) + } + if !key.certificate.isEmpty { + _ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey) + } let keySnapshot = keys - if let credential { + let changesLocation = previousKey.map { + $0.storage != key.storage || $0.credentialRef != key.credentialRef + } ?? false + if changesLocation, + previousKey?.storage == .secureEnclave || key.storage == .secureEnclave + { + throw TermindSSHError.library( + String(localized: "Secure Enclave keys cannot be moved to another storage location.") + ) + } + let previousCredential = try (credential != nil || changesLocation) + ? previousKey.flatMap { try keyCredential(for: $0) } + : nil + let destinationCredential = credential ?? (changesLocation ? previousCredential : nil) + let sanitizedCredential = destinationCredential.map(Self.withoutSavedPassphrase) + if let sanitizedCredential { try keychain.setKeyCredential( - credential, for: key.credentialRef, storage: key.storage + sanitizedCredential, for: key.credentialRef, storage: key.storage ) } if let index = keys.firstIndex(where: { $0.id == key.id }) { @@ -1082,19 +1221,16 @@ final class HostStore { guard persistCore() else { keys = keySnapshot do { - if let previousKey, let previousCredential { + if changesLocation, sanitizedCredential != nil { + try keychain.removeKeyCredential( + key.credentialRef, storage: key.storage + ) + } else if sanitizedCredential != nil, let previousKey, let previousCredential { try keychain.setKeyCredential( previousCredential, for: previousKey.credentialRef, storage: previousKey.storage ) - if previousKey.credentialRef != key.credentialRef - || previousKey.storage != key.storage - { - try keychain.removeKeyCredential( - key.credentialRef, storage: key.storage - ) - } - } else if credential != nil { + } else if sanitizedCredential != nil { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } } catch { @@ -1106,6 +1242,18 @@ final class HostStore { storageError ?? String(localized: "The key could not be saved.") ) } + + if changesLocation, let previousKey { + do { + try keychain.removeKeyCredential( + previousKey.credentialRef, storage: previousKey.storage + ) + } catch { + storageError = String( + localized: "The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" + ) + } + } } /// Reorders Keys without allowing them to leave their fixed Keychain @@ -1135,6 +1283,14 @@ final class HostStore { try keychain.keyCredential(for: key.credentialRef, storage: key.storage) } + func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { + do { + return try keyCredential(for: key) == nil ? .missing : .available + } catch { + return .failed(error.localizedDescription) + } + } + func delete(keyID: SSHKeyRef.ID) throws { guard let key = key(keyID) else { return } let credential = try keyCredential(for: key) diff --git a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift index 83d29e1d..4cbe6408 100644 --- a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift +++ b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift @@ -230,32 +230,16 @@ private struct ContextPanelDetailTitle: View { private struct KeyContextPanelDetailView: View { let key: SSHKeyRef - @Environment(AppModel.self) private var app - @State private var isRenaming = false + @Environment(WindowModel.self) private var window @State private var isExporting = false - @State private var error: String? var body: some View { KeyDetailContent( - key: key, rename: { isRenaming = true }, export: { isExporting = true } + key: key, + edit: { window.openVaultWork(.editKey(key.id), on: .keychain) }, + export: { isExporting = true } ) - .sheet(isPresented: $isRenaming) { - RenameKeySheet(key: key) { label in - var updated = key - updated.label = label - do { try app.hosts.upsert(updated) } catch { self.error = error.localizedDescription } - } - } .sheet(isPresented: $isExporting) { ExportKeyToHostsSheet(keys: [key]) } - .alert("Key Error", isPresented: errorPresented) { - Button("OK", role: .cancel) { error = nil } - } message: { - Text(error ?? "") - } - } - - private var errorPresented: Binding { - Binding(get: { error != nil }, set: { if !$0 { error = nil } }) } } diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/ContextPanel/ContextPanelView.swift index 7e513f43..cd50d760 100644 --- a/Termind/Features/ContextPanel/ContextPanelView.swift +++ b/Termind/Features/ContextPanel/ContextPanelView.swift @@ -856,7 +856,7 @@ struct HostDetailContent: View { struct KeyDetailContent: View { let key: SSHKeyRef - let rename: () -> Void + let edit: () -> Void let export: () -> Void @Environment(AppModel.self) private var app @@ -872,7 +872,7 @@ struct KeyDetailContent: View { var body: some View { VStack(alignment: .leading, spacing: Metrics.Space.xl) { Menu("Actions") { - KeyObjectActions(key: key, rename: rename, export: export) + KeyObjectActions(key: key, edit: edit, export: export) } .menuStyle(.button) .controlSize(.small) @@ -885,7 +885,7 @@ struct KeyDetailContent: View { !key.hasPassphrase ? String(localized: "None") : key.storesPassphrase - ? String(localized: "Saved") : String(localized: "Required at use") + ? String(localized: "Cleanup pending") : String(localized: "Required at use") ) } LabeledContent("Authentication") { @@ -897,6 +897,16 @@ struct KeyDetailContent: View { title: "Public Key", text: key.publicKey.isEmpty ? String(localized: "Not available") : key.publicKey ) + if !key.certificate.isEmpty { + DetailTextBlock(title: "Certificate", text: key.certificate) + if let certificate = try? SSHUserCertificate.parse( + key.certificate, matching: key.publicKey + ), let warning = certificate.validityWarning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } DetailSection(title: "Used by Identities") { if identities.isEmpty { @@ -929,7 +939,8 @@ struct IdentityDetailContent: View { LabeledContent("Username", value: identity.username) LabeledContent("Password") { - Text(identity.usesPassword ? String(localized: "Configured") : String(localized: "Not configured")) + Text(passwordStatus) + .foregroundStyle(passwordStatusIsWarning ? .orange : .primary) } LabeledContent("Key") { if let key { @@ -961,6 +972,22 @@ struct IdentityDetailContent: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + private var passwordStatus: String { + switch app.hosts.passwordAvailability(for: identity) { + case .notConfigured: String(localized: "Not configured") + case .available: String(localized: "Configured") + case .missing: String(localized: "Not available on this Mac") + case .failed: String(localized: "Keychain unavailable") + } + } + + private var passwordStatusIsWarning: Bool { + switch app.hosts.passwordAvailability(for: identity) { + case .missing, .failed: true + case .notConfigured, .available: false + } + } } struct SnippetDetailContent: View { diff --git a/Termind/Features/HostEditor/HostEditorView.swift b/Termind/Features/HostEditor/HostEditorView.swift index 0a7e3d70..b2cc9b8d 100644 --- a/Termind/Features/HostEditor/HostEditorView.swift +++ b/Termind/Features/HostEditor/HostEditorView.swift @@ -415,7 +415,7 @@ struct HostEditorView: View { } private var proxyIdentities: [Identity] { - app.hosts.identities.filter(\.isProxyEligible) + app.hosts.identities.filter(app.hosts.isProxyEligibleOnThisDevice) } private var footer: some View { diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 1fd20a76..c6090fc3 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -4,7 +4,6 @@ import UniformTypeIdentifiers @Observable final class KeychainPageState { var query = "" - var isImportingFile = false var errorMessage: String? } @@ -17,9 +16,8 @@ final class KeychainPageState { /// the database box, and copying the key twice to say that would be a lie about /// how many keys you have. /// -/// The private half of a key is never loaded into this process's view layer, -/// which is why there is no "reveal" action anywhere on this page and why the -/// footnote says so out loud. +/// Private material is loaded only by the explicit Import/Edit work surfaces; +/// cards and selection details continue to operate on public metadata. struct KeychainPageView: ManagedCardPage { nonisolated enum SectionID: Hashable { case keys @@ -128,8 +126,8 @@ struct KeychainPageView: ManagedCardPage { extendSelection: context.extendSelection, actionKeys: context.actionItems.compactMap(\.key), actionIdentities: context.actionItems.compactMap(\.identity), - rename: { - window.openVaultWork(.renameKey(key.id), on: .keychain) + edit: { + window.openVaultWork(.editKey(key.id), on: .keychain) }, export: { requestExport(context.actionItems.compactMap(\.key)) }, delete: { @@ -166,7 +164,7 @@ struct KeychainPageView: ManagedCardPage { case .key(let key): KeyCard( key: key, isSelected: false, select: {}, extendSelection: {}, - actionKeys: [], actionIdentities: [], rename: {}, export: {}, delete: {} + actionKeys: [], actionIdentities: [], edit: {}, export: {}, delete: {} ) .allowsHitTesting(false) case .identity(let identity): @@ -182,7 +180,9 @@ struct KeychainPageView: ManagedCardPage { func managedSupplementaryContent(for section: SectionID) -> some View { switch section { case .keys: - AddCardTile(title: "Import Key") { state.isImportingFile = true } + AddCardTile(title: "Import Key") { + window.openVaultWork(.importKey, on: .keychain) + } case .identities: AddCardTile(title: "New Identity") { window.openVaultWork(.identityEditor(nil), on: .keychain) @@ -237,7 +237,9 @@ struct KeychainPageView: ManagedCardPage { query: Binding(get: { state.query }, set: { state.query = $0 }), prompt: "Filter keys and identities" ) { - Button("Import Key") { state.isImportingFile = true } + Button("Import Key") { + window.openVaultWork(.importKey, on: .keychain) + } Button("Generate Key") { window.openVaultWork(.generateKey, on: .keychain) } @@ -274,7 +276,9 @@ struct KeychainPageView: ManagedCardPage { Button("New Identity") { window.openVaultWork(.identityEditor(nil), on: .keychain) } - Button("Import Key") { state.isImportingFile = true } + Button("Import Key") { + window.openVaultWork(.importKey, on: .keychain) + } Button("Generate Key") { window.openVaultWork(.generateKey, on: .keychain) } @@ -290,44 +294,15 @@ struct KeychainPageView: ManagedCardPage { KeychainPagePresentation( state: state, actions: KeychainPagePresentation.Actions( - receiveImportedFiles: receiveImportedFiles, pruneSelection: { window.pruneDetailSelections(in: app.hosts) } ) ) } - private func receiveImportedFiles(_ result: Result<[URL], any Error>) { - do { - guard let url = try result.get().first else { return } - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - let privateKey = try String(contentsOf: url, encoding: .utf8) - guard privateKey.contains("PRIVATE KEY") else { - throw CocoaError( - .fileReadCorruptFile, - userInfo: [ - NSLocalizedDescriptionKey: String(localized: "Select an OpenSSH or PEM private key.") - ]) - } - window.openVaultWork( - .importKey( - PendingKeyImport( - label: url.deletingPathExtension().lastPathComponent, - privateKey: privateKey - ) - ), - on: .keychain - ) - } catch { - state.errorMessage = error.localizedDescription - } - } - } struct KeychainPagePresentation: ManagedCardPresentationModifier { struct Actions { - let receiveImportedFiles: (Result<[URL], any Error>) -> Void let pruneSelection: () -> Void } @@ -337,58 +312,37 @@ struct KeychainPagePresentation: ManagedCardPresentationModifier { func body(content: Content) -> some View { @Bindable var state = state content - .fileImporter( - isPresented: $state.isImportingFile, - allowedContentTypes: [.data, .plainText], - allowsMultipleSelection: false, - onCompletion: actions.receiveImportedFiles - ) .onAppear(perform: actions.pruneSelection) } } -nonisolated struct PendingKeyImport: Identifiable, Hashable, Sendable { - let id = UUID() - var label: String - var privateKey: String -} - struct KeychainImportDetail: View { - let pending: PendingKeyImport - @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window var body: some View { - KeyImportSheet(pending: pending, save: importKey, close: window.closeVaultWork) + SoftwareKeyEditor( + title: "Import Key", symbol: "square.and.arrow.down", + close: window.closeVaultWork, save: save + ) } - private func importKey( - _ label: String, _ passphrase: String?, _ savePassphrase: Bool, - _ storage: KeyStorage + private func save( + name: String, storage: KeyStorage, privateKey: String, + passphrase: String?, certificate: String, material: SSHKeyMaterial ) async throws -> SSHKeyRef { - let privateKey = pending.privateKey - let material = try await Task.detached(priority: .userInitiated) { - try SSHKeyMaterial.inspect( - privateKey: privateKey, passphrase: passphrase, comment: label - ) - }.value - let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) let key = SSHKeyRef( - label: trimmed.isEmpty ? material.algorithm.typeName : trimmed, + label: name.isEmpty ? material.algorithm.typeName : name, algorithm: material.algorithm, publicKey: material.publicKey, - fingerprint: material.fingerprint, + certificate: certificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, - storesPassphrase: passphrase != nil && savePassphrase, createdAt: Date() + storesPassphrase: false, createdAt: Date() ) try app.hosts.upsert( key, credential: .software( - SSHPrivateKeySecret( - privateKey: privateKey, - passphrase: savePassphrase ? passphrase : nil - ) + SSHPrivateKeySecret(privateKey: privateKey, passphrase: nil) ) ) window.selectDetail(.key(key.id), on: .keychain) @@ -396,72 +350,147 @@ struct KeychainImportDetail: View { } } -private struct KeyImportSheet: View { - let pending: PendingKeyImport - let save: (String, String?, Bool, KeyStorage) async throws -> SSHKeyRef +private struct SoftwareKeyEditor: View { + let existing: SSHKeyRef? + let title: String + let symbol: String let close: () -> Void + let loadPrivateKey: (() throws -> String)? + let save: + ( + _ name: String, _ storage: KeyStorage, _ privateKey: String, + _ passphrase: String?, _ certificate: String, _ material: SSHKeyMaterial + ) async throws -> SSHKeyRef - @State private var label: String - @State private var storage = KeyStorage.localKeychain + @State private var name: String + @State private var storage: KeyStorage + @State private var privateKey = "" + @State private var originalPrivateKey = "" @State private var passphrase = "" - @State private var savePassphrase = false + @State private var certificate: String + @State private var material: SSHKeyMaterial? + @State private var isLoading: Bool + @State private var isChoosingFile = false @State private var isWorking = false @State private var errorMessage: String? - private let availableStorage: [KeyStorage] = [ - .localKeychain, - .iCloudKeychain, - ] - init( - pending: PendingKeyImport, - save: @escaping (String, String?, Bool, KeyStorage) async throws -> SSHKeyRef, - close: @escaping () -> Void + existing: SSHKeyRef? = nil, title: String, + symbol: String, close: @escaping () -> Void, + loadPrivateKey: (() throws -> String)? = nil, + save: @escaping ( + String, KeyStorage, String, String?, String, SSHKeyMaterial + ) async throws -> SSHKeyRef ) { - self.pending = pending - self.save = save + self.existing = existing + self.title = title + self.symbol = symbol self.close = close - _label = State(initialValue: pending.label) + self.loadPrivateKey = loadPrivateKey + self.save = save + _name = State(initialValue: existing?.label ?? "") + _storage = State(initialValue: existing?.storage ?? .iCloudKeychain) + _certificate = State(initialValue: existing?.certificate ?? "") + _isLoading = State(initialValue: loadPrivateKey != nil) } var body: some View { - VaultDetailScaffold(title: "Import Key", symbol: "square.and.arrow.down", close: close) { - Form { - TextField("Name", text: $label, prompt: Text("Defaults to key type")) - Picker("Store in", selection: $storage) { - ForEach(availableStorage) { location in - Text(location.displayName).tag(location) + VaultDetailScaffold(title: title, symbol: symbol, close: close) { + if isLoading { + ProgressView("Loading private key…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Form { + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + Section("Key") { + TextField("Name", text: $name, prompt: Text("Defaults to key type")) + Picker("Store in", selection: $storage) { + Label("iCloud", systemImage: "icloud.fill").tag(KeyStorage.iCloudKeychain) + Label("This Mac", systemImage: "lock.laptopcomputer").tag(KeyStorage.thisDevice) + } + } + + Section("Private Key") { + HStack { + Text("Private Key") + Spacer() + Button("Choose File…") { isChoosingFile = true } + .controlSize(.small) + } + TextEditor(text: $privateKey) + .font(.caption.monospaced()) + .frame(minHeight: 150) + .accessibilityLabel("Private Key") + SecureField("Passphrase", text: $passphrase, prompt: Text("Optional")) + } + + Section("Public Key") { + Text(publicKeyDisplay) + .font(.caption.monospaced()) + .foregroundStyle(material == nil ? .secondary : .primary) + .textSelection(.enabled) + } + + Section("Certificate") { + TextEditor(text: $certificate) + .font(.caption.monospaced()) + .frame(minHeight: 110) + .overlay(alignment: .topLeading) { + if certificate.isEmpty { + Text("Certificate (optional)") + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + .padding(.top, 8).padding(.leading, 5) + .allowsHitTesting(false) + } + } + if let warning = certificateWarning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + if let existing, let material, + material.publicKey != existing.publicKey + { + Label( + "Replacing this private key changes its public key. Hosts using the old key may reject the next connection.", + systemImage: "arrow.triangle.2.circlepath" + ) + .font(.caption) + .foregroundStyle(.orange) + } } } - SecureField("Passphrase", text: $passphrase, prompt: Text("If protected")) - Toggle("Save Passphrase", isOn: $savePassphrase) - .disabled(passphrase.isEmpty) - .onChange(of: passphrase) { _, value in - if value.isEmpty { savePassphrase = false } - } - Text( - "Choose a private-key file. Termind derives its public key and fingerprint. If you don't save the passphrase, Termind asks for it when needed." - ) - .font(.caption).foregroundStyle(.secondary) - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } + .formStyle(.grouped) } - .formStyle(.grouped) } footer: { HStack { Spacer() if isWorking { ProgressView().controlSize(.small) } Button("Cancel", action: close).keyboardShortcut(.cancelAction) - Button("Import") { + Button(existing == nil ? "Import" : "Save") { Task { await commit() } } .keyboardShortcut(.defaultAction) - .disabled(isWorking) + .disabled(isWorking || isLoading || privateKey.isEmpty) } .padding() } + .fileImporter( + isPresented: $isChoosingFile, allowedContentTypes: [.data, .plainText], + allowsMultipleSelection: true, onCompletion: receiveFiles + ) + .dropDestination(for: URL.self) { urls, _ in + receive(urls: urls) + } + .task { load() } + .task(id: validationToken) { await derivePublicMaterial() } } @MainActor @@ -469,8 +498,32 @@ private struct KeyImportSheet: View { isWorking = true errorMessage = nil do { + let passphrase = passphrase.isEmpty ? nil : passphrase + let inspected: SSHKeyMaterial + if let existing, privateKey == originalPrivateKey { + inspected = SSHKeyMaterial( + privateKey: privateKey, publicKey: existing.publicKey, + algorithm: existing.algorithm, fingerprint: existing.fingerprint, + keySize: existing.keySize + ) + } else { + let keyText = privateKey + let comment = name + inspected = try await Task.detached(priority: .userInitiated) { + try SSHKeyMaterial.inspect( + privateKey: keyText, passphrase: passphrase, comment: comment + ) + }.value + } + let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCertificate.isEmpty { + _ = try SSHUserCertificate.parse( + normalizedCertificate, matching: inspected.publicKey + ) + } _ = try await save( - label, passphrase.isEmpty ? nil : passphrase, savePassphrase, storage + name.trimmingCharacters(in: .whitespacesAndNewlines), storage, + privateKey, passphrase, normalizedCertificate, inspected ) close() } catch { @@ -478,6 +531,95 @@ private struct KeyImportSheet: View { isWorking = false } } + + private var validationToken: String { privateKey + "\u{0}" + passphrase } + + private var publicKeyDisplay: String { + material?.publicKey ?? existing?.publicKey ?? String(localized: "Derived from the private key") + } + + private var certificateWarning: String? { + guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + do { + return try SSHUserCertificate.parse( + certificate, matching: material?.publicKey ?? existing?.publicKey + ).validityWarning + } catch { + return error.localizedDescription + } + } + + @MainActor + private func load() { + guard isLoading else { return } + do { + privateKey = try loadPrivateKey?() ?? "" + originalPrivateKey = privateKey + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + private func derivePublicMaterial() async { + guard !privateKey.isEmpty else { + material = nil + return + } + do { + try await Task.sleep(for: .milliseconds(250)) + let privateKey = privateKey + let passphrase = passphrase.isEmpty ? nil : passphrase + let comment = name + material = try await Task.detached(priority: .utility) { + try SSHKeyMaterial.inspect( + privateKey: privateKey, passphrase: passphrase, comment: comment + ) + }.value + } catch is CancellationError { + } catch { + material = nil + } + } + + private func receiveFiles(_ result: Result<[URL], any Error>) { + do { + _ = receive(urls: try result.get()) + } catch { + errorMessage = error.localizedDescription + } + } + + @discardableResult + private func receive(urls: [URL]) -> Bool { + do { + guard !urls.isEmpty else { return false } + var found = false + for url in urls { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let text = try String(contentsOf: url, encoding: .utf8) + if text.contains("PRIVATE KEY") { + privateKey = text + if name.isEmpty { name = url.deletingPathExtension().lastPathComponent } + found = true + } else if text.contains("-cert-v01@openssh.com") { + certificate = text.trimmingCharacters(in: .whitespacesAndNewlines) + found = true + } else { + throw CocoaError( + .fileReadCorruptFile, + userInfo: [NSLocalizedDescriptionKey: String(localized: "Choose a private key or OpenSSH user certificate file.")] + ) + } + } + errorMessage = nil + return found + } catch { + errorMessage = error.localizedDescription + return false + } + } } struct KeychainGenerateDetail: View { @@ -490,8 +632,8 @@ struct KeychainGenerateDetail: View { private func generate( label: String, generation: SSHKeyMaterial.Generation, passphrase: String?, - cipher: SSHKeyMaterial.Cipher, rounds: Int, savePassphrase: Bool, - storage: KeyStorage, requiresUserPresence: Bool + cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage, + requiresUserPresence: Bool ) async throws -> SSHKeyRef { let comment = "termind@\(ProcessInfo.processInfo.hostName)" let key: SSHKeyRef @@ -522,15 +664,12 @@ struct KeychainGenerateDetail: View { publicKey: material.publicKey, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, - storesPassphrase: passphrase != nil && savePassphrase, createdAt: Date() + storesPassphrase: false, createdAt: Date() ) try app.hosts.upsert( key, credential: .software( - SSHPrivateKeySecret( - privateKey: material.privateKey, - passphrase: savePassphrase ? passphrase : nil - ) + SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: nil) ) ) } @@ -550,13 +689,13 @@ private struct GenerateKeySheet: View { let generate: ( - String, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int, Bool, + String, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int, KeyStorage, Bool ) async throws -> SSHKeyRef let close: () -> Void @State private var label = "" - @State private var storage = KeyStorage.localKeychain + @State private var storage = KeyStorage.iCloudKeychain @State private var kind = Kind.ed25519 @State private var curveBits = 256 @State private var rsaBits = 3072 @@ -564,13 +703,12 @@ private struct GenerateKeySheet: View { @State private var confirmation = "" @State private var cipher = SSHKeyMaterial.Cipher.aes256 @State private var rounds = 100 - @State private var savePassphrase = false @State private var requiresUserPresence = false @State private var isWorking = false @State private var errorMessage: String? private let availableStorage: [KeyStorage] = [ - .localKeychain, + .thisDevice, .iCloudKeychain, .secureEnclave, ] @@ -594,7 +732,7 @@ private struct GenerateKeySheet: View { TextField("Name", text: $label, prompt: Text(defaultName)) Picker("Store in", selection: $storage) { ForEach(availableStorage) { location in - Text(location.displayName) + Label(location.displayName, systemImage: location.symbol) .tag(location) .disabled(location == .secureEnclave && !SecureEnclaveKey.isAvailable) } @@ -626,9 +764,6 @@ private struct GenerateKeySheet: View { } } SecureField("Passphrase", text: $passphrase, prompt: Text("Optional")) - .onChange(of: passphrase) { _, value in - if value.isEmpty { savePassphrase = false } - } SecureField("Confirm Passphrase", text: $confirmation) .disabled(passphrase.isEmpty) if !passphrase.isEmpty { @@ -638,7 +773,6 @@ private struct GenerateKeySheet: View { } } TextField("Rounds", value: $rounds, format: .number) - Toggle("Save Passphrase", isOn: $savePassphrase) } if !passwordsMatch { Text("Passphrases do not match.") @@ -646,9 +780,9 @@ private struct GenerateKeySheet: View { .foregroundStyle(.red) } Text( - passphrase.isEmpty || savePassphrase - ? "The generated private key is stored on this Mac." - : "The private key is stored on this Mac. Termind asks for its passphrase when needed." + passphrase.isEmpty + ? "The generated private key is stored in the selected Keychain." + : "The private key is encrypted. Termind never saves its passphrase and asks for it when needed." ) .font(.caption) .foregroundStyle(.secondary) @@ -685,7 +819,7 @@ private struct GenerateKeySheet: View { _ = try await generate( resolvedLabel, generation, storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase, - cipher, rounds, savePassphrase, storage, + cipher, rounds, storage, storage == .secureEnclave && requiresUserPresence ) close() @@ -705,7 +839,7 @@ private struct GenerateKeySheet: View { } } -struct KeychainRenameDetail: View { +struct KeychainEditDetail: View { let keyID: SSHKeyRef.ID @Environment(AppModel.self) private var app @@ -714,50 +848,108 @@ struct KeychainRenameDetail: View { @ViewBuilder var body: some View { if let key = app.hosts.key(keyID) { - RenameKeySheet(key: key, save: save, close: window.closeVaultWork) + if key.storage == .secureEnclave { + SecureEnclaveKeyEditor( + key: key, close: window.closeVaultWork, + save: { name in try saveSecureEnclave(key, name: name) } + ) + } else { + SoftwareKeyEditor( + existing: key, title: "Edit Key", symbol: "pencil", + close: window.closeVaultWork, + loadPrivateKey: { try loadPrivateKey(key) }, + save: { name, storage, privateKey, passphrase, certificate, material in + try await saveSoftwareKey( + key, name: name, storage: storage, + privateKey: privateKey, passphrase: passphrase, + certificate: certificate, material: material + ) + } + ) + } } else { ContentUnavailableView("Key Unavailable", systemImage: "key.slash") } } - private func save(_ label: String) throws { - guard var key = app.hosts.key(keyID) else { return } - key.label = label - try app.hosts.upsert(key) - window.selectDetail(.key(key.id), on: .keychain) + private func loadPrivateKey(_ key: SSHKeyRef) throws -> String { + guard case .software(let secret)? = try app.hosts.keyCredential(for: key) else { + throw TermindSSHError.library( + String(localized: "The private key is unavailable on this Mac.") + ) + } + return secret.privateKey + } + + private func saveSoftwareKey( + _ original: SSHKeyRef, name: String, storage: KeyStorage, + privateKey: String, passphrase: String?, certificate: String, + material: SSHKeyMaterial + ) async throws -> SSHKeyRef { + var updated = original + updated.label = name.isEmpty ? material.algorithm.typeName : name + updated.storage = storage + updated.algorithm = material.algorithm + updated.publicKey = material.publicKey + updated.certificate = certificate + updated.fingerprint = material.fingerprint + updated.keySize = material.keySize + let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)) + updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase) + updated.storesPassphrase = false + try app.hosts.upsert( + updated, + credential: .software( + SSHPrivateKeySecret(privateKey: privateKey, passphrase: nil) + ) + ) + window.selectDetail(.key(updated.id), on: .keychain) + return updated + } + + private func saveSecureEnclave(_ original: SSHKeyRef, name: String) throws { + var updated = original + updated.label = name + try app.hosts.upsert(updated) + window.selectDetail(.key(updated.id), on: .keychain) } } -struct RenameKeySheet: View { +private struct SecureEnclaveKeyEditor: View { let key: SSHKeyRef + let close: () -> Void let save: (String) throws -> Void - var requestedClose: (() -> Void)? - @Environment(\.dismiss) private var dismiss - @State private var label: String + @State private var name: String @State private var errorMessage: String? - init( - key: SSHKeyRef, save: @escaping (String) throws -> Void, - close: (() -> Void)? = nil - ) { + init(key: SSHKeyRef, close: @escaping () -> Void, save: @escaping (String) throws -> Void) { self.key = key + self.close = close self.save = save - requestedClose = close - _label = State(initialValue: key.label) + _name = State(initialValue: key.label) } var body: some View { - VaultDetailScaffold(title: "Rename Key", symbol: "pencil", close: close) { + VaultDetailScaffold(title: "Edit Key", symbol: "pencil", close: close) { Form { - TextField("Name", text: $label) - Text(key.fingerprint) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) + Section("Key") { + TextField("Name", text: $name) + } + Section("Secure Enclave") { + LabeledContent("Stored in", value: key.storage.displayName) + LabeledContent("Algorithm", value: key.algorithm.displayName) + LabeledContent("Fingerprint") { + Text(key.fingerprint).font(.caption.monospaced()).textSelection(.enabled) + } + LabeledContent("Public Key") { + Text(key.publicKey).font(.caption.monospaced()).textSelection(.enabled) + } + Text("The private key cannot leave the Secure Enclave. Only its name can be changed.") + .font(.caption).foregroundStyle(.secondary) + } if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") } } } .formStyle(.grouped) @@ -767,22 +959,16 @@ struct RenameKeySheet: View { Button("Cancel", action: close).keyboardShortcut(.cancelAction) Button("Save") { do { - try save(label.trimmingCharacters(in: .whitespacesAndNewlines)) + try save(name.trimmingCharacters(in: .whitespacesAndNewlines)) close() - } catch { - errorMessage = error.localizedDescription - } + } catch { errorMessage = error.localizedDescription } } .keyboardShortcut(.defaultAction) - .disabled(label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() } } - - private func close() { - if let requestedClose { requestedClose() } else { dismiss() } - } } // MARK: - Export to hosts @@ -1107,11 +1293,11 @@ struct ExportKeyToHostsSheet: View { // MARK: - Key card /// The singular actions for one key, shared by its card and ContextPanel detail. -/// Secret material is intentionally absent: only the public key and fingerprint -/// ever enter this view layer. +/// Card actions never resolve secret material; Edit is the explicit boundary +/// that loads a software key from Keychain. struct KeyObjectActions: View { let key: SSHKeyRef - let rename: () -> Void + let edit: () -> Void let export: () -> Void var body: some View { @@ -1122,7 +1308,7 @@ struct KeyObjectActions: View { Button("Export to Hosts", action: export) .disabled(key.publicKey.isEmpty) Divider() - Button("Rename", action: rename) + Button("Edit", action: edit) } } @@ -1133,7 +1319,7 @@ private struct KeyCard: View { let extendSelection: () -> Void let actionKeys: [SSHKeyRef] let actionIdentities: [Identity] - let rename: () -> Void + let edit: () -> Void let export: () -> Void let delete: () -> Void @@ -1194,7 +1380,7 @@ private struct KeyCard: View { if key.hasPassphrase { CardChip( text: key.storesPassphrase - ? String(localized: "Passphrase Saved") + ? String(localized: "Passphrase Cleanup Pending") : String(localized: "Passphrase Required"), symbol: "lock.fill" ) @@ -1203,6 +1389,20 @@ private struct KeyCard: View { text: key.storage.displayName, symbol: key.storage.symbol ) + switch app.hosts.keyAvailability(for: key) { + case .missing: + CardChip( + text: String(localized: "Private key missing"), + symbol: "exclamationmark.triangle.fill", tint: .orange + ) + case .failed: + CardChip( + text: String(localized: "Keychain unavailable"), + symbol: "exclamationmark.triangle.fill", tint: .orange + ) + case .available, .notConfigured: + EmptyView() + } Spacer(minLength: Metrics.Space.xs) @@ -1217,7 +1417,7 @@ private struct KeyCard: View { } .contextMenu { if actionKeys.count == 1, actionIdentities.isEmpty { - KeyObjectActions(key: key, rename: rename, export: export) + KeyObjectActions(key: key, edit: edit, export: export) } else if actionIdentities.isEmpty { Button("Export to Hosts", action: export) .disabled(actionKeys.isEmpty || actionKeys.contains { $0.publicKey.isEmpty }) @@ -1324,7 +1524,22 @@ private struct IdentityCard: View { @ViewBuilder private var secretChip: some View { if identity.usesPassword { - CardChip(text: String(localized: "Password"), symbol: "lock.fill") + switch app.hosts.passwordAvailability(for: identity) { + case .available: + CardChip(text: String(localized: "Password"), symbol: "lock.fill") + case .missing: + CardChip( + text: String(localized: "Password missing"), + symbol: "exclamationmark.triangle.fill", tint: .orange + ) + case .failed: + CardChip( + text: String(localized: "Password unavailable"), + symbol: "exclamationmark.triangle.fill", tint: .orange + ) + case .notConfigured: + EmptyView() + } } if identity.usesKey { diff --git a/Termind/Features/Keychain/NewIdentitySheet.swift b/Termind/Features/Keychain/NewIdentitySheet.swift index 50ced9d8..8c04c541 100644 --- a/Termind/Features/Keychain/NewIdentitySheet.swift +++ b/Termind/Features/Keychain/NewIdentitySheet.swift @@ -64,6 +64,7 @@ struct NewIdentitySheet: View { @State private var name: String @State private var username: String @State private var password = "" + @State private var passwordStorage: PasswordStorage @State private var keyID: SSHKeyRef.ID? @State private var removesPassword = false @State private var saveError: String? @@ -82,6 +83,7 @@ struct NewIdentitySheet: View { requestedBack = back _name = State(initialValue: identity?.name ?? "") _username = State(initialValue: identity?.username ?? "") + _passwordStorage = State(initialValue: identity?.passwordStorage ?? .iCloudKeychain) _keyID = State(initialValue: identity?.keyID) } @@ -135,6 +137,12 @@ struct NewIdentitySheet: View { } } + Picker("Store password in", selection: $passwordStorage) { + ForEach(PasswordStorage.allCases) { storage in + Label(storage.displayName, systemImage: storage.symbol).tag(storage) + } + } + if configuration.showsKey { Picker("Key", selection: $keyID) { Text("None").tag(SSHKeyRef.ID?.none) @@ -183,9 +191,10 @@ struct NewIdentitySheet: View { private var passwordPrompt: String { if removesPassword { return String(localized: "Will be cleared") } - if existing?.usesPassword == true { + if hasAvailablePassword { return String(localized: "Leave blank to keep") } + if existing?.usesPassword == true { return String(localized: "Not available on this Mac") } return configuration.requiresPassword ? String(localized: "Required") : String(localized: "Optional") @@ -195,7 +204,7 @@ struct NewIdentitySheet: View { if removesPassword { return String(localized: "The saved password will be cleared when you save.") } - if existing?.usesPassword == true { + if hasAvailablePassword { return String( localized: "Leave blank to keep the saved password, or enter a replacement." @@ -213,6 +222,11 @@ struct NewIdentitySheet: View { ) } + private var hasAvailablePassword: Bool { + guard let existing else { return false } + return app.hosts.passwordAvailability(for: existing) == .available + } + private var passwordUpdate: IdentityPasswordUpdate { if removesPassword { return .remove } if !password.isEmpty { return .replace(password) } @@ -228,7 +242,7 @@ struct NewIdentitySheet: View { private var isValid: Bool { configuration.accepts( username: username, password: password, - hasSavedPassword: existing?.usesPassword == true, + hasSavedPassword: hasAvailablePassword, removesPassword: removesPassword ) } @@ -243,6 +257,7 @@ struct NewIdentitySheet: View { name: trimmedName.isEmpty ? trimmedUsername : trimmedName, username: trimmedUsername, credentialRef: existing?.credentialRef ?? "", + passwordStorage: passwordStorage, keyID: configuration.showsKey ? keyID : nil, notes: existing?.notes ?? "", lastUsedAt: existing?.lastUsedAt diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index e7c02a45..c5bcd4f6 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -211,6 +211,20 @@ final class SFTPSessionModel: Identifiable { username: suppliedAuthentication?.username ?? store.identity(for: host)?.username, mode: reconnecting ? .reconnection : .connection ) + if let passphraseHost = route.first(where: { routeHost in + guard let submission = suppliedAuthentications[routeHost.id], + submission.passphrase == nil, + let keyID = submission.keyID, + let key = store.key(keyID) + else { return false } + return key.requiresPassphraseEntry + }) { + requestAuthentication( + host: passphraseHost, store: store, + requiresPassphrase: true, preserveSubmission: true + ) + return + } if reconnecting, usesEphemeralCredentials, suppliedAuthentication == nil { requestAuthentication(host: host, store: store) return @@ -232,7 +246,7 @@ final class SFTPSessionModel: Identifiable { { credentials.append( stored.sshCredential( - publicKey: key.publicKey, + publicKey: key.publicKey, certificate: key.certificate, passphraseOverride: suppliedAuthentication.passphrase )) } @@ -314,11 +328,18 @@ final class SFTPSessionModel: Identifiable { } else { authenticationHost = host } + let submitted = self.suppliedAuthentications[authenticationHost.id] + let retryNeedsPassphrase = submitted?.passphrase == nil + && (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID) + .flatMap { store.key($0) }?.requiresPassphraseEntry == true + let returnsToIdentity = submitted?.passphrase != nil self.requestAuthentication( host: authenticationHost, store: store, failure: error.localizedDescription, - mode: error.isServerAuthenticationChallenge - ? .serverChallenge : .localIdentity + mode: !returnsToIdentity && error.isServerAuthenticationChallenge + ? .serverChallenge : .localIdentity, + requiresPassphrase: retryNeedsPassphrase, + preserveSubmission: retryNeedsPassphrase && submitted != nil ) } catch { if self.hasConnectedOnce || reconnecting { @@ -437,6 +458,18 @@ final class SFTPSessionModel: Identifiable { var host = store.host(prompt.host.id) else { return } + if prompt.requiresPassphrase, + var staged = suppliedAuthentications[host.id] + { + guard let passphrase, !passphrase.isEmpty else { return } + staged.passphrase = passphrase + suppliedAuthentications[host.id] = staged + pendingAuthentication = nil + connectionJourney.resume() + connect(using: store) + return + } + let selectedIdentity = identityID.flatMap { store.identity($0) } let submittedUsername = (selectedIdentity?.username ?? username) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -456,7 +489,8 @@ final class SFTPSessionModel: Identifiable { suppliedAuthentications[host.id] = SSHAuthenticationSubmission( username: submittedUsername, password: submittedPassword, - passphrase: passphrase, keyID: submittedKeyID + passphrase: passphrase, keyID: submittedKeyID, + identityID: selectedIdentity?.id ) usesEphemeralCredentials = !save @@ -466,6 +500,19 @@ final class SFTPSessionModel: Identifiable { connectionJourney.resume() if save { if let selectedIdentity { + if let password { + do { + try store.upsert( + selectedIdentity, passwordUpdate: .replace(password) + ) + } catch { + operationError = error.localizedDescription + requestAuthentication( + host: host, store: store, failure: error.localizedDescription + ) + return + } + } host.identityID = selectedIdentity.id store.upsert(host) connect(using: store) @@ -475,6 +522,7 @@ final class SFTPSessionModel: Identifiable { let identity = Identity( id: existing?.id ?? UUID(), name: existing?.name ?? submittedUsername, username: submittedUsername, credentialRef: existing?.credentialRef ?? "", + passwordStorage: existing?.passwordStorage ?? .iCloudKeychain, keyID: submittedKeyID, notes: existing?.notes ?? "", lastUsedAt: existing?.lastUsedAt ) @@ -499,6 +547,7 @@ final class SFTPSessionModel: Identifiable { reconnectController.stop() isRecoveringConnection = false pendingAuthentication = nil + suppliedAuthentications.removeAll() status = .disconnected connectionJourney.hide() } @@ -1217,21 +1266,35 @@ final class SFTPSessionModel: Identifiable { private func requestAuthentication( host: Host, store: HostStore, failure: String? = nil, - mode: SSHAuthenticationPrompt.Mode = .localIdentity + mode: SSHAuthenticationPrompt.Mode = .localIdentity, + requiresPassphrase: Bool? = nil, + preserveSubmission: Bool = false ) { reconnectController.pauseForUserInteraction() - suppliedAuthentications[host.id] = nil + if !preserveSubmission { suppliedAuthentications[host.id] = nil } let identity = store.identity(for: host) + let submission = suppliedAuthentications[host.id] + let selectedKeyID = submission?.keyID ?? identity?.keyID + let username = submission?.username ?? identity?.username ?? "" + let needsPassphrase = requiresPassphrase + ?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true) pendingAuthentication = SSHAuthenticationPrompt( - host: host, username: identity?.username ?? "", selectedKeyID: identity?.keyID, + host: host, selectedIdentityID: submission?.identityID ?? identity?.id, + username: username, + selectedKeyID: selectedKeyID, failureMessage: failure, - requiresPassphrase: identity?.keyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true, + requiresPassphrase: needsPassphrase, mode: mode ) status = .disconnected switch mode { case .localIdentity: - connectionJourney.waitForLocalIdentity(at: host.id, failure: failure) + if pendingAuthentication?.requiresPassphrase == true { + connectionJourney.waitForLocalPassphrase(at: host.id, failure: failure) + } else { + connectionJourney.waitForLocalIdentity(at: host.id, failure: failure) + } case .serverChallenge: connectionJourney.waitForAuthentication(at: host.id, failure: failure) } diff --git a/Termind/Features/Shell/WindowRoute.swift b/Termind/Features/Shell/WindowRoute.swift index 0bea4821..df02d8d6 100644 --- a/Termind/Features/Shell/WindowRoute.swift +++ b/Termind/Features/Shell/WindowRoute.swift @@ -71,8 +71,8 @@ nonisolated enum VaultWorkRoute: Hashable, Sendable { case hostGroupEditor(groupID: HostGroup.ID?, parentID: HostGroup.ID?) case identityEditor(Identity.ID?) case generateKey - case importKey(PendingKeyImport) - case renameKey(SSHKeyRef.ID) + case importKey + case editKey(SSHKeyRef.ID) case exportKeys([SSHKeyRef.ID]) case snippetEditor(snippetID: Snippet.ID?, packageID: SnippetPackage.ID?) case snippetPackageEditor(packageID: SnippetPackage.ID?, parentID: SnippetPackage.ID?) diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 3ed17a15..6c8410dd 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -414,11 +414,25 @@ final class SSHSessionModel: Identifiable { host: host, route: route, purpose: .sshSession, username: username, mode: reconnecting ? .reconnection : .connection ) + if let passphraseHost = route.first(where: { routeHost in + guard let submission = suppliedAuthentications[routeHost.id], + submission.passphrase == nil, + let keyID = submission.keyID, + let key = store.key(keyID) + else { return false } + return key.requiresPassphraseEntry + }), let submission = suppliedAuthentications[passphraseHost.id] { + requestAuthentication( + for: passphraseHost, username: submission.username, + selectedKeyID: submission.keyID, requiresPassphrase: true, + preserveSubmission: true + ) + return + } guard let username, !username.trimmingCharacters(in: .whitespaces).isEmpty else { requestAuthentication( for: host, username: "", selectedKeyID: identity?.keyID, - requiresPassphrase: identity?.keyID - .flatMap { store.key($0) }?.requiresPassphraseEntry == true + requiresPassphrase: false ) return } @@ -441,7 +455,7 @@ final class SSHSessionModel: Identifiable { if let credential = try store.keyCredential(for: key) { credentials.append( credential.sshCredential( - publicKey: key.publicKey, + publicKey: key.publicKey, certificate: key.certificate, passphraseOverride: suppliedAuthentication.passphrase ) ) @@ -514,14 +528,17 @@ final class SSHSessionModel: Identifiable { let jump = store.host(id) { let identity = store.identity(for: jump) + let submitted = suppliedAuthentications[jump.id] + let retryNeedsPassphrase = submitted?.passphrase == nil + && (submitted?.keyID ?? identity?.keyID) + .flatMap { store.key($0) }?.requiresPassphraseEntry == true connectionAttemptID = nil requestAuthentication( for: jump, username: identity?.username ?? "", - selectedKeyID: identity?.keyID, + selectedKeyID: submitted?.keyID ?? identity?.keyID, failureMessage: sshError.localizedDescription, - requiresPassphrase: - identity?.keyID.flatMap { store.key($0) }? - .requiresPassphraseEntry == true + requiresPassphrase: retryNeedsPassphrase, + preserveSubmission: retryNeedsPassphrase && submitted != nil ) return } @@ -606,17 +623,23 @@ final class SSHSessionModel: Identifiable { authenticationHost = host } let authenticationIdentity = store.identity(for: authenticationHost) + let submitted = suppliedAuthentications[authenticationHost.id] + let authenticationKeyID = submitted?.keyID + ?? authenticationIdentity?.keyID ?? selectedKeyID + let retryNeedsPassphrase = submitted?.passphrase == nil + && authenticationKeyID.flatMap { store.key($0) }? + .requiresPassphraseEntry == true + let returnsToIdentity = submitted?.passphrase != nil requestAuthentication( for: authenticationHost, username: authenticationIdentity?.username ?? username, - selectedKeyID: authenticationIdentity?.keyID ?? selectedKeyID, + selectedKeyID: authenticationKeyID, failureMessage: (credentialFailures + [sshError.localizedDescription]).joined(separator: "\n"), - requiresPassphrase: - selectedKeyID - .flatMap { store.key($0) }?.requiresPassphraseEntry == true, - mode: sshError.isServerAuthenticationChallenge - ? .serverChallenge : .localIdentity + requiresPassphrase: retryNeedsPassphrase, + mode: !returnsToIdentity && sshError.isServerAuthenticationChallenge + ? .serverChallenge : .localIdentity, + preserveSubmission: retryNeedsPassphrase && submitted != nil ) } else { handleConnectionAttemptFailure( @@ -640,6 +663,18 @@ final class SSHSessionModel: Identifiable { var host = store.host(prompt.host.id) ?? (prompt.host.id == hostID ? transientHost : nil) else { return } + if prompt.requiresPassphrase, + var staged = suppliedAuthentications[host.id] + { + guard let passphrase, !passphrase.isEmpty else { return } + staged.passphrase = passphrase + suppliedAuthentications[host.id] = staged + pendingAuthentication = nil + connectionJourney.resume() + connect(using: store) + return + } + let submittedKeyID = selectedIdentity?.keyID ?? keyID let submittedPassword: String? do { @@ -660,7 +695,8 @@ final class SSHSessionModel: Identifiable { suppliedAuthentications[host.id] = SSHAuthenticationSubmission( username: trimmedUsername, password: submittedPassword, - passphrase: passphrase, keyID: submittedKeyID + passphrase: passphrase, keyID: submittedKeyID, + identityID: selectedIdentity?.id ) if host.id == hostID { usernameHint = trimmedUsername } pendingAuthentication = nil @@ -668,6 +704,23 @@ final class SSHSessionModel: Identifiable { if save { if let selectedIdentity { + if let password { + do { + try store.upsert( + selectedIdentity, passwordUpdate: .replace(password) + ) + } catch { + requestAuthentication( + for: host, username: trimmedUsername, + selectedKeyID: submittedKeyID, + failureMessage: error.localizedDescription, + requiresPassphrase: + submittedKeyID + .flatMap { store.key($0) }?.requiresPassphraseEntry == true + ) + return + } + } host.identityID = selectedIdentity.id store.upsert(host) if host.id == hostID { transientHost = host } @@ -680,6 +733,7 @@ final class SSHSessionModel: Identifiable { name: existing?.name ?? trimmedUsername, username: trimmedUsername, credentialRef: existing?.credentialRef ?? "", + passwordStorage: existing?.passwordStorage ?? .iCloudKeychain, keyID: submittedKeyID, notes: existing?.notes ?? "", lastUsedAt: existing?.lastUsedAt @@ -709,6 +763,7 @@ final class SSHSessionModel: Identifiable { func cancelAuthentication() { pendingAuthentication = nil + suppliedAuthentications.removeAll() pendingInitialCommand = nil status = .disconnected connectionJourney.hide() @@ -963,12 +1018,16 @@ final class SSHSessionModel: Identifiable { for host: Host, username: String, selectedKeyID: SSHKeyRef.ID?, failureMessage: String? = nil, requiresPassphrase: Bool = false, - mode: SSHAuthenticationPrompt.Mode = .localIdentity + mode: SSHAuthenticationPrompt.Mode = .localIdentity, + preserveSubmission: Bool = false ) { reconnectController.pauseForUserInteraction() - suppliedAuthentications[host.id] = nil + if !preserveSubmission { suppliedAuthentications[host.id] = nil } pendingAuthentication = SSHAuthenticationPrompt( - host: host, username: username, + host: host, + selectedIdentityID: preserveSubmission + ? suppliedAuthentications[host.id]?.identityID : host.identityID, + username: username, selectedKeyID: selectedKeyID, failureMessage: failureMessage?.isEmpty == false ? failureMessage : nil, requiresPassphrase: requiresPassphrase, mode: mode @@ -976,7 +1035,15 @@ final class SSHSessionModel: Identifiable { status = .disconnected switch mode { case .localIdentity: - connectionJourney.waitForLocalIdentity(at: host.id, failure: failureMessage) + if requiresPassphrase { + connectionJourney.waitForLocalPassphrase( + at: host.id, failure: failureMessage + ) + } else { + connectionJourney.waitForLocalIdentity( + at: host.id, failure: failureMessage + ) + } case .serverChallenge: connectionJourney.waitForAuthentication(at: host.id, failure: failureMessage) } @@ -1154,6 +1221,7 @@ nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Sendable { let id = UUID() var host: Host + var selectedIdentityID: Identity.ID? = nil var username: String var selectedKeyID: SSHKeyRef.ID? var failureMessage: String? diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 8bc49ee5..e36e1316 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -145,10 +145,10 @@ struct VaultWorkDetailView: View { ) case .generateKey: KeychainGenerateDetail() - case .importKey(let pending): - KeychainImportDetail(pending: pending) - case .renameKey(let id): - KeychainRenameDetail(keyID: id) + case .importKey: + KeychainImportDetail() + case .editKey(let id): + KeychainEditDetail(keyID: id) case .exportKeys(let ids): ExportKeyToHostsSheet(keys: ids.compactMap(app.hosts.key)) case .snippetEditor(let snippetID, let packageID): diff --git a/Termind/UI/Components/SSHConnectionJourneyView.swift b/Termind/UI/Components/SSHConnectionJourneyView.swift index 01eae7d0..23c1933e 100644 --- a/Termind/UI/Components/SSHConnectionJourneyView.swift +++ b/Termind/UI/Components/SSHConnectionJourneyView.swift @@ -133,7 +133,7 @@ struct SSHConnectionJourneyView: View { purpose: journey.purpose, state: endpointState(for: index), showsActivity: activityEndpointIndex == index, - usesLocalIdentitySymbol: index == 0 && journey.waitsForLocalIdentity, + localRequirement: index == 0 ? journey.localRequirement : nil, reduceMotion: reduceMotion ) .frame(width: labelWidth, height: 92, alignment: .top) @@ -360,7 +360,7 @@ private struct JourneyEndpointView: View { let purpose: SSHConnectionPurpose let state: JourneyVisualState let showsActivity: Bool - let usesLocalIdentitySymbol: Bool + let localRequirement: SSHConnectionJourney.LocalRequirement? let reduceMotion: Bool var body: some View { @@ -396,7 +396,12 @@ private struct JourneyEndpointView: View { private var symbol: String { switch endpoint.role { - case .local: usesLocalIdentitySymbol ? "person.fill" : "laptopcomputer" + case .local: + switch localRequirement { + case .identity: "person.fill" + case .passphrase: "key.fill" + case nil: "laptopcomputer" + } case .jump: "server.rack" case .destination: purpose == .sshSession ? "apple.terminal.fill" : "folder.fill" @@ -544,15 +549,13 @@ private struct JourneyAuthenticationCard: View { self.prompt = prompt self.submit = submit self.cancel = cancel - _identityID = State(initialValue: prompt.host.identityID) + _identityID = State(initialValue: prompt.selectedIdentityID) _username = State(initialValue: prompt.username) _keyID = State(initialValue: prompt.selectedKeyID) } private var needsPassphrase: Bool { - guard let keyID else { return false } - return app.hosts.key(keyID)?.requiresPassphraseEntry == true - || (prompt.requiresPassphrase && keyID == prompt.selectedKeyID) + prompt.requiresPassphrase } private var canContinue: Bool { @@ -560,14 +563,17 @@ private struct JourneyAuthenticationCard: View { && (!needsPassphrase || !passphrase.isEmpty) } + private var title: LocalizedStringKey { + if needsPassphrase { return "Passphrase required" } + return prompt.mode == .localIdentity + ? "Identity required" : "Authentication required" + } + var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 3) { - Text( - prompt.mode == .localIdentity - ? "Identity required" : "Authentication required" - ) + Text(title) .font(.headline) Text(prompt.host.displayAddress).font(.caption.monospaced()).foregroundStyle(.secondary) } @@ -581,69 +587,70 @@ private struct JourneyAuthenticationCard: View { } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { - GridRow { - Text("Identity").foregroundStyle(.secondary) - fullWidthMenu(title: identityLabel) { - Button { - identitySelection.wrappedValue = nil - } label: { - selectionMenuLabel( - String(localized: "Custom"), selected: identityID == nil - ) - } - if !app.hosts.identities.isEmpty { Divider() } - ForEach(app.hosts.identities) { identity in - Button { - identitySelection.wrappedValue = identity.id - } label: { - selectionMenuLabel( - "\(identity.name) · \(identity.username)", - selected: identityID == identity.id - ) - } - } - } - } - if identityID == nil { - GridRow { - Text("Username").foregroundStyle(.secondary) - TextField("Username", text: usernameInput) - .focused($focusedField, equals: .username) - } - GridRow { - Text("Password").foregroundStyle(.secondary) - SecureField("Optional", text: passwordInput) - .focused($focusedField, equals: .password) - } - GridRow { - Text("Key").foregroundStyle(.secondary) - fullWidthMenu(title: keyLabel) { - Button { - keySelection.wrappedValue = nil - } label: { - selectionMenuLabel( - String(localized: "None"), selected: keyID == nil - ) - } - if !app.hosts.keys.isEmpty { Divider() } - ForEach(app.hosts.keys) { key in - Button { - keySelection.wrappedValue = key.id - } label: { - selectionMenuLabel( - key.label, selected: keyID == key.id - ) - } - } - } - } - } if needsPassphrase { GridRow { Text("Passphrase").foregroundStyle(.secondary) SecureField("Required for this connection", text: $passphrase) .focused($focusedField, equals: .passphrase) } + } else { + GridRow { + Text("Identity").foregroundStyle(.secondary) + fullWidthMenu(title: identityLabel) { + Button { + identitySelection.wrappedValue = nil + } label: { + selectionMenuLabel( + String(localized: "Custom"), selected: identityID == nil + ) + } + if !app.hosts.identities.isEmpty { Divider() } + ForEach(app.hosts.identities) { identity in + Button { + identitySelection.wrappedValue = identity.id + } label: { + selectionMenuLabel( + "\(identity.name) · \(identity.username)", + selected: identityID == identity.id + ) + } + } + } + } + if identityID == nil { + GridRow { + Text("Username").foregroundStyle(.secondary) + TextField("Username", text: usernameInput) + .focused($focusedField, equals: .username) + } + GridRow { + Text("Password").foregroundStyle(.secondary) + SecureField("Optional", text: passwordInput) + .focused($focusedField, equals: .password) + } + GridRow { + Text("Key").foregroundStyle(.secondary) + fullWidthMenu(title: keyLabel) { + Button { + keySelection.wrappedValue = nil + } label: { + selectionMenuLabel( + String(localized: "None"), selected: keyID == nil + ) + } + if !app.hosts.keys.isEmpty { Divider() } + ForEach(app.hosts.keys) { key in + Button { + keySelection.wrappedValue = key.id + } label: { + selectionMenuLabel( + key.label, selected: keyID == key.id + ) + } + } + } + } + } } } .frame(maxWidth: .infinity) @@ -652,11 +659,18 @@ private struct JourneyAuthenticationCard: View { HStack { Button("Cancel", action: cancel).keyboardShortcut(.cancelAction) Spacer() - Button("Continue") { commit(save: false) }.disabled(!canContinue) - Button("Save and Continue") { commit(save: true) } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!canContinue) + if needsPassphrase { + Button("Continue") { commit(save: false) } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canContinue) + } else { + Button("Continue") { commit(save: false) }.disabled(!canContinue) + Button("Save and Continue") { commit(save: true) } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canContinue) + } } } .padding(18) @@ -664,11 +678,17 @@ private struct JourneyAuthenticationCard: View { .overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(.separator.opacity(0.7)) } .onAppear { if identityID != nil, selectedIdentity == nil { identityID = nil } - if needsPassphrase { - focusedField = .passphrase - } else if identityID == nil { - focusedField = username.isEmpty ? .username : .password - } + updateFocusForRequirement() + } + } + + private func updateFocusForRequirement() { + if needsPassphrase { + focusedField = .passphrase + } else if identityID == nil { + focusedField = username.isEmpty ? .username : .password + } else { + focusedField = nil } } diff --git a/TermindTests/DataProtectionKeychainTests.swift b/TermindTests/DataProtectionKeychainTests.swift index 7da3f785..0303bf4f 100644 --- a/TermindTests/DataProtectionKeychainTests.swift +++ b/TermindTests/DataProtectionKeychainTests.swift @@ -5,6 +5,28 @@ import Testing @Suite(.serialized) struct DataProtectionKeychainTests { + @Test + func persistedStorageNamesUseThisDevice() { + #expect(KeyStorage.thisDevice.rawValue == "thisDevice") + #expect(PasswordStorage.thisDevice.rawValue == "thisDevice") + #expect(KeyStorage(rawValue: "local" + "Keychain") == nil) + #expect(Identity(name: "", username: "").passwordStorage == .iCloudKeychain) + #expect(SSHKeyRef(label: "").storage == .iCloudKeychain) + } + + @Test(arguments: [PasswordStorage.thisDevice, .iCloudKeychain]) + func identityPasswordRoundTripUsesExactStorageClass( + _ storage: PasswordStorage + ) throws { + let keychain = KeychainSecretStore() + let reference = "termind.password.\(UUID().uuidString)" + defer { try? keychain.removePassword(reference, storage: storage) } + + try keychain.setString("secret", for: reference, storage: storage) + #expect(try keychain.string(for: reference, storage: storage) == "secret") + let other: PasswordStorage = storage == .thisDevice ? .iCloudKeychain : .thisDevice + #expect(try keychain.string(for: reference, storage: other) == nil) + } @Test func aiSecretsSurviveAcrossStoreInstances() { let reference = "termind.ai.test.\(UUID().uuidString)" @@ -19,7 +41,7 @@ struct DataProtectionKeychainTests { #expect(reader.secretStorageError == nil) } - @Test(arguments: [KeyStorage.localKeychain, .iCloudKeychain]) + @Test(arguments: [KeyStorage.thisDevice, .iCloudKeychain]) func softwareKeyRoundTripUsesExactStorageClass(_ storage: KeyStorage) throws { let keychain = KeychainSecretStore() let reference = "termind.key.\(UUID().uuidString)" diff --git a/TermindTests/IdentityCredentialTests.swift b/TermindTests/IdentityCredentialTests.swift index cefea33a..5f67d7a3 100644 --- a/TermindTests/IdentityCredentialTests.swift +++ b/TermindTests/IdentityCredentialTests.swift @@ -14,7 +14,7 @@ struct IdentityCredentialTests { let keyID = UUID() let original = Identity( id: id, name: "Deploy", username: "deploy", - credentialRef: reference, keyID: keyID + credentialRef: reference, passwordStorage: .thisDevice, keyID: keyID ) let store = HostStore(identities: [original], keychain: keychain) defer { try? keychain.removePassword(reference) } @@ -45,10 +45,18 @@ struct IdentityCredentialTests { try store.upsert(identity, passwordUpdate: .replace("secret")) let saved = try #require(store.identity(identity.id)) - defer { try? keychain.removePassword(saved.credentialRef) } + defer { + try? keychain.removePassword( + saved.credentialRef, storage: saved.passwordStorage + ) + } #expect(!saved.credentialRef.isEmpty) - #expect(try keychain.string(for: saved.credentialRef) == "secret") + #expect( + try keychain.string( + for: saved.credentialRef, storage: saved.passwordStorage + ) == "secret" + ) } @Test @@ -58,7 +66,7 @@ struct IdentityCredentialTests { let missingKeyReference = "termind.key.\(UUID().uuidString)" defer { try? keychain.removePassword(passwordReference) - try? keychain.removeKeyCredential(missingKeyReference, storage: .localKeychain) + try? keychain.removeKeyCredential(missingKeyReference, storage: .thisDevice) } let key = SSHKeyRef( @@ -67,7 +75,8 @@ struct IdentityCredentialTests { ) let identity = Identity( name: "Deploy", username: "deploy", - credentialRef: passwordReference, keyID: key.id + credentialRef: passwordReference, passwordStorage: .thisDevice, + keyID: key.id ) try keychain.setString("fallback", for: passwordReference) let store = HostStore(keys: [key], identities: [identity], keychain: keychain) @@ -87,7 +96,7 @@ struct IdentityCredentialTests { func protectedKeyWithoutSavedPassphraseRequestsItBeforeUse() throws { let keychain = KeychainSecretStore() let reference = "termind.key.\(UUID().uuidString)" - defer { try? keychain.removeKeyCredential(reference, storage: .localKeychain) } + defer { try? keychain.removeKeyCredential(reference, storage: .thisDevice) } let key = SSHKeyRef( label: "Protected", algorithm: .ed25519, publicKey: "ssh-ed25519 test", @@ -98,7 +107,7 @@ struct IdentityCredentialTests { let store = HostStore(keys: [key], identities: [identity], keychain: keychain) try keychain.setKeyCredential( .software(SSHPrivateKeySecret(privateKey: "encrypted", passphrase: nil)), - for: reference, storage: .localKeychain + for: reference, storage: .thisDevice ) let resolution = store.sshCredentials(for: identity) diff --git a/TermindTests/KeychainOrderTests.swift b/TermindTests/KeychainOrderTests.swift index 75a2b94b..7cbe5e95 100644 --- a/TermindTests/KeychainOrderTests.swift +++ b/TermindTests/KeychainOrderTests.swift @@ -39,7 +39,7 @@ struct KeychainOrderTests { } @Test - func renameKeepsPositionAndNewItemsAppend() throws { + func editingANameKeepsPositionAndNewItemsAppend() throws { let first = SSHKeyRef( label: "First", credentialRef: "termind.key.\(UUID().uuidString)", sortIndex: 0 ) diff --git a/TermindTests/RepositoryOutboxTests.swift b/TermindTests/RepositoryOutboxTests.swift index cf57d562..ad445640 100644 --- a/TermindTests/RepositoryOutboxTests.swift +++ b/TermindTests/RepositoryOutboxTests.swift @@ -7,6 +7,56 @@ import Testing @Suite @MainActor struct RepositoryOutboxTests { + @Test + func versionThreeStorageNamesMigrateToThisDevice() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "TermindV3Migration-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appending(path: "Termind.store") + let identity = Identity( + name: "Legacy Identity", username: "legacy", + credentialRef: "termind.password.legacy", passwordStorage: .thisDevice + ) + let key = SSHKeyRef( + label: "Legacy Key", publicKey: "ssh-ed25519 AAAA legacy", + fingerprint: "SHA256:legacy", credentialRef: "termind.key.legacy", + storage: .thisDevice + ) + + do { + let schema = Schema(versionedSchema: TermindSchemaV3.self) + let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let context = container.mainContext + context.insert(TermindSchemaV3Models.PersistentIdentity(identity)) + let persistentKey = TermindSchemaV3Models.PersistentSSHKey(key) + persistentKey.storage = "local" + "Keychain" + context.insert(persistentKey) + try context.save() + } + + let schema = Schema(versionedSchema: TermindSchemaV4.self) + let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL) + let container = try ModelContainer( + for: schema, migrationPlan: TermindMigrationPlan.self, + configurations: [configuration] + ) + let context = container.mainContext + let migratedIdentity = try #require( + context.fetch(FetchDescriptor()).first + ) + let migratedKey = try #require( + context.fetch(FetchDescriptor()).first + ) + + #expect(migratedIdentity.value == identity) + #expect(migratedIdentity.passwordStorage == PasswordStorage.thisDevice.rawValue) + #expect(migratedKey.value == key) + #expect(migratedKey.storage == KeyStorage.thisDevice.rawValue) + #expect(migratedKey.certificate.isEmpty) + } + @Test func versionOneStoreMigratesToCurrentSchemaWithoutLosingData() throws { let directory = FileManager.default.temporaryDirectory @@ -17,10 +67,13 @@ struct RepositoryOutboxTests { let host = Host(name: "Production", host: "prod.example") let group = HostGroup(name: "Servers") - let identity = Identity(name: "Operator", username: "deploy") + let identity = Identity( + name: "Operator", username: "deploy", passwordStorage: .thisDevice + ) let key = SSHKeyRef( label: "Deployment", publicKey: "ssh-ed25519 AAAA migration", - fingerprint: "SHA256:migration", credentialRef: "key-migration" + fingerprint: "SHA256:migration", credentialRef: "key-migration", + storage: .thisDevice ) let knownHost = KnownHostEntry( hostname: "prod.example", algorithm: .ed25519, @@ -44,8 +97,10 @@ struct RepositoryOutboxTests { let context = container.mainContext context.insert(PersistentHost(host)) context.insert(PersistentHostGroup(group)) - context.insert(PersistentIdentity(identity)) - context.insert(PersistentSSHKey(key)) + context.insert(TermindSchemaV3Models.PersistentIdentity(identity)) + let persistentKey = TermindSchemaV3Models.PersistentSSHKey(key) + persistentKey.storage = "local" + "Keychain" + context.insert(persistentKey) context.insert(PersistentKnownHost(knownHost)) context.insert(PersistentSnippetPackage(package)) context.insert(PersistentSnippet(snippet)) @@ -53,7 +108,7 @@ struct RepositoryOutboxTests { try context.save() } - let schema = Schema(versionedSchema: TermindSchemaV3.self) + let schema = Schema(versionedSchema: TermindSchemaV4.self) let configuration = ModelConfiguration( "Migration", schema: schema, url: storeURL ) diff --git a/TermindTests/SSHConnectionJourneyTests.swift b/TermindTests/SSHConnectionJourneyTests.swift index 29e4d376..40915a86 100644 --- a/TermindTests/SSHConnectionJourneyTests.swift +++ b/TermindTests/SSHConnectionJourneyTests.swift @@ -90,6 +90,32 @@ struct SSHConnectionJourneyTests { #expect(!journey.waitsForLocalIdentity) } + @Test + func localRequirementTransitionsBetweenIdentityAndPassphraseSteps() { + let destination = Host(name: "Server", host: "server.example") + let journey = SSHConnectionJourney() + journey.begin( + host: destination, route: [destination], + purpose: .sshSession, username: "deploy" + ) + + journey.waitForLocalIdentity(at: destination.id) + #expect(journey.waitsForLocalIdentity) + #expect(!journey.waitsForLocalPassphrase) + + journey.waitForLocalPassphrase(at: destination.id) + #expect(journey.waitsForLocalPassphrase) + #expect(!journey.waitsForLocalIdentity) + #expect(journey.plainTextLog.contains("Waiting for a Passphrase")) + + journey.waitForLocalIdentity(at: destination.id) + #expect(journey.waitsForLocalIdentity) + #expect(!journey.waitsForLocalPassphrase) + + journey.resume() + #expect(journey.localRequirement == nil) + } + @Test func authenticationPromptDistinguishesIdentityCollectionFromServerChallenge() { let host = Host(name: "Server", host: "server.example") diff --git a/TermindTests/SSHUserCertificateTests.swift b/TermindTests/SSHUserCertificateTests.swift new file mode 100644 index 00000000..a14a69b0 --- /dev/null +++ b/TermindTests/SSHUserCertificateTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing + +@testable import Termind + +struct SSHUserCertificateTests { + @Test + func parsesMatchingUserCertificateAndReportsValidity() throws { + let material = try SSHKeyMaterial.generate( + .ed25519, comment: "certificate-test", passphrase: nil + ) + let certificate = try makeEd25519Certificate( + publicKey: material.publicKey, type: 1, + validAfter: UInt64(Date().timeIntervalSince1970) - 60, + validBefore: UInt64(Date().timeIntervalSince1970) + 3_600 + ) + + let parsed = try SSHUserCertificate.parse( + certificate, matching: material.publicKey + ) + + #expect(parsed.algorithm == .ed25519) + #expect(parsed.serial == 42) + #expect(parsed.keyID == "termind-test") + #expect(parsed.validityWarning == nil) + } + + @Test + func rejectsHostCertificateAndMismatchedKey() throws { + let first = try SSHKeyMaterial.generate(.ed25519, comment: "first", passphrase: nil) + let second = try SSHKeyMaterial.generate(.ed25519, comment: "second", passphrase: nil) + let hostCertificate = try makeEd25519Certificate( + publicKey: first.publicKey, type: 2, + validAfter: 0, validBefore: UInt64.max + ) + #expect(throws: SSHUserCertificateError.hostCertificate) { + try SSHUserCertificate.parse(hostCertificate, matching: first.publicKey) + } + + let userCertificate = try makeEd25519Certificate( + publicKey: first.publicKey, type: 1, + validAfter: 0, validBefore: UInt64.max + ) + #expect(throws: SSHUserCertificateError.keyMismatch) { + try SSHUserCertificate.parse(userCertificate, matching: second.publicKey) + } + } + + @Test + func acceptsExpiredCertificateWithWarning() throws { + let material = try SSHKeyMaterial.generate( + .ed25519, comment: "expired", passphrase: nil + ) + let now = UInt64(Date().timeIntervalSince1970) + let certificate = try makeEd25519Certificate( + publicKey: material.publicKey, type: 1, + validAfter: now - 3_600, validBefore: now - 60 + ) + let parsed = try SSHUserCertificate.parse( + certificate, matching: material.publicKey + ) + #expect(parsed.validityWarning != nil) + } + + private func makeEd25519Certificate( + publicKey: String, type: UInt32, validAfter: UInt64, validBefore: UInt64 + ) throws -> String { + let parts = publicKey.split(whereSeparator: \Character.isWhitespace) + let publicBlob = try #require(Data(base64Encoded: String(parts[1]))) + var reader = TestSSHReader(data: publicBlob) + let method = reader.field() + let keyField = reader.field() + _ = try #require(method) + let key = try #require(keyField) + + var blob = Data() + blob.append(field(Data("ssh-ed25519-cert-v01@openssh.com".utf8))) + blob.append(field(Data("nonce".utf8))) + blob.append(field(key)) + blob.append(uint64(42)) + blob.append(uint32(type)) + blob.append(field(Data("termind-test".utf8))) + blob.append(field(Data())) // principals + blob.append(uint64(validAfter)) + blob.append(uint64(validBefore)) + blob.append(field(Data())) // critical options + blob.append(field(Data())) // extensions + blob.append(field(Data())) // reserved + blob.append(field(Data("ca".utf8))) + blob.append(field(Data("signature".utf8))) + return "ssh-ed25519-cert-v01@openssh.com \(blob.base64EncodedString())" + } + + private func field(_ value: Data) -> Data { uint32(UInt32(value.count)) + value } + + private func uint32(_ value: UInt32) -> Data { + var bigEndian = value.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + private func uint64(_ value: UInt64) -> Data { + var bigEndian = value.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } +} + +private struct TestSSHReader { + let data: Data + var offset = 0 + + mutating func field() -> Data? { + guard offset + 4 <= data.count else { return nil } + let length = data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) } + offset += 4 + guard offset + length <= data.count else { return nil } + defer { offset += length } + return data[offset..<(offset + length)] + } +}