Refactor Keychain storage and SSH key editing
This commit is contained in:
1 parent
37e1e0a9ec
commit
bd09284247
25 files changed
+1582
-456
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user