fix(ssh): restore saved passphrases and authentication saving

This commit is contained in:
Kaede Akino committed 2026-09-28 18:37:21 +08:00
1 parent 0a9d5100e6
commit b58c8beb5e
32 files changed
+1261 -241

No files matched your search

@@ -180,7 +180,7 @@ struct SSHConnectionPreparationTests {
}
@Test
func protectedKeyIsNotReadBeforePassphraseIsRequested() async {
func protectedKeyWithoutSavedPassphraseRequestsEntry() async {
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
credentialRef: "key.protected", hasPassphrase: true
@@ -149,7 +149,7 @@ struct SSHInteractionPolicyTests {
#expect(password == "current-password")
}
@Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws {
@Test func additionalResolutionChecksForSavedPassphraseAndUsesIdentityPassword() throws {
let secrets = CountingSecretStore()
let protectedKey = SSHKeyRef(
label: "Encrypted", publicKey: "ssh-ed25519 public",
@@ -174,9 +174,9 @@ struct SSHInteractionPolicyTests {
let request = try store.additionalConnectionRequest(
from: SSHAdditionalConnectionSnapshot(target: node)
)
#expect(secrets.keyReads == 0)
#expect(secrets.keyReads == 1)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("Passphrase key must remain unread")
Issue.record("A key without a saved passphrase must require interaction")
return
}
guard case .password(let password) = request.configuration.credentials[1] else {
+326
View File
@@ -0,0 +1,326 @@
import Foundation
import Observation
import Synchronization
import TermindDomain
import Testing
@testable import TermindApplication
@Suite @MainActor
struct SavedPassphraseTests {
@Test func keychainCardObservesSavingAndClearingThePassphrase() throws {
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let store = HostLibraryModel()
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
let credentials = CredentialLibraryModel(library: store)
let changed = Mutex(false)
withObservationTracking {
#expect(!credentials.hasSavedPassphrase(for: key))
} onChange: {
changed.withLock { $0 = true }
}
try store.saveKeyPassphrase("saved", for: key.id)
#expect(changed.withLock { $0 })
#expect(credentials.hasSavedPassphrase(for: key))
changed.withLock { $0 = false }
withObservationTracking {
#expect(credentials.hasSavedPassphrase(for: key))
} onChange: {
changed.withLock { $0 = true }
}
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
#expect(changed.withLock { $0 })
#expect(!credentials.hasSavedPassphrase(for: key))
}
@Test(arguments: [false, true], [false, true])
func passphraseSaveIsExplicitAndPreservesStagedIdentity(save: Bool, allowsSaving: Bool) throws {
let secrets = InMemorySecretStore()
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Host", host: "example.invalid")
let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
let prompt = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity.id, username: "deploy", selectedKeyID: key.id,
requiresPassphrase: true, allowsSaving: allowsSaving)
let staged = SSHAuthenticationSubmission(
username: "deploy", password: "fallback", keyID: key.id, identityID: identity.id)
let result = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: save)
#expect(result.password == "fallback")
#expect(result.identityID == identity.id)
#expect(result.keyID == key.id)
#expect(result.passphrase == "saved")
#expect(store.host(host.id) == host)
#expect(store.identities == [identity])
#expect(store.requiresPassphraseEntry(for: key) == !(save && allowsSaving))
guard case .software(let credential)? = try secrets.keyCredential(for: key.credentialRef, storage: key.storage)
else {
Issue.record("Missing key")
return
}
#expect(credential.passphrase == (save && allowsSaving ? "saved" : nil))
}
@Test func savingWithoutAStagedSubmissionDoesNotSaveTheHostIdentity() throws {
let secrets = InMemorySecretStore()
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let store = HostLibraryModel(keys: [key], secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
let host = Host(name: "Host", host: "example.invalid")
let prompt = SSHAuthenticationPrompt(
host: host, selectedIdentityID: nil, username: "deploy", selectedKeyID: key.id, requiresPassphrase: true)
let result = try store.passphraseSubmission(for: prompt, existing: nil, passphrase: "saved", save: true)
#expect(result.username == "deploy")
#expect(store.hosts.isEmpty && store.identities.isEmpty)
#expect(!store.requiresPassphraseEntry(for: key))
}
@Test func missingKeySaveFailsWithoutMutatingTheSubmission() {
let store = HostLibraryModel()
let keyID = UUID()
let staged = SSHAuthenticationSubmission(username: "deploy", password: "fallback", keyID: keyID)
let prompt = SSHAuthenticationPrompt(
host: Host(name: "Host", host: "example.invalid"), selectedIdentityID: nil, username: "deploy",
selectedKeyID: keyID, requiresPassphrase: true)
#expect(throws: TermindSSHError.self) {
_ = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: true)
}
#expect(staged.passphrase == nil && staged.password == "fallback")
}
@Test func savedPassphrasesAllowSilentTargetAndJumpConnectionsAndAreReread() throws {
let secrets = InMemorySecretStore()
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let store = HostLibraryModel(keys: [key], identities: [identity], secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
)
func node(_ name: String) -> SSHAdditionalConnectionSnapshot.Node {
.init(
id: UUID(), name: name, host: "example.invalid", port: 22,
authentication: .init(identityID: identity.id, username: "deploy"), expectedHostKeys: [], proxy: nil)
}
let snapshot = SSHAdditionalConnectionSnapshot(target: node("Target"), jumps: [node("Jump")])
let first = try store.additionalConnectionRequest(from: snapshot)
defer { first.configuration.clearSecrets() }
for credential in first.configuration.credentials + first.configuration.jumps.flatMap(\.credentials) {
guard case .privateKey(_, _, let passphrase, let requiresEntry) = credential else {
Issue.record("Saved passphrase must be usable silently")
continue
}
#expect(!requiresEntry)
#expect(passphrase?.matches("saved") == true)
}
#expect(
SSHAuthenticationPromptResolver.host(
Host(name: "Host", host: "example.invalid", identityID: identity.id), using: store
).requiresPassphrase == false)
try store.saveKeyPassphrase("replacement", for: key.id)
let second = try store.additionalConnectionRequest(from: snapshot)
defer { second.configuration.clearSecrets() }
guard case .privateKey(_, _, let passphrase, _) = second.configuration.credentials.first else {
Issue.record("Expected refreshed key")
return
}
#expect(passphrase?.matches("replacement") == true)
}
@Test func preparationUsesSavedPassphrasesWithoutPrompts() async throws {
let secrets = InMemorySecretStore()
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
)
let preparation = SSHConnectionPreparation(
nodes: [
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
], secrets: secrets)
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
defer { configuration.clearSecrets() }
guard case .privateKey(_, _, let passphrase, let requiresEntry) = configuration.credentials.first else {
Issue.record("Expected saved key")
return
}
#expect(!requiresEntry && passphrase?.matches("saved") == true)
}
@Test(arguments: [false, true], [false, true])
func clearingOrDeletingAKeyZeroesRollbackSecrets(delete: Bool, failsSave: Bool) throws {
let observation = ZeroizedAllocations()
let secrets = TrackedVaultSecrets(observation: observation)
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let persistence = PassphrasePersistence(snapshot: VaultSnapshot(keys: [key]))
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage
)
persistence.failsSave = failsSave
do {
if delete {
try store.delete(keyID: key.id)
} else {
try store.upsert(key, credential: .software(.init(privateKey: "encrypted")))
}
#expect(!failsSave)
} catch { #expect(failsSave) }
#expect(observation.count == 2 && observation.allZero)
let current = try secrets.keyCredential(for: key.credentialRef, storage: key.storage)
if delete && !failsSave {
#expect(current == nil && store.key(key.id) == nil)
} else {
guard case .software(let value)? = current else {
Issue.record("Missing key")
return
}
#expect(value.privateKey == "encrypted")
#expect(value.passphrase == (failsSave ? "saved" : nil))
#expect(store.key(key.id) != nil)
}
}
@Test func preparationFailureZeroesSavedSecretsEvenWhileAliasesRemainAlive() async {
let observation = ZeroizedAllocations()
let secrets = RetainedPreparationSecrets(observation: observation)
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
let preparation = SSHConnectionPreparation(
nodes: [
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
], secrets: secrets)
await #expect(throws: TermindSSHError.self) { _ = try await preparation.resolve() }
#expect(secrets.key.isConsumed && secrets.passphrase.isConsumed)
#expect(observation.count == 2 && observation.allZero)
}
@Test func cancellationAfterKeychainReadZeroesSavedSecrets() async {
let observation = ZeroizedAllocations()
let (events, continuation) = AsyncStream<Void>.makeStream()
let gate = DispatchSemaphore(value: 0)
let secrets = RetainedPreparationSecrets(observation: observation) {
continuation.yield(())
gate.wait()
}
let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
let preparation = SSHConnectionPreparation(
nodes: [
.init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil)
], secrets: secrets)
let task = Task { try await preparation.resolve(trustPolicy: .allowChallenge) }
var iterator = events.makeAsyncIterator()
_ = await iterator.next()
task.cancel()
gate.signal()
await #expect(throws: CancellationError.self) { _ = try await task.value }
#expect(observation.count == 2 && observation.allZero)
continuation.finish()
}
@Test func replacedSavedPassphraseAndAllAttemptBuffersAreZeroedBeforeDeallocation() throws {
let observation = ZeroizedAllocations()
let key = SSHSecret("private-key")
let saved = SSHSecret("saved-passphrase")
let override = SSHSecret("override-passphrase")
for secret in [key, saved, override] { secret.observeZeroization { observation.record($0) } }
let credential = SSHResolvedKeyCredential.software(key, passphrase: saved).sshCredential(
publicKey: "public", passphraseOverride: override, requiresPassphrase: true)
#expect(observation.count == 1)
credential.clearSecrets()
#expect(observation.count == 3)
#expect(observation.allZero)
}
@Test(arguments: [false, true])
func consumingSavedPassphraseZeroesAllocationOnSuccessAndThrow(fails: Bool) throws {
let observation = ZeroizedAllocations()
let secret = SSHSecret("saved")
secret.observeZeroization { observation.record($0) }
do {
try secret.withUnsafeCStringAndConsume { _, _ in
if fails { throw CancellationError() }
}
} catch { #expect(fails && error is CancellationError) }
#expect(observation.count == 1 && observation.allZero)
}
}
private nonisolated final class ZeroizedAllocations: @unchecked Sendable {
private let lock = NSLock()
private var observations: [Bool] = []
var count: Int { lock.withLock { observations.count } }
var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } }
func record(_ bytes: UnsafeRawBufferPointer) {
lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) }
}
}
private nonisolated final class RetainedPreparationSecrets: SSHRuntimeSecretResolving, Sendable {
let key = SSHSecret("encrypted")
let passphrase = SSHSecret("saved")
let onRead: @Sendable () -> Void
init(observation: ZeroizedAllocations, onRead: @escaping @Sendable () -> Void = {}) {
self.onRead = onRead
key.observeZeroization { observation.record($0) }
passphrase.observeZeroization { observation.record($0) }
}
func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { nil }
func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? {
onRead()
return .software(key, passphrase: passphrase)
}
}
private nonisolated final class TrackedVaultSecrets: SecretStoring, Sendable {
let base = InMemorySecretStore()
let observation: ZeroizedAllocations
init(observation: ZeroizedAllocations) { self.observation = observation }
func string(for account: String, storage: PasswordStorage) throws -> String? {
try base.string(for: account, storage: storage)
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try base.setString(value, for: account, storage: storage)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
try base.keyCredential(for: account, storage: storage)
}
func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? {
let result = try base.sshKeyCredential(for: account, storage: storage)
if case .software(let key, let passphrase) = result {
key.observeZeroization { [observation] in observation.record($0) }
passphrase?.observeZeroization { [observation] in observation.record($0) }
}
return result
}
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {
try base.setKeyCredential(value, for: account, storage: storage)
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
try base.removePassword(account, storage: storage)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
try base.removeKeyCredential(account, storage: storage)
}
}
@MainActor private final class PassphrasePersistence: VaultPersisting {
var snapshot: VaultSnapshot
var failsSave = false
init(snapshot: VaultSnapshot) { self.snapshot = snapshot }
func load() throws -> VaultSnapshot { snapshot }
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
if failsSave { throw CocoaError(.fileWriteUnknown) }
self.snapshot = snapshot
return .empty
}
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
}
}
@@ -19574,6 +19574,23 @@
}
}
},
"Passphrase Saved" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已保存密码短语"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "已儲存金鑰密碼"
}
}
}
},
"Passphrase required" : {
"extractionState" : "manual",
"localizations" : {
@@ -23891,6 +23908,23 @@
}
}
},
"Save Passphrase" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "保存密码短语"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "儲存金鑰密碼"
}
}
}
},
"Save Workspace Template" : {
"extractionState" : "manual",
"localizations" : {
@@ -59,6 +59,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
func passwordSecret(for identity: Identity) throws -> SSHSecret?
func passwordAvailability(for identity: Identity) -> SecretAvailability
func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID)
@@ -69,6 +70,36 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
}
extension RemoteSessionHostStore {
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
throw TermindSSHError.missingCredential
}
public func passphraseSubmission(
for prompt: SSHAuthenticationPrompt, existing: SSHAuthenticationSubmission?,
passphrase: String?, save: Bool
) throws -> SSHAuthenticationSubmission {
guard let passphrase, !passphrase.isEmpty else { throw TermindSSHError.keyPassphraseRequired }
var submission =
existing
?? SSHAuthenticationSubmission(
username: prompt.username, keyID: prompt.selectedKeyID, identityID: prompt.selectedIdentityID)
guard let keyID = submission.keyID else { throw TermindSSHError.missingCredential }
if save && prompt.allowsSaving { try saveKeyPassphrase(passphrase, for: keyID) }
submission.passphrase = passphrase
return submission
}
/// Query the local Keychain rather than relying on synchronized metadata.
/// Every probe releases its private key and passphrase immediately.
public func requiresPassphraseEntry(for key: SSHKeyRef) -> Bool {
guard key.hasPassphrase else { return false }
do {
guard let credential = try keySecretCredential(for: key) else { return true }
defer { credential.clearSecrets() }
return !credential.hasSavedPassphrase
} catch { return true }
}
public func snippet(_ id: Snippet.ID) -> Snippet? { nil }
public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil }
public var customPasswordStorage: PasswordStorage { .iCloud }
@@ -292,6 +323,15 @@ extension RemoteSessionHostStore {
public func additionalConnectionRequest(
from snapshot: SSHAdditionalConnectionSnapshot
) throws -> SSHConnectionRequest {
var completed = false
var resolvedCredentials: [SSHCredentials] = []
var resolvedProxies: [SSHSecret] = []
defer {
if !completed {
for credential in resolvedCredentials { credential.clearSecrets() }
for secret in resolvedProxies { secret.clear() }
}
}
func proxy(
_ value: SSHAdditionalConnectionSnapshot.Proxy?, ownerHostID: Host.ID
) throws -> SSHTransportProxy? {
@@ -307,6 +347,7 @@ extension RemoteSessionHostStore {
}
let password = try connectionPasswordSecret(for: identity)
guard let password else { throw TermindSSHError.missingCredential }
resolvedProxies.append(password)
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: value.username, password: password,
@@ -332,7 +373,7 @@ extension RemoteSessionHostStore {
let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID
if let keyID {
if let key = key(keyID) {
if key.requiresPassphraseEntry || key.requiresUserPresence {
if key.requiresUserPresence || requiresPassphraseEntry(for: key) {
credentials.append(.interactionRequired)
} else {
do {
@@ -373,6 +414,7 @@ extension RemoteSessionHostStore {
if reference.hasEphemeralSecret { throw SSHInteractionRequired() }
if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential }
}
resolvedCredentials += credentials
return (username, credentials)
}
@@ -396,6 +438,7 @@ extension RemoteSessionHostStore {
charset: snapshot.target.charset,
authenticationReference: snapshot.target.authentication
)
completed = true
return SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
@@ -68,7 +68,9 @@ public enum SSHAuthenticationPromptResolver {
let needsPassphrase =
requiresPassphrase
?? (submission?.requiresPasswordReentry != true
&& keyID.flatMap(store.key)?.requiresPassphraseEntry == true
&& keyID.flatMap(store.key).map {
(mode == .serverChallenge && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0)
} == true
&& submission?.passphrase == nil)
return SSHAuthenticationPrompt(
host: host, selectedIdentityID: submission?.identityID ?? identity?.id,
@@ -44,7 +44,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
}
return try await withTaskCancellationHandler {
let result = try await task.value
try Task.checkCancellation()
do { try Task.checkCancellation() } catch {
result.clearSecrets()
throw error
}
return result
} onCancel: {
task.cancel()
@@ -58,10 +61,22 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
var jumps: [SSHRouteHopConfiguration] = []
var completed = false
var resolvedCredentials: [SSHCredentials] = []
var resolvedProxies: [SSHSecret] = []
defer {
if !completed {
for credential in resolvedCredentials { credential.clearSecrets() }
for secret in resolvedProxies { secret.clear() }
}
}
for (offset, node) in nodes.dropLast().enumerated() {
do {
try Task.checkCancellation()
let material = try credentials(node)
resolvedCredentials += material.1
let resolvedProxy = try proxy(node)
if let password = resolvedProxy?.password { resolvedProxies.append(password) }
if trustPolicy == .requireKnownHost, node.trustedKeys.isEmpty {
throw TermindSSHError.untrustedHost
}
@@ -70,7 +85,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port,
username: material.0, credentials: material.1,
expectedHostKeys: node.trustedKeys,
proxy: try proxy(node),
proxy: resolvedProxy,
authenticationReference: authenticationReference(for: node)
))
} catch {
@@ -90,13 +105,17 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
discoveryOnly
? ("", [SSHCredentials]())
: try credentials(target)
resolvedCredentials += material.1
let resolvedProxy = try proxy(target)
if let password = resolvedProxy?.password { resolvedProxies.append(password) }
if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty {
throw TermindSSHError.untrustedHost
}
completed = true
return SSHConnectionConfiguration(
hostID: target.host.id, host: target.host.host, port: target.host.port,
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
proxy: try proxy(target), jumps: jumps,
proxy: resolvedProxy, jumps: jumps,
charset: target.host.charset,
authenticationReference: authenticationReference(for: target)
)
@@ -126,11 +145,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
var result: [SSHCredentials] = []
var completed = false
defer { if !completed { for credential in result { credential.clearSecrets() } } }
var retrievalFailure: (any Error)?
if let key = node.key {
if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true {
throw TermindSSHError.keyPassphraseRequired
}
let stored: SSHResolvedKeyCredential?
do {
stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage)
@@ -139,6 +157,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
retrievalFailure = error
}
if let stored {
if key.hasPassphrase, !stored.hasSavedPassphrase, node.submission?.hasPassphrase != true {
stored.clearSecrets()
throw TermindSSHError.keyPassphraseRequired
}
result.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
@@ -169,6 +191,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
if result.isEmpty, node.key != nil || (usesIdentity && node.identity?.hasSecret == true) {
throw TermindSSHError.missingCredential
}
completed = true
return (username, result)
}
@@ -34,6 +34,9 @@ public final class CredentialLibraryModel {
public func editablePassword(for identity: Identity) throws -> String? {
try library.editablePassword(for: identity)
}
public func hasSavedPassphrase(for key: SSHKeyRef) -> Bool {
key.hasPassphrase && !library.requiresPassphraseEntry(for: key)
}
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
library.keyAvailability(for: key)
}
@@ -26,6 +26,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// The latest local persistence failure. Secret text is never included.
public private(set) var storageError: String?
private let vault: VaultStore
private var keyCredentialRevision = 0
private let keychain: any SecretStoring
private let defaults: UserDefaults
private var expansionState = LibraryExpansionState()
@@ -730,7 +731,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
private func sshCredentials(for submission: SSHAuthenticationSubmission) throws -> [SSHCredentials] {
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID) {
if key.requiresPassphraseEntry, !submission.hasPassphrase {
if requiresPassphraseEntry(for: key), !submission.hasPassphrase {
throw TermindSSHError.keyPassphraseRequired
}
if let stored = try keySecretCredential(for: key) {
@@ -802,7 +803,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
if let keyID = identity.keyID {
if let key = key(keyID) {
if key.requiresPassphraseEntry {
if requiresPassphraseEntry(for: key) {
requiredPassphraseKeyID = key.id
} else {
do {
@@ -1469,11 +1470,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
let previousCredential =
try (credential != nil || changesLocation)
? previousKey.flatMap { try keyCredential(for: $0) }
? previousKey.flatMap { try keySecretCredential(for: $0) }
: nil
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
let destinationCredential = credential?.resolvedCredential() ?? (changesLocation ? previousCredential : nil)
defer {
previousCredential?.clearSecrets()
destinationCredential?.clearSecrets()
}
if let destinationCredential {
try keychain.setKeyCredential(
try keychain.setResolvedKeyCredential(
destinationCredential, for: key.credentialRef, storage: key.storage
)
}
@@ -1494,7 +1499,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
key.credentialRef, storage: key.storage
)
} else if destinationCredential != nil, let previousKey, let previousCredential {
try keychain.setKeyCredential(
try keychain.setResolvedKeyCredential(
previousCredential, for: previousKey.credentialRef,
storage: previousKey.storage
)
@@ -1510,6 +1515,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
storageError ?? String(localized: "The key could not be saved.")
)
}
if destinationCredential != nil { keyCredentialRevision &+= 1 }
if changesLocation, let previousKey {
do {
try keychain.removeKeyCredential(
@@ -1551,8 +1557,19 @@ public final class HostLibraryModel: RemoteSessionHostStore {
try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
}
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
guard !passphrase.isEmpty, let key = key(keyID), key.hasPassphrase, key.storage != .secureEnclave else {
throw TermindSSHError.missingCredential
}
let secret = SSHSecret(passphrase)
defer { secret.clear() }
try keychain.setKeyPassphrase(secret, for: key.credentialRef, storage: key.storage)
keyCredentialRevision &+= 1
}
public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
_ = keyCredentialRevision
return try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
}
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
@@ -1567,7 +1584,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func delete(keyID: SSHKeyRef.ID) throws {
guard let key = key(keyID) else { return }
let credential = try keyCredential(for: key)
let credential = try keySecretCredential(for: key)
defer { credential?.clearSecrets() }
try keychain.removeKeyCredential(key.credentialRef, storage: key.storage)
let keySnapshot = keys
let identitySnapshot = identities
@@ -1579,7 +1597,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
keys = keySnapshot
identities = identitySnapshot
if let credential {
try keychain.setKeyCredential(
try keychain.setResolvedKeyCredential(
credential, for: key.credentialRef, storage: key.storage
)
}
@@ -296,13 +296,15 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
return
}
if server.authenticationPrompt?.requiresPassphrase == true,
var staged = submissions[server.id]?[host.id]
{
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
submissions[server.id]?[host.id] = staged
retry(server)
if let prompt = server.authenticationPrompt, prompt.requiresPassphrase {
do {
submissions[server.id, default: [:]][host.id] = try hosts.passphraseSubmission(
for: prompt, existing: submissions[server.id]?[host.id], passphrase: passphrase, save: save)
retry(server)
} catch {
server.authenticationPrompt?.failureMessage = error.localizedDescription
server.status = error.localizedDescription
}
return
}
let chosenKey = identity?.keyID ?? keyID
@@ -367,7 +369,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
let keyID = staged?.keyID ?? identity?.keyID
let passphrase =
staged?.requiresPasswordReentry != true
&& keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true
&& keyID.flatMap { hosts.key($0) }?.hasPassphrase == true
&& staged?.passphrase == nil
server.authenticationHost = host
server.authenticationPrompt = SSHAuthenticationPromptResolver.host(
@@ -730,7 +732,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
)
}
if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase(
in: route, key: hosts.key
in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
) {
throw TermindSSHError.routeHost(
hostID: passphraseHost.id, authentication: true,
@@ -329,25 +329,39 @@ public nonisolated enum SSHCredentials: Sendable {
}
public nonisolated enum SSHResolvedKeyCredential: Sendable {
case software(SSHSecret)
case software(SSHSecret, passphrase: SSHSecret? = nil)
case secureEnclave(SecureEnclaveKeyCredential)
public var hasSavedPassphrase: Bool {
if case .software(_, let passphrase) = self { return passphrase != nil && passphrase?.isConsumed == false }
return false
}
public func clearSecrets() {
if case .software(let privateKey, let passphrase) = self {
privateKey.clear()
passphrase?.clear()
}
}
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
case .software(let privateKey):
.privateKey(
case .software(let privateKey, let savedPassphrase):
let hasSavedPassphrase = self.hasSavedPassphrase
if let passphraseOverride, savedPassphrase !== passphraseOverride { savedPassphrase?.clear() }
return .privateKey(
privateKey: privateKey,
publicKey: certificate.isEmpty
? (publicKey.isEmpty ? nil : publicKey)
: certificate,
passphrase: passphraseOverride,
requiresPassphrase: requiresPassphrase
passphrase: passphraseOverride ?? savedPassphrase,
requiresPassphrase: requiresPassphrase && !hasSavedPassphrase
)
case .secureEnclave(let credential):
.secureEnclave(
return .secureEnclave(
credential: credential, publicKey: publicKey,
requiresUserPresence: requiresUserPresence
)
@@ -356,13 +370,23 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
}
nonisolated extension StoredSSHKeyCredential {
public func resolvedCredential() -> SSHResolvedKeyCredential {
switch self {
case .software(let secret):
.software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init))
case .secureEnclave(let key): .secureEnclave(key)
}
}
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
case .software(let secret):
SSHResolvedKeyCredential.software(SSHSecret(secret.privateKey)).sshCredential(
SSHResolvedKeyCredential.software(
SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init)
).sshCredential(
publicKey: publicKey, certificate: certificate,
passphraseOverride: passphraseOverride,
requiresPassphrase: requiresPassphrase,
@@ -131,13 +131,14 @@ nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticatio
}
public func firstHostRequiringPassphrase(
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?,
requiresEntry: (SSHKeyRef) -> Bool = { $0.requiresPassphraseEntry }
) -> Host? {
route.first { host in
guard let submission = self[host.id], !submission.hasPassphrase,
let keyID = submission.keyID
else { return false }
return key(keyID)?.requiresPassphraseEntry == true
return key(keyID).map(requiresEntry) == true
}
}
@@ -26,9 +26,11 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
private struct Storage {
var pointer: UnsafeMutableRawPointer
var count: Int
var didZeroize: (@Sendable (UnsafeRawBufferPointer) -> Void)?
func clearAndDeallocate() {
_ = memset_s(pointer, count + 1, 0, count + 1)
didZeroize?(UnsafeRawBufferPointer(start: pointer, count: count + 1))
pointer.deallocate()
}
}
@@ -63,6 +65,8 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
deinit { clear() }
public var byteCount: Int { lock.withLock { storage?.count ?? 0 } }
public var isConsumed: Bool {
lock.withLock { storage == nil }
}
@@ -101,6 +105,12 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
storage.clearAndDeallocate()
}
/// Internal test seam: observes the allocation only after memset_s and
/// before deallocation, never after the pointer has become invalid.
func observeZeroization(_ observer: @escaping @Sendable (UnsafeRawBufferPointer) -> Void) {
lock.withLock { storage?.didZeroize = observer }
}
private func take() throws -> Storage {
try lock.withLock {
guard let value = storage else { throw Consumed() }
@@ -3,8 +3,10 @@ import TermindDomain
public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable {
public var privateKey: String
public init(privateKey: String) {
public var passphrase: String?
public init(privateKey: String, passphrase: String? = nil) {
self.privateKey = privateKey
self.passphrase = passphrase
}
}
@@ -23,10 +25,13 @@ public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving {
func keyCredential(
for account: String, storage: KeyStorage
) throws -> StoredSSHKeyCredential?
func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws
func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws
func setResolvedKeyCredential(_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage)
throws
func removePassword(_ account: String, storage: PasswordStorage) throws
func removeKeyCredential(_ account: String, storage: KeyStorage) throws
}
@@ -40,6 +45,39 @@ nonisolated extension SecretStoring {
try keyCredential(for: account, storage: storage) != nil
}
/// Default for in-memory/test stores. The Keychain adapter overrides this
/// to update its JSON envelope entirely through wipeable byte buffers.
public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws {
defer { passphrase?.clear() }
guard case .software(var secret)? = try keyCredential(for: account, storage: storage) else {
throw TermindSSHError.missingCredential
}
secret.passphrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
}
try setKeyCredential(.software(secret), for: account, storage: storage)
}
/// Test/in-memory fallback; persistent adapters must write the wipeable
/// credential directly, without first materializing String secrets.
public func setResolvedKeyCredential(
_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage
) throws {
defer { credential.clearSecrets() }
switch credential {
case .software(let key, let passphrase):
let text = try key.withUnsafeCStringAndConsume { bytes, count in
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
}
let phrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in
String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
}
try setKeyCredential(.software(.init(privateKey: text, passphrase: phrase)), for: account, storage: storage)
case .secureEnclave(let key):
try setKeyCredential(.secureEnclave(key), for: account, storage: storage)
}
}
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
try string(for: account, storage: storage).map(SSHSecret.init)
}
@@ -48,7 +86,8 @@ nonisolated extension SecretStoring {
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential? {
switch try keyCredential(for: account, storage: storage) {
case .software(let secret): .software(SSHSecret(secret.privateKey))
case .software(let secret):
.software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init))
case .secureEnclave(let credential): .secureEnclave(credential)
case nil: nil
}
@@ -111,9 +111,13 @@ public struct SSHAuthenticationForm: View {
if prompt.keyboardInteractive == nil, let keyboard {
keyboard.bridge(
canSubmit: canContinue,
submit: { commit(save: !needsPassphrase) }, cancel: cancel)
submit: { commit(save: prompt.allowsSaving) }, cancel: cancel)
}
}
.onDisappear {
password = ""
passphrase = ""
}
.onAppear {
if identityID != nil, selectedIdentity == nil { identityID = nil }
loadPassword(for: selectedIdentity ?? initialCustomIdentity)
@@ -226,43 +230,29 @@ public struct SSHAuthenticationForm: View {
@ViewBuilder
private var actions: some View {
if needsPassphrase {
ViewThatFits(in: .horizontal) {
HStack {
cancelButton
Spacer()
passphraseContinueButton
}
VStack(spacing: 10) {
cancelButton.frame(maxWidth: .infinity, alignment: .leading)
passphraseContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
}
ViewThatFits(in: .horizontal) {
HStack {
cancelButton
Spacer()
continueButton
if prompt.allowsSaving { saveAndContinueButton }
}
} else {
ViewThatFits(in: .horizontal) {
VStack(spacing: 10) {
HStack {
cancelButton
Spacer()
continueButton
if prompt.allowsSaving { saveAndContinueButton }
}
VStack(spacing: 10) {
#if os(iOS)
HStack {
cancelButton
Spacer()
continueButton
if prompt.allowsSaving { saveAndContinueButton }
}
#if os(iOS)
HStack {
Spacer()
if prompt.allowsSaving { saveAndContinueButton }
}
#else
if prompt.allowsSaving {
saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
}
#endif
}
#else
if prompt.allowsSaving {
saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
}
#endif
}
}
}
@@ -272,7 +262,16 @@ public struct SSHAuthenticationForm: View {
.keyboardShortcut(.cancelAction)
}
@ViewBuilder
private var continueButton: some View {
if prompt.allowsSaving {
baseContinueButton
} else {
baseContinueButton.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
}
}
private var baseContinueButton: some View {
Button {
commit(save: false)
} label: {
@@ -281,17 +280,11 @@ public struct SSHAuthenticationForm: View {
.disabled(!canContinue)
}
private var passphraseContinueButton: some View {
continueButton
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
private var saveAndContinueButton: some View {
Button {
commit(save: true)
} label: {
ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark")
ConnectionActionLabel(title: AppStrings.Keychain.saveAndContinue, symbol: "checkmark")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
@@ -309,7 +302,8 @@ public struct SSHAuthenticationForm: View {
}
private func commit(save: Bool) {
let submittedPassword = save ? password : (password.isEmpty ? nil : password)
let save = save && prompt.allowsSaving
let submittedPassword = needsPassphrase ? nil : (save ? password : (password.isEmpty ? nil : password))
let submittedPassphrase = passphrase.isEmpty ? nil : passphrase
password = ""
passphrase = ""
@@ -2,6 +2,9 @@ import Foundation
extension AppStrings {
public nonisolated enum Keychain {
public static var savePassphrase: LocalizedStringResource { "Save Passphrase" }
public static var saveAndContinue: LocalizedStringResource { "Save and Continue" }
public static var addAndConnectToAHostBeforeExportingAKeyToIt: LocalizedStringResource {
"Add and connect to a host before exporting a key to it."
}
@@ -206,6 +209,8 @@ extension AppStrings {
"No secret"
}
public static var passphraseSaved: LocalizedStringResource { "Passphrase Saved" }
public static var passphraseRequired: LocalizedStringResource {
"Passphrase Required"
}
@@ -311,19 +311,15 @@ public final class AgentHostResolver: Sendable {
case .cancelled, .keyboardInteractive:
throw AgentToolError.cancelled
case .submit(let answer):
if case .passphrase = requirement.mode,
var retained = submissions[node.id]
{
// A protected key may have been chosen beside a password
// in the preceding Identity step. The passphrase card only
// returns the newly-entered secret, so merge it into that
// staged submission instead of replacing (and silently
// dropping) the password and other choices.
guard let passphrase = answer.passphrase, !passphrase.isEmpty else {
throw TermindSSHError.keyPassphraseRequired
if case .passphrase = requirement.mode {
do {
submissions[node.id] = try store.passphraseSubmission(
for: request.journeyPrompt, existing: submissions[node.id],
passphrase: answer.passphrase, save: answer.save)
} catch {
failure = error.localizedDescription
continue
}
retained.passphrase = passphrase
submissions[node.id] = retained
} else {
submissions[node.id] = try submission(from: answer, for: node)
}
@@ -414,7 +410,9 @@ public final class AgentHostResolver: Sendable {
guard forcedHostID == nil || forcedHostID == host.id else { return nil }
if let submission = submissions[host.id] {
if let keyID = submission.keyID,
store.key(keyID)?.requiresPassphraseEntry == true,
store.key(keyID).map({
(forcedHostID == host.id && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0)
}) == true,
submission.passphrase?.isEmpty != false
{
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
@@ -430,6 +428,9 @@ public final class AgentHostResolver: Sendable {
guard let identity = store.identity(for: host) else {
return AuthenticationRequirement(mode: .identity)
}
if forcedHostID == host.id, let keyID = identity.keyID, store.key(keyID)?.hasPassphrase == true {
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
}
let resolution = store.sshCredentials(for: identity)
if let keyID = resolution.requiredPassphraseKeyID {
return AuthenticationRequirement(mode: .passphrase(keyID: keyID))
@@ -282,10 +282,16 @@ public final class PortForwardingController {
}
return
}
if prompt.requiresPassphrase, var staged = context.submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
context.submissions[host.id] = staged
if prompt.requiresPassphrase {
do {
context.submissions[host.id] = try hosts.passphraseSubmission(
for: prompt, existing: context.submissions[host.id], passphrase: passphrase, save: save)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
context.setPrompt(failed)
return
}
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
@@ -374,7 +380,7 @@ public final class PortForwardingController {
return
}
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
in: context.route, key: hosts.key
in: context.route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
) {
requestAuthentication(
context,
@@ -549,7 +555,7 @@ public final class PortForwardingController {
let keyID = staged?.keyID ?? identity?.keyID
let passphrase =
staged?.requiresPasswordReentry != true
&& keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true
&& keyID.flatMap(hosts.key)?.hasPassphrase == true
&& staged?.passphrase == nil
context.setPrompt(
SSHAuthenticationPromptResolver.host(
@@ -90,11 +90,12 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
{
try validate(account, prefix: "termind.key.")
guard
let data = try data(
var data = try data(
for: account, service: service(for: storage),
synchronizable: storage == .iCloud
)
else { return nil }
defer { SecretMemory.clear(&data) }
switch storage {
case .thisDevice, .iCloud:
return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data))
@@ -125,7 +126,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
defer { SecretMemory.clear(&data) }
switch storage {
case .thisDevice, .iCloud:
return .software(try Self.decodePrivateKeySecret(from: &data))
return try Self.decodeKeySecrets(from: &data)
case .secureEnclave:
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
@@ -159,90 +160,222 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
)
}
/// Strictly decodes the legacy single-field JSON envelope without creating
/// an immutable Swift `String` containing the private key. Corrupted shapes
/// fail closed instead of being searched for a matching field substring.
static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret {
defer { SecretMemory.clear(&data) }
var index = data.startIndex
func skipWhitespace() {
while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) {
index = data.index(after: index)
}
public func setResolvedKeyCredential(
_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage
) throws {
defer { credential.clearSecrets() }
try validate(account, prefix: "termind.key.")
var encoded: Data
switch (credential, storage) {
case (.software(let key, let passphrase), .thisDevice), (.software(let key, let passphrase), .iCloud):
encoded = try Self.encodeKeySecrets(privateKey: key, passphrase: passphrase)
case (.secureEnclave(let key), .secureEnclave): encoded = key.dataRepresentation
default: throw KeychainSecretError.unexpectedValue
}
func consume(_ expected: UInt8) throws {
guard index < data.endIndex, data[index] == expected else {
throw KeychainSecretError.unexpectedValue
}
index = data.index(after: index)
}
skipWhitespace()
try consume(0x7B)
skipWhitespace()
for expected in Array("\"privateKey\"".utf8) {
try consume(expected)
}
skipWhitespace()
try consume(0x3A)
skipWhitespace()
try consume(0x22)
var result = Data()
defer { SecretMemory.clear(&result) }
while index < data.endIndex {
let byte = data[index]
index = data.index(after: index)
if byte == 0x22 {
skipWhitespace()
try consume(0x7D)
skipWhitespace()
guard index == data.endIndex else {
throw KeychainSecretError.unexpectedValue
}
return SSHSecret(consumingUTF8: &result)
}
guard byte == 0x5C else {
guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue }
result.append(byte)
continue
}
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
let escape = data[index]
index = data.index(after: index)
switch escape {
case 0x22, 0x5C, 0x2F: result.append(escape)
case 0x62: result.append(0x08)
case 0x66: result.append(0x0C)
case 0x6E: result.append(0x0A)
case 0x72: result.append(0x0D)
case 0x74: result.append(0x09)
case 0x75:
var value: UInt32 = 0
for _ in 0..<4 {
guard index < data.endIndex, let digit = hex(data[index]) else {
throw KeychainSecretError.unexpectedValue
}
value = value * 16 + UInt32(digit)
index = data.index(after: index)
}
guard let scalar = Unicode.Scalar(value), !(0xD800...0xDFFF).contains(value) else {
throw KeychainSecretError.unexpectedValue
}
result.append(contentsOf: String(scalar).utf8)
default:
throw KeychainSecretError.unexpectedValue
}
}
throw KeychainSecretError.unexpectedValue
defer { SecretMemory.clear(&encoded) }
try setData(
encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud,
accessible: storage == .iCloud
? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
)
}
private static func hex(_ byte: UInt8) -> UInt8? {
switch byte {
case 0x30...0x39: byte - 0x30
case 0x41...0x46: byte - 0x41 + 10
case 0x61...0x66: byte - 0x61 + 10
default: nil
public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws {
defer { passphrase?.clear() }
guard storage != .secureEnclave,
let credential = try sshKeyCredential(for: account, storage: storage),
case .software(let privateKey, _) = credential
else { throw KeychainSecretError.unexpectedValue }
defer { credential.clearSecrets() }
var encoded = try Self.encodeKeySecrets(privateKey: privateKey, passphrase: passphrase)
defer { SecretMemory.clear(&encoded) }
try setData(
encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud,
accessible: storage == .iCloud
? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly
)
}
/// The editor's legacy envelope and the runtime reader share the same wire
/// format. Secret values never become immutable Strings in the runtime path.
static func encodeKeySecrets(privateKey: SSHSecret, passphrase: SSHSecret?) throws -> Data {
defer {
privateKey.clear()
passphrase?.clear()
}
var output = Data()
var succeeded = false
defer { if !succeeded { SecretMemory.clear(&output) } }
func append(_ secret: SSHSecret) throws {
output.append(0x22)
try secret.withUnsafeCStringAndConsume { pointer, count in
for byte in UnsafeRawBufferPointer(start: pointer, count: count) {
switch byte {
case 0x22, 0x5C: output.append(contentsOf: [0x5C, byte])
case 0..<0x20:
let digits = Array("0123456789abcdef".utf8)
output.append(contentsOf: [
0x5C, 0x75, 0x30, 0x30, digits[Int(byte >> 4)], digits[Int(byte & 15)],
])
default: output.append(byte)
}
}
}
output.append(0x22)
}
// Reserve an upper bound before writing any plaintext, avoiding Data
// reallocation while appending escaped secret bytes.
output.reserveCapacity(64 + 6 * (privateKey.byteCount + (passphrase?.byteCount ?? 0)))
output.append(contentsOf: "{\"privateKey\":".utf8)
try append(privateKey)
if let passphrase {
output.append(contentsOf: ",\"passphrase\":".utf8)
try append(passphrase)
}
output.append(0x7D)
succeeded = true
return output
}
static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret {
let credential = try decodeKeySecrets(from: &data)
guard case .software(let key, let passphrase) = credential else {
throw KeychainSecretError.unexpectedValue
}
passphrase?.clear()
return key
}
static func decodeKeySecrets(
from data: inout Data, observeSecret: ((SSHSecret) -> Void)? = nil
) throws -> SSHResolvedKeyCredential {
defer { SecretMemory.clear(&data) }
var index = data.startIndex
var privateKey: SSHSecret?
var passphrase: SSHSecret?
var succeeded = false
defer {
if !succeeded {
privateKey?.clear()
passphrase?.clear()
}
}
func skipWhitespace() {
while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) { index += 1 }
}
func consume(_ expected: UInt8) throws {
guard index < data.endIndex, data[index] == expected else { throw KeychainSecretError.unexpectedValue }
index += 1
}
func hexUnit() throws -> UInt32 {
var value: UInt32 = 0
for _ in 0..<4 {
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
let byte = data[index]
let digit: UInt8
switch byte {
case 0x30...0x39: digit = byte - 0x30
case 0x41...0x46: digit = byte - 0x41 + 10
case 0x61...0x66: digit = byte - 0x61 + 10
default: throw KeychainSecretError.unexpectedValue
}
value = value * 16 + UInt32(digit)
index += 1
}
return value
}
func readString() throws -> SSHSecret {
try consume(0x22)
var bytes = Data()
bytes.reserveCapacity(data.count)
defer { SecretMemory.clear(&bytes) }
while index < data.endIndex {
let byte = data[index]
index += 1
if byte == 0x22 {
func validateUTF8() throws {
var decoder = Unicode.UTF8()
var iterator = bytes.makeIterator()
while true {
switch decoder.decode(&iterator) {
case .scalarValue: continue
case .emptyInput: return
case .error: throw KeychainSecretError.unexpectedValue
}
}
}
try validateUTF8()
let secret = SSHSecret(consumingUTF8: &bytes)
observeSecret?(secret)
return secret
}
guard byte == 0x5C else {
guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue }
bytes.append(byte)
continue
}
guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue }
let escape = data[index]
index += 1
switch escape {
case 0x22, 0x5C, 0x2F: bytes.append(escape)
case 0x62: bytes.append(0x08)
case 0x66: bytes.append(0x0C)
case 0x6E: bytes.append(0x0A)
case 0x72: bytes.append(0x0D)
case 0x74: bytes.append(0x09)
case 0x75:
var scalar = try hexUnit()
if (0xD800...0xDBFF).contains(scalar) {
try consume(0x5C)
try consume(0x75)
let low = try hexUnit()
guard (0xDC00...0xDFFF).contains(low) else { throw KeychainSecretError.unexpectedValue }
scalar = 0x10000 + ((scalar - 0xD800) << 10) + low - 0xDC00
}
guard let value = Unicode.Scalar(scalar) else { throw KeychainSecretError.unexpectedValue }
Unicode.UTF8.encode(value) { bytes.append($0) }
default: throw KeychainSecretError.unexpectedValue
}
}
throw KeychainSecretError.unexpectedValue
}
skipWhitespace()
try consume(0x7B)
var sawPassphrase = false
while true {
skipWhitespace()
let name = try readString()
defer { name.clear() }
skipWhitespace()
try consume(0x3A)
skipWhitespace()
if name.matches("privateKey"), privateKey == nil {
privateKey = try readString()
} else if name.matches("passphrase"), !sawPassphrase {
sawPassphrase = true
if index < data.endIndex, data[index] == 0x6E {
for byte in Array("null".utf8) { try consume(byte) }
} else {
passphrase = try readString()
if passphrase?.byteCount == 0 {
passphrase?.clear()
passphrase = nil
}
}
} else {
throw KeychainSecretError.unexpectedValue
}
skipWhitespace()
if index < data.endIndex, data[index] == 0x2C {
index += 1
continue
}
try consume(0x7D)
skipWhitespace()
guard index == data.endIndex, let privateKey else { throw KeychainSecretError.unexpectedValue }
succeeded = true
return .software(privateKey, passphrase: passphrase)
}
}
@@ -29,8 +29,17 @@ final class MobileKeyEditorModel: Identifiable {
var cipher = SSHKeyMaterial.Cipher.aes256
var rounds = 100
var confirmation = ""
var privateKey = ""
var privateKey = "" {
didSet {
if privateKey != originalPrivateKey, oldValue == originalPrivateKey, !originalPassphrase.isEmpty {
passphrase = ""
}
}
}
var passphrase = ""
var savePassphrase = true
private var originalPassphrase = ""
private var originalSavePassphrase = true
var certificate: String
var requiresUserPresence = false
private(set) var isLoadingPrivateKey: Bool
@@ -49,6 +58,8 @@ final class MobileKeyEditorModel: Identifiable {
self.mode = mode
self.credentials = credentials
original = key
savePassphrase = mode != .edit
originalSavePassphrase = mode != .edit
label = key?.label ?? ""
storage = key?.storage ?? defaultStorage
certificate = key?.certificate ?? ""
@@ -76,7 +87,9 @@ final class MobileKeyEditorModel: Identifiable {
var isDirty: Bool {
label != (original?.label ?? "") || storage != (original?.storage ?? .iCloud)
|| certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey || !passphrase.isEmpty
|| certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey
|| passphrase != originalPassphrase
|| savePassphrase != originalSavePassphrase
|| algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none)
|| curveBits != 256 || rsaBits != 3072 || cipher != .aes256 || rounds != 100 || !confirmation.isEmpty
}
@@ -154,6 +167,10 @@ final class MobileKeyEditorModel: Identifiable {
}
privateKey = secret.privateKey
originalPrivateKey = secret.privateKey
passphrase = secret.passphrase ?? ""
originalPassphrase = passphrase
savePassphrase = !passphrase.isEmpty
originalSavePassphrase = savePassphrase
didLoadPrivateKey = true
} catch {
privateKeyLoadError = error.localizedDescription
@@ -259,7 +276,9 @@ final class MobileKeyEditorModel: Identifiable {
key.keySize = material.keySize
key.hasPassphrase = enteredPassphrase != nil || (keepsOriginalPrivateKey && key.hasPassphrase)
try credentials.upsert(
key, credential: .software(SSHPrivateKeySecret(privateKey: input))
key,
credential: .software(
SSHPrivateKeySecret(privateKey: input, passphrase: savePassphrase ? enteredPassphrase : nil))
)
clearSensitiveDrafts()
return
@@ -304,17 +323,20 @@ final class MobileKeyEditorModel: Identifiable {
certificate: normalizedCertificate, fingerprint: material.fingerprint,
credentialRef: "termind.key.\(UUID())", storage: storage, keySize: material.keySize,
hasPassphrase: passphrase != nil, createdAt: .now, colorTag: colorTag)
secret = .software(SSHPrivateKeySecret(privateKey: material.privateKey))
secret = .software(
SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil))
}
try credentials.upsert(key, credential: secret)
clearSensitiveDrafts()
}
private func clearSensitiveDrafts() {
func clearSensitiveDrafts() {
didLoadPrivateKey = mode != .edit
privateKey = ""
originalPrivateKey = ""
passphrase = ""
confirmation = ""
originalPassphrase = ""
inspectedMaterial = nil
}
}
@@ -193,7 +193,10 @@ private struct MobileKeyCredentialCard: View {
@ViewBuilder private var primaryChips: some View {
MobileCardChip(text: keyDisplayName)
if key.hasPassphrase {
MobileCardChip(title: AppStrings.Keychain.passphraseRequired, symbol: "lock.fill")
MobileCardChip(
title: credentials.hasSavedPassphrase(for: key)
? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired,
symbol: "lock.fill")
}
}
@@ -53,6 +53,8 @@ struct MobileKeyEditor: View {
RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $model.confirmation)
.multilineTextAlignment(.trailing).disabled(model.passphrase.isEmpty)
}
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(model.passphrase.isEmpty)
if !model.passphrase.isEmpty {
Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $model.cipher) {
ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) }
@@ -106,6 +108,8 @@ struct MobileKeyEditor: View {
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
.multilineTextAlignment(.leading)
.disabled(!model.canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(!model.canEditPrivateKey || model.passphrase.isEmpty)
}
}
} else {
@@ -159,6 +163,7 @@ struct MobileKeyEditor: View {
} message: {
Text(fileError ?? "")
}
.onDisappear { model.clearSensitiveDrafts() }
.task { await model.loadPrivateKey() }
.task(id: model.validationToken) { await model.derivePublicMaterial() }
}
+10 -5
View File
@@ -387,10 +387,15 @@ final class MobileWorkspace: Identifiable {
connect()
return
}
if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
submissions[host.id] = staged
if let prompt = authenticationPrompt, prompt.requiresPassphrase {
do {
submissions[host.id] = try hosts.passphraseSubmission(
for: prompt, existing: submissions[host.id], passphrase: passphrase, save: save)
} catch {
connectionFailure = error.localizedDescription
authenticationPrompt?.failureMessage = error.localizedDescription
return
}
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
@@ -684,7 +689,7 @@ final class MobileWorkspace: Identifiable {
return
}
if let passphraseHost = submissions.firstHostRequiringPassphrase(
in: route, key: hosts.key
in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry
) {
authenticationRequired = true
authenticationMode = .localIdentity
@@ -0,0 +1,73 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobilePassphraseFormLayoutTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0])
func passphraseActionsFitCompactAndRegularWidths(language: String, width: Double) throws {
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true)
let controller = UIHostingController(
rootView: SSHAuthenticationForm(
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel()
).environment(\.locale, Locale(identifier: language)).frame(width: width))
let size = controller.sizeThatFits(in: CGSize(width: width, height: 1000))
#expect(abs(size.width - width) < 1)
#expect(size.height > 100 && size.height < 500)
controller.view.frame = CGRect(origin: .zero, size: size)
controller.view.layoutIfNeeded()
}
@Test(
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"),
arguments: ["en", "zh-Hans"], [false, true])
func rendersPassphraseActionsInANativeWindow(language: String, showKeys: Bool) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true)
let app = try MobileAppModel(inMemory: true)
for saved in [false, true] {
let key = SSHKeyRef(
label: saved ? "Saved Key" : "Unsaved Key", publicKey: "ssh-ed25519 fixture",
credentialRef: "termind.key.\(UUID())", hasPassphrase: true)
try app.credentials.upsert(
key, credential: .software(.init(privateKey: "fixture", passphrase: saved ? "fixture-phrase" : nil)))
}
let controller = UIHostingController(
rootView: Group {
if showKeys {
NavigationStack { MobileCredentialsView(app: app) }
} else {
SSHAuthenticationForm(
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: app.library
).frame(width: 320).padding()
}
}.environment(\.locale, Locale(identifier: language)))
let window = UIWindow(windowScene: scene)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(300))
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
#expect(data.count > 10000)
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
"termind-passphrase-ios-\(language)-\(showKeys ? "keys" : "form").png")
try data.write(to: url)
print("Passphrase preview: \(url.path)")
}
}
+66
View File
@@ -379,6 +379,72 @@ struct MobileVaultFeatureTests {
app.removeScene(sceneID)
}
@Test func savedPassphraseDefaultsAndEditingPreserveOrRemoveTheSavedValue() async throws {
let app = try MobileAppModel(inMemory: true)
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
#expect(generator.savePassphrase)
generator.passphrase = "test-only-passphrase"
generator.confirmation = generator.passphrase
try await generator.save()
let original = try #require(app.credentials.keys.first)
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
await editor.loadPrivateKey()
#expect(editor.savePassphrase && !editor.isDirty)
#expect(editor.passphrase == "test-only-passphrase")
editor.label = "Renamed"
try await editor.save()
guard case .software(let saved)? = try app.credentials.keyCredential(for: original) else {
Issue.record("Expected saved key")
return
}
#expect(saved.passphrase == "test-only-passphrase")
let updated = try #require(app.credentials.key(original.id))
let remover = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
await remover.loadPrivateKey()
remover.savePassphrase = false
#expect(remover.isDirty)
try await remover.save()
guard case .software(let removed)? = try app.credentials.keyCredential(for: updated) else {
Issue.record("Expected saved key")
return
}
#expect(removed.passphrase == nil)
#expect(removed.privateKey == saved.privateKey)
#expect(app.credentials.key(original.id)?.hasPassphrase == true)
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
#expect(importer.savePassphrase)
importer.privateKey = saved.privateKey
importer.passphrase = "test-only-passphrase"
importer.savePassphrase = false
try await importer.save()
let imported = try #require(app.credentials.keys.first { $0.id != original.id })
guard case .software(let importedSecret)? = try app.credentials.keyCredential(for: imported) else {
Issue.record("Expected imported key")
return
}
#expect(importedSecret.passphrase == nil)
}
@Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws {
let app = try MobileAppModel(inMemory: true)
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
generator.passphrase = "old-passphrase"
generator.confirmation = generator.passphrase
try await generator.save()
let key = try #require(app.credentials.keys.first)
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
await editor.loadPrivateKey()
editor.privateKey = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: nil).privateKey
#expect(editor.passphrase.isEmpty)
try await editor.save()
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
Issue.record("Expected replacement key")
return
}
#expect(secret.passphrase == nil)
#expect(app.credentials.key(key.id)?.hasPassphrase == false)
}
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
let app = try MobileAppModel(inMemory: true)
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
+56 -21
View File
@@ -437,7 +437,7 @@ struct KeychainImportDetail: View {
private func save(
name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String,
passphrase: String?, certificate: String, material: SSHKeyMaterial
passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial
) async throws -> SSHKeyRef {
let key = SSHKeyRef(
label: name,
@@ -450,7 +450,7 @@ struct KeychainImportDetail: View {
try app.credentials.upsert(
key,
credential: .software(
SSHPrivateKeySecret(privateKey: privateKey)
SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil)
)
)
window.selectVaultItem(.key(key.id), on: .keychain)
@@ -468,11 +468,11 @@ private struct SoftwareKeyEditor: View {
let title: LocalizedStringResource
let symbol: String
let close: () -> Void
let loadPrivateKey: (() throws -> String)?
let loadPrivateKey: (() throws -> SSHPrivateKeySecret)?
let save:
(
_ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String,
_ passphrase: String?, _ certificate: String, _ material: SSHKeyMaterial
_ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial
) async throws -> SSHKeyRef
let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)?
let delete: (() throws -> Void)?
@@ -483,6 +483,9 @@ private struct SoftwareKeyEditor: View {
@State private var privateKey = ""
@State private var originalPrivateKey = ""
@State private var passphrase = ""
@State private var originalPassphrase = ""
@State private var savePassphrase = true
@State private var originalSavePassphrase = true
@State private var certificate: String
@State private var material: SSHKeyMaterial?
@State private var isLoading: Bool
@@ -502,12 +505,12 @@ private struct SoftwareKeyEditor: View {
existing: SSHKeyRef? = nil, title: LocalizedStringResource,
symbol: String, close: @escaping () -> Void,
defaultStorage: KeyStorage = .iCloud,
loadPrivateKey: (() throws -> String)? = nil,
loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil,
saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil,
delete: (() throws -> Void)? = nil,
save:
@escaping (
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
String, TagColor, KeyStorage, String, String?, Bool, String, SSHKeyMaterial
) async throws -> SSHKeyRef
) {
self.existing = existing
@@ -579,6 +582,8 @@ private struct SoftwareKeyEditor: View {
prompt: Text(AppStrings.Common.optional)
)
.disabled(!canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase)
.disabled(!canEditPrivateKey || passphrase.isEmpty)
} header: {
fileSectionHeader(AppStrings.Keychain.privateKey, canChoose: canEditPrivateKey) {
chooseFile(for: .privateKey)
@@ -661,7 +666,18 @@ private struct SoftwareKeyEditor: View {
await commitChanges()
}
}
.onChange(of: privateKey) { old, value in
if value != originalPrivateKey, old == originalPrivateKey, !originalPassphrase.isEmpty {
passphrase = ""
}
}
.onDisappear {
privateKey = ""
originalPrivateKey = ""
passphrase = ""
originalPassphrase = ""
material = nil
isLoading = loadPrivateKey != nil
guard let editingToken else { return }
editingSession.unregister(editingToken)
}
@@ -735,7 +751,9 @@ private struct SoftwareKeyEditor: View {
private var isDirty: Bool {
guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !certificate.isEmpty }
return name != existing.label || colorTag != existing.colorTag || storage != existing.storage
|| privateKey != originalPrivateKey || !passphrase.isEmpty || certificate != existing.certificate
|| privateKey != originalPrivateKey || passphrase != originalPassphrase
|| savePassphrase != originalSavePassphrase
|| certificate != existing.certificate
}
private func cancelEditing() {
@@ -786,7 +804,7 @@ private struct SoftwareKeyEditor: View {
}
_ = try await save(
name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, storage,
privateKey, passphrase, normalizedCertificate, inspected
privateKey, passphrase, savePassphrase, normalizedCertificate, inspected
)
isWorking = false
return true
@@ -845,8 +863,13 @@ private struct SoftwareKeyEditor: View {
private func load() {
guard isLoading else { return }
do {
privateKey = try loadPrivateKey?() ?? ""
let secret = try loadPrivateKey?()
privateKey = secret?.privateKey ?? ""
originalPrivateKey = privateKey
passphrase = secret?.passphrase ?? ""
originalPassphrase = passphrase
savePassphrase = !passphrase.isEmpty
originalSavePassphrase = savePassphrase
} catch {
errorMessage = error.localizedDescription
canEditPrivateKey = false
@@ -942,6 +965,7 @@ struct KeychainGenerateDetail: View {
private func generate(
label: String, colorTag: TagColor, generation: SSHKeyMaterial.Generation, passphrase: String?,
savePassphrase: Bool,
cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage,
requiresUserPresence: Bool
) async throws -> SSHKeyRef {
@@ -979,7 +1003,7 @@ struct KeychainGenerateDetail: View {
try app.credentials.upsert(
key,
credential: .software(
SSHPrivateKeySecret(privateKey: material.privateKey)
SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil)
)
)
}
@@ -999,7 +1023,7 @@ private struct GenerateKeySheet: View {
let generate:
(
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int,
KeyStorage, Bool
) async throws -> SSHKeyRef
let close: () -> Void
@@ -1011,6 +1035,7 @@ private struct GenerateKeySheet: View {
@State private var curveBits = 256
@State private var rsaBits = 3072
@State private var passphrase = ""
@State private var savePassphrase = true
@State private var confirmation = ""
@State private var cipher = SSHKeyMaterial.Cipher.aes256
@State private var rounds = 100
@@ -1028,7 +1053,7 @@ private struct GenerateKeySheet: View {
defaultStorage: KeyStorage = .iCloud,
generate:
@escaping (
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int,
KeyStorage, Bool
) async throws -> SSHKeyRef,
close: @escaping () -> Void
@@ -1097,6 +1122,8 @@ private struct GenerateKeySheet: View {
prompt: Text(AppStrings.Common.optional))
RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $confirmation)
.disabled(passphrase.isEmpty)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase)
.disabled(passphrase.isEmpty)
if !passphrase.isEmpty {
Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $cipher) {
ForEach(SSHKeyMaterial.Cipher.allCases) { cipher in
@@ -1136,6 +1163,10 @@ private struct GenerateKeySheet: View {
}
.padding()
}
.onDisappear {
passphrase = ""
confirmation = ""
}
}
@MainActor
@@ -1146,9 +1177,11 @@ private struct GenerateKeySheet: View {
_ = try await generate(
label.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, generation,
storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase,
cipher, rounds, storage,
savePassphrase, cipher, rounds, storage,
storage == .secureEnclave && requiresUserPresence
)
passphrase = ""
confirmation = ""
close()
} catch {
errorMessage = error.localizedDescription
@@ -1196,10 +1229,10 @@ struct KeychainEditDetail: View {
},
delete: { try delete(key) },
save: {
name, colorTag, storage, privateKey, passphrase, certificate, material in
name, colorTag, storage, privateKey, passphrase, savePassphrase, certificate, material in
try await saveSoftwareKey(
key, name: name, colorTag: colorTag, storage: storage,
privateKey: privateKey, passphrase: passphrase,
privateKey: privateKey, passphrase: passphrase, savePassphrase: savePassphrase,
certificate: certificate, material: material
)
}
@@ -1210,13 +1243,13 @@ struct KeychainEditDetail: View {
}
}
private func loadPrivateKey(_ key: SSHKeyRef) throws -> String {
private func loadPrivateKey(_ key: SSHKeyRef) throws -> SSHPrivateKeySecret {
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
throw TermindSSHError.library(
String(localized: AppStrings.Keychain.thePrivateKeyIsUnavailableOnThisMac)
)
}
return secret.privateKey
return secret
}
private func delete(_ key: SSHKeyRef) throws {
@@ -1226,7 +1259,7 @@ struct KeychainEditDetail: View {
private func saveSoftwareKey(
_ original: SSHKeyRef, name: String, colorTag: TagColor, storage: KeyStorage,
privateKey: String, passphrase: String?, certificate: String,
privateKey: String, passphrase: String?, savePassphrase: Bool, certificate: String,
material: SSHKeyMaterial
) async throws -> SSHKeyRef {
var updated = original
@@ -1238,12 +1271,12 @@ struct KeychainEditDetail: View {
updated.certificate = certificate
updated.fingerprint = material.fingerprint
updated.keySize = material.keySize
let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original))
let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)).privateKey
updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase)
try app.credentials.upsert(
updated,
credential: .software(
SSHPrivateKeySecret(privateKey: privateKey)
SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil)
)
)
window.selectVaultItem(.key(updated.id), on: .keychain)
@@ -1821,7 +1854,9 @@ private struct KeyCard: View {
CardChip(text: keyDisplayName)
if key.hasPassphrase {
CardChip(
text: String(localized: AppStrings.Keychain.passphraseRequired),
text: String(
localized: app.credentials.hasSavedPassphrase(for: key)
? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired),
symbol: "lock.fill"
)
}
+14 -11
View File
@@ -353,7 +353,7 @@ final class SFTPChannelModel: Identifiable {
return
}
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
in: route, key: store.key
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
) {
requestAuthentication(
host: passphraseHost, store: store,
@@ -732,15 +732,18 @@ final class SFTPChannelModel: Identifiable {
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)
if prompt.requiresPassphrase {
do {
suppliedAuthentications[host.id] = try store.passphraseSubmission(
for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save)
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
pendingAuthentication = failed
}
return
}
@@ -1171,7 +1174,7 @@ final class SFTPChannelModel: Identifiable {
let needsPassphrase =
requiresPassphrase
?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true)
&& selectedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true)
pendingAuthentication = SSHAuthenticationPromptResolver.host(
host, using: store, submission: submission, username: username,
selectedKeyID: selectedKeyID,
+15 -13
View File
@@ -420,7 +420,7 @@ final class SSHSessionModel: Identifiable {
return
}
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
in: route, key: store.key
in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry
), let submission = suppliedAuthentications[passphraseHost.id] {
requestAuthentication(
for: passphraseHost, username: submission.username,
@@ -645,8 +645,7 @@ final class SSHSessionModel: Identifiable {
let retryNeedsPassphrase =
submitted?.requiresPasswordReentry != true
&& submitted?.hasPassphrase != true
&& authenticationKeyID.flatMap { store.key($0) }?
.requiresPassphraseEntry == true
&& authenticationKeyID.flatMap { store.key($0) }?.hasPassphrase == true
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
requestAuthentication(
for: authenticationHost,
@@ -703,15 +702,18 @@ final class SSHSessionModel: Identifiable {
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)
if prompt.requiresPassphrase {
do {
suppliedAuthentications[host.id] = try store.passphraseSubmission(
for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save)
pendingAuthentication = nil
connectionJourney.resume()
connect(using: store)
} catch {
var failed = prompt
failed.failureMessage = error.localizedDescription
pendingAuthentication = failed
}
return
}
@@ -745,7 +747,7 @@ final class SSHSessionModel: Identifiable {
for: host, username: trimmedUsername,
selectedKeyID: submittedKeyID, failureMessage: error.localizedDescription,
requiresPassphrase:
submittedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true
submittedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true
)
return
}
+7 -3
View File
@@ -255,8 +255,8 @@ struct AgentHostAuthenticationTests {
#expect(configuration.username == "deploy")
}
@Test
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection() async throws {
@Test(arguments: [false, true])
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection(save: Bool) async throws {
let keychain = MemoryHostSecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let key = SSHKeyRef(
@@ -291,7 +291,7 @@ struct AgentHostAuthenticationTests {
return .submit(
AgentSSHAuthenticationAnswer(
username: request.username, passphrase: "one-use",
keyID: key.id, save: false
keyID: key.id, save: save
))
}
}
@@ -323,6 +323,10 @@ struct AgentHostAuthenticationTests {
return
}
#expect(stored.privateKey == "encrypted")
#expect(stored.passphrase == (save ? "one-use" : nil))
#expect(store.requiresPassphraseEntry(for: key) == !save)
#expect(store.host(host.id)?.identityID == nil)
configuration.clearSecrets()
#expect(store.key(key.id)?.requiresPassphraseEntry == true)
}
+102
View File
@@ -0,0 +1,102 @@
import Foundation
import Testing
@testable import TermindApplication
@testable import TermindRemoteInfrastructure
struct KeyPassphraseEnvelopeTests {
@Test(arguments: [
#"{"privateKey":"encrypted","passphrase":"密语😀\n\"\\"}"#,
#"{"passphrase":"\u5bc6\u8bed\ud83d\ude00\n\"\\","privateKey":"encrypted"}"#,
])
func oldEnvelopeDecodesWithoutImmutableSecretStrings(_ json: String) throws {
var data = Data(json.utf8)
let observation = EnvelopeZeroization()
let credential = try KeychainSecretStore.decodeKeySecrets(from: &data)
guard case .software(let key, let passphrase) = credential else {
Issue.record("Expected software key")
return
}
let phrase = try #require(passphrase)
for secret in [key, phrase] { secret.observeZeroization { observation.record($0) } }
#expect(key.matches("encrypted"))
#expect(phrase.matches("密语😀\n\"\\"))
#expect(data.isEmpty)
var encoded = try KeychainSecretStore.encodeKeySecrets(privateKey: key, passphrase: phrase)
let editor = try JSONDecoder().decode(SSHPrivateKeySecret.self, from: encoded)
#expect(editor.privateKey == "encrypted" && editor.passphrase == "密语😀\n\"\\")
SecretMemory.clear(&encoded)
#expect(observation.count == 2 && observation.allZero)
}
@Test(arguments: [
#"{"privateKey":"key"}"#, #"{"privateKey":"key","passphrase":null}"#, #"{"passphrase":"","privateKey":"key"}"#,
])
func envelopesWithoutSavedPassphraseRemainCompatible(_ json: String) throws {
var data = Data(json.utf8)
let credential = try KeychainSecretStore.decodeKeySecrets(from: &data)
defer { credential.clearSecrets() }
#expect(!credential.hasSavedPassphrase)
#expect(data.isEmpty)
}
@Test(arguments: [
#"{"privateKey":"key","privateKey":"duplicate"}"#,
#"{"privateKey":"key","passphrase":"first","passphrase":"second"}"#,
#"{"privateKey":"key","passphrase":12}"#,
#"{"privateKey":"key","passphrase":"\ud800"}"#,
#"{"privateKey":"key","passphrase":"saved"} trailing"#,
#"{"passphrase":"saved"}"#,
#"{"privateKey":"key","extra":"saved"}"#,
#"{"privateKey":"key","passphrase":"saved",}"#,
])
func malformedEnvelopeZeroesEveryDecodedBuffer(_ json: String) {
let observation = EnvelopeZeroization()
var data = Data(json.utf8)
#expect(throws: KeychainSecretError.self) {
_ = try KeychainSecretStore.decodeKeySecrets(from: &data) { secret in
secret.observeZeroization { observation.record($0) }
}
}
#expect(data.isEmpty)
#expect(observation.count > 0 && observation.allZero)
}
@Test func keychainSourceBytesAreZeroedOnParseFailure() {
let observation = EnvelopeZeroization()
let bytes = Array(
(#"{"privateKey":"key","passphrase":"saved"} invalid"# + String(repeating: " ", count: 4096)).utf8)
let pointer = UnsafeMutableRawPointer.allocate(byteCount: bytes.count, alignment: 1)
bytes.withUnsafeBytes { pointer.copyMemory(from: $0.baseAddress!, byteCount: bytes.count) }
var data = Data(
bytesNoCopy: pointer, count: bytes.count,
deallocator: .custom { pointer, count in
observation.record(UnsafeRawBufferPointer(start: pointer, count: count))
pointer.deallocate()
})
#expect(throws: KeychainSecretError.self) { _ = try KeychainSecretStore.decodeKeySecrets(from: &data) }
#expect(observation.count == 1 && observation.allZero)
}
@Test func serializationFailureStillZeroesTheUnusedPassphrase() {
let observation = EnvelopeZeroization()
let consumed = SSHSecret("key")
consumed.clear()
let phrase = SSHSecret("saved")
phrase.observeZeroization { observation.record($0) }
#expect(throws: SSHSecret.Consumed.self) {
_ = try KeychainSecretStore.encodeKeySecrets(privateKey: consumed, passphrase: phrase)
}
#expect(observation.count == 1 && observation.allZero)
}
}
private nonisolated final class EnvelopeZeroization: @unchecked Sendable {
private let lock = NSLock()
private var observations: [Bool] = []
var count: Int { lock.withLock { observations.count } }
var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } }
func record(_ bytes: UnsafeRawBufferPointer) {
lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) }
}
}
+34
View File
@@ -0,0 +1,34 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@Suite @MainActor
struct PassphraseFormLayoutTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func passphraseActionsFitTheAuthenticationCard(language: String, allowsSaving: Bool) throws {
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
let prompt = SSHAuthenticationPrompt(
host: host, username: "deploy", requiresPassphrase: true, allowsSaving: allowsSaving)
let view = NSHostingView(
rootView: SSHAuthenticationForm(
prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel()
).environment(\.locale, Locale(identifier: language)).frame(width: 420))
let size = view.fittingSize
#expect(size.width == 420)
#expect(size.height > 100 && size.height < 400)
view.frame = NSRect(origin: .zero, size: size)
view.layoutSubtreeIfNeeded()
if ProcessInfo.processInfo.environment["TERMIND_PASSPHRASE_PREVIEW"] == "1", allowsSaving {
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
"termind-passphrase-macos-\(language).png")
try png.write(to: url)
print("Passphrase preview: \(url.path)")
}
}
}
+4 -2
View File
@@ -164,7 +164,8 @@ struct PortForwardingJourneyTests {
#expect(credentials.controller.state(for: credentialRule.id) == .awaitingAuthentication)
}
@Test func encryptedKeyPassphraseResumesPreparationInTheJourney() async throws {
@Test(arguments: [false, true])
func encryptedKeyPassphraseResumesPreparationInTheJourney(save: Bool) async throws {
let material = try SSHKeyMaterial.generate(.ed25519, comment: "forwarding", passphrase: "test-passphrase")
let key = SSHKeyRef(
label: "Encrypted", algorithm: .ed25519, publicKey: material.publicKey,
@@ -185,9 +186,10 @@ struct PortForwardingJourneyTests {
#expect(prompt.requiresPassphrase)
controller.submitAuthentication(
for: rule.id, promptID: prompt.id, identityID: identity.id, username: "deploy",
password: nil, passphrase: "test-passphrase", keyID: key.id, save: false)
password: nil, passphrase: "test-passphrase", keyID: key.id, save: save)
try await wait { controller.state(for: rule.id) == .running(activeConnections: 0) }
#expect(!context.journey.plainTextLog.contains("test-passphrase"))
#expect(library.requiresPassphraseEntry(for: key) == !save)
}
@Test func savedCredentialsContinueTheExplicitOpeningWithoutStartingOtherRules() async throws {