Refactor Keychain storage and SSH key editing

This commit is contained in:
Kaede Akino committed 2026-08-25 17:10:23 +08:00
1 parent 37e1e0a9ec
commit bd09284247
25 files changed
+1582 -456

No files matched your search

+31
View File
@@ -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.
+8 -5
View File
@@ -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)
+179 -74
View File
@@ -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<PersistentSSHKey>())
for key in keys where key.storage == legacyStorage {
key.storage = KeyStorage.thisDevice.rawValue
}
let identities = try context.fetch(FetchDescriptor<PersistentIdentity>())
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
+29 -8
View File
@@ -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)
}
+4 -2
View File
@@ -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):
+166
View File
@@ -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..<count {
guard let field = readField() else { throw SSHUserCertificateError.invalidFormat }
result.append(field)
}
return result
}
}
private nonisolated enum SSHWireWriter {
static func field(_ data: Data) -> Data {
var length = UInt32(data.count).bigEndian
return withUnsafeBytes(of: &length) { Data($0) } + data
}
}
@@ -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
}
}
+1 -1
View File
@@ -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.
+188 -32
View File
@@ -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)
@@ -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<Bool> {
Binding(get: { error != nil }, set: { if !$0 { error = nil } })
}
}
@@ -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 {
@@ -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 {
+389 -174
View File
@@ -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 {
@@ -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
+72 -9
View File
@@ -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)
}
+2 -2
View File
@@ -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?)
+86 -18
View File
@@ -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?
+4 -4
View File
@@ -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):
@@ -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
}
}
+23 -1
View File
@@ -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)"
+16 -7
View File
@@ -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)
+1 -1
View File
@@ -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
)
+60 -5
View File
@@ -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<PersistentIdentity>()).first
)
let migratedKey = try #require(
context.fetch(FetchDescriptor<PersistentSSHKey>()).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
)
@@ -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")
+119
View File
@@ -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)]
}
}