Harden one-shot SSH secret handling

This commit is contained in:
Kaede Akino committed 2026-09-20 20:33:18 +08:00
1 parent cb106ba15d
commit 3db8cdbf85
26 files changed
+524 -261

No files matched your search

@@ -141,7 +141,7 @@ struct SSHAuthenticationSubmissionTests {
}
@Test
func takingAttemptSecretsLeavesOnlyReentryMarkersInSessionState() {
func takingAttemptSecretsMovesPlaintextIntoOneShotOwners() throws {
let hostID = UUID()
var retained = [
hostID: SSHAuthenticationSubmission(
@@ -150,14 +150,22 @@ struct SSHAuthenticationSubmissionTests {
)
]
let attempt = retained.takeAuthenticationSecrets()
var attempt = retained.takeAuthenticationSecrets()
#expect(attempt[hostID]?.password == "one-use-password")
#expect(attempt[hostID]?.passphrase == "one-use-passphrase")
#expect(attempt[hostID]?.password == nil)
#expect(attempt[hostID]?.passphrase == nil)
#expect(retained[hostID]?.password == nil)
#expect(retained[hostID]?.passphrase == nil)
#expect(retained[hostID]?.requiresPasswordReentry == true)
#expect(retained[hostID]?.requiresPassphraseReentry == true)
let password = try #require(attempt[hostID]?.passwordForConnection())
let passphrase = try #require(attempt[hostID]?.passphraseForConnection())
#expect(password.matches("one-use-password"))
#expect(passphrase.matches("one-use-passphrase"))
attempt[hostID]?.clearSecrets()
#expect(password.isConsumed)
#expect(passphrase.isConsumed)
}
}
@@ -187,10 +187,13 @@ struct SSHInteractionPolicyTests {
}
@Test func runtimeSecretCanBeConsumedOnlyOnceAcrossAllReferences() throws {
let secret = SSHSecret("one-use")
var source = Data("one-use".utf8)
let secret = SSHSecret(consumingUTF8: &source)
let alias = secret
var observedCount = 0
#expect(source.isEmpty)
try secret.withUnsafeCStringAndConsume { buffer, count in
observedCount = count
#expect(buffer[0] == CChar(bitPattern: Character("o").asciiValue!))
@@ -44,9 +44,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
) throws -> SSHConnectionRoute
func transportProxy(for host: Host) throws -> SSHTransportProxy?
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
func passwordCredential(for identity: Identity) throws -> String?
func passwordSecret(for identity: Identity) throws -> SSHSecret?
func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential?
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
func recordIdentityCredentialAccess(_ identityIDs: Set<Identity.ID>, at date: Date)
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
@@ -262,14 +260,6 @@ extension RemoteSessionHostStore {
).enforcingInteractionPolicy()
}
public func connectionPasswordCredential(for identity: Identity) throws -> String? {
let password = try passwordCredential(for: identity)
if password != nil {
recordIdentityCredentialAccess([identity.id], at: Date())
}
return password
}
public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? {
let password = try passwordSecret(for: identity)
if password != nil {
@@ -278,16 +268,6 @@ extension RemoteSessionHostStore {
return password
}
public func connectionKeyCredential(
for key: SSHKeyRef, identityID: Identity.ID?
) throws -> StoredSSHKeyCredential? {
let credential = try keyCredential(for: key)
if credential != nil, let identityID {
recordIdentityCredentialAccess([identityID], at: Date())
}
return credential
}
public func connectionKeySecretCredential(
for key: SSHKeyRef, identityID: Identity.ID?
) throws -> SSHResolvedKeyCredential? {
@@ -29,7 +29,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
}
public var nodes: [Node]
public var secrets: any SecretStoring
public var secrets: any SSHRuntimeSecretResolving
public var credentialAccessHandler: SSHCredentialAccessHandler?
public func resolve(
@@ -112,14 +112,16 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
identityID: identity.id,
username: identity.username,
permitsNone: !identity.hasSecret,
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
hasEphemeralSecret: node.submission?.hasPassword == true
|| node.submission?.hasPassphrase == true
)
}
return SSHAuthenticationReference(
fallbackKeyID: node.submission?.keyID ?? node.key?.id,
username: node.submission?.username ?? "",
permitsNone: node.key == nil && node.submission?.password == nil,
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
permitsNone: node.key == nil && node.submission?.hasPassword != true,
hasEphemeralSecret: node.submission?.hasPassword == true
|| node.submission?.hasPassphrase == true
)
}
@@ -132,7 +134,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
var result: [SSHCredentials] = []
var retrievalFailure: (any Error)?
if let key = node.key {
if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false {
if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true {
throw TermindSSHError.keyPassphraseRequired
}
let stored: SSHResolvedKeyCredential?
@@ -149,14 +151,14 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
result.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: node.submission?.passphrase,
passphraseOverride: node.submission?.passphraseForConnection(),
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
))
}
}
try Task.checkCancellation()
if let password = node.submission?.password {
if let password = node.submission?.passwordForConnection() {
result.append(.password(password))
} else if node.submission == nil || node.submission?.identityID != nil,
let identity = node.identity, identity.usesPassword
@@ -215,7 +217,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
}
public init(
nodes: [Node], secrets: any SecretStoring,
nodes: [Node], secrets: any SSHRuntimeSecretResolving,
credentialAccessHandler: SSHCredentialAccessHandler? = nil
) {
self.nodes = nodes
@@ -39,8 +39,8 @@ public final class CredentialLibraryModel {
public func keyExportUnavailableReason(for host: Host) -> String? {
library.keyExportUnavailableReason(for: host)
}
public func sshConnectionConfiguration(for host: Host) throws -> SSHConnectionConfiguration {
try library.sshConnectionConfiguration(for: host)
public func prepareSSHConnection(for host: Host) throws -> SSHConnectionPreparation {
try library.prepareSSHConnection(for: host, submissions: [:])
}
public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws {
@@ -460,7 +460,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
credentials = resolution.credentials
}
let expectsCredential =
submissions[host.id].map { $0.keyID != nil || $0.password != nil }
submissions[host.id].map { $0.keyID != nil || $0.hasPassword }
?? (identity(for: host)?.hasSecret == true)
if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential }
let expectedHostKeys = try expectedHostKeys(
@@ -645,14 +645,16 @@ public final class HostLibraryModel: RemoteSessionHostStore {
identityID: resolvedIdentity.id,
username: resolvedIdentity.username,
permitsNone: !resolvedIdentity.hasSecret,
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
hasEphemeralSecret: submission?.hasPassword == true
|| submission?.hasPassphrase == true
)
}
return SSHAuthenticationReference(
fallbackKeyID: submission?.keyID,
username: submission?.username ?? "",
permitsNone: submission?.keyID == nil && submission?.password == nil,
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
permitsNone: submission?.keyID == nil && submission?.hasPassword != true,
hasEphemeralSecret: submission?.hasPassword == true
|| submission?.hasPassphrase == true
)
}
@@ -662,9 +664,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
) throws -> [SSHCredentials] {
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID) {
if key.requiresPassphraseEntry,
submission.passphrase?.isEmpty != false
{
if key.requiresPassphraseEntry, !submission.hasPassphrase {
throw TermindSSHError.keyPassphraseRequired
}
if let stored = try keySecretCredential(for: key) {
@@ -674,14 +674,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: submission.passphrase,
passphraseOverride: submission.passphraseForConnection(),
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
}
}
if let password = submission.password {
if let password = submission.passwordForConnection() {
credentials.append(.password(password))
} else if let identityID = submission.identityID,
let identity = identity(identityID), identity.usesPassword,
@@ -1233,7 +1233,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func passwordAvailability(for identity: Identity) -> SecretAvailability {
guard identity.usesPassword else { return .notConfigured }
do {
return try passwordCredential(for: identity) == nil ? .missing : .available
return try keychain.containsString(
for: identity.credentialRef, storage: identity.passwordStorage
) ? .available : .missing
} catch {
return .failed(error.localizedDescription)
}
@@ -1415,7 +1417,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
do {
return try keyCredential(for: key) == nil ? .missing : .available
return try keychain.containsKeyCredential(
for: key.credentialRef, storage: key.storage
) ? .available : .missing
} catch {
return .failed(error.localizedDescription)
}
@@ -669,23 +669,16 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
server.collectionState = .connecting
server.isConnecting = true
let route = hosts.route(for: host)
if let passwordHost = route.first(where: { routeHost in
submissions[server.id]?[routeHost.id]?.requiresPasswordReentry == true
}) {
if let passwordHost = submissions[server.id]?.firstHostRequiringPassword(in: route) {
throw TermindSSHError.routeHost(
hostID: passwordHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.missingCredential.localizedDescription
)
}
if let passphraseHost = route.first(where: { routeHost in
guard let submission = submissions[server.id]?[routeHost.id],
submission.passphrase == nil,
let keyID = submission.keyID,
let key = hosts.key(keyID)
else { return false }
return key.requiresPassphraseEntry
}) {
if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase(
in: route, key: hosts.key
) {
throw TermindSSHError.routeHost(
hostID: passphraseHost.id, authentication: true,
serverChallenge: false, hostKey: false,
@@ -44,6 +44,16 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable {
public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision
public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest
public nonisolated struct SSHAuthorizedKeyInstall: Hashable, Sendable {
public var publicKey: String
public var name: String
public init(publicKey: String, name: String) {
self.publicKey = publicKey
self.name = name
}
}
public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable {
case requireKnownHost
case allowChallenge
@@ -263,7 +273,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
case secureEnclave(SecureEnclaveKeyCredential)
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
@@ -273,7 +283,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
publicKey: certificate.isEmpty
? (publicKey.isEmpty ? nil : publicKey)
: certificate,
passphrase: passphraseOverride.map(SSHSecret.init),
passphrase: passphraseOverride,
requiresPassphrase: requiresPassphrase
)
case .secureEnclave(let credential):
@@ -287,7 +297,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable {
nonisolated extension StoredSSHKeyCredential {
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
@@ -13,7 +13,7 @@ public nonisolated enum SSHRouteEvent: Hashable, Sendable {
public typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void
public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
public nonisolated struct SSHAuthenticationSubmission: Sendable {
public var username: String
public var password: String?
public var passphrase: String?
@@ -21,6 +21,8 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
public var identityID: Identity.ID? = nil
public var requiresPasswordReentry = false
public var requiresPassphraseReentry = false
private var runtimePassword: SSHSecret?
private var runtimePassphrase: SSHSecret?
public init(
username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil,
identityID: Identity.ID? = nil
@@ -32,22 +34,72 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
self.identityID = identityID
}
public var hasPassword: Bool { password != nil || runtimePassword != nil }
public var hasPassphrase: Bool {
passphrase?.isEmpty == false || runtimePassphrase != nil
}
public func passwordForConnection() -> SSHSecret? {
runtimePassword ?? password.map(SSHSecret.init)
}
public func passphraseForConnection() -> SSHSecret? {
runtimePassphrase ?? passphrase.map(SSHSecret.init)
}
public mutating func clearSecrets() {
if password != nil { requiresPasswordReentry = true }
if passphrase != nil { requiresPassphraseReentry = true }
if hasPassword { requiresPasswordReentry = true }
if hasPassphrase { requiresPassphraseReentry = true }
password = nil
passphrase = nil
runtimePassword?.clear()
runtimePassword = nil
runtimePassphrase?.clear()
runtimePassphrase = nil
}
fileprivate mutating func prepareForConnectionAttempt() {
if let password {
runtimePassword = SSHSecret(password)
self.password = nil
}
if let passphrase {
if !passphrase.isEmpty {
runtimePassphrase = SSHSecret(passphrase)
}
self.passphrase = nil
}
}
}
nonisolated extension Dictionary where Value == SSHAuthenticationSubmission {
nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticationSubmission {
public func firstHostRequiringPassword(in route: [Host]) -> Host? {
route.first { self[$0.id]?.requiresPasswordReentry == true }
}
public func firstHostRequiringPassphrase(
in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?
) -> 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
}
}
/// Moves plaintext answers into one connection attempt while leaving only
/// non-secret selection metadata in the session's retry state.
public mutating func takeAuthenticationSecrets() -> Self {
let result = self
for key in keys {
self[key]?.clearSecrets()
var result: Self = [:]
for key in Array(keys) {
guard var retained = self[key] else { continue }
var attempt = retained
attempt.prepareForConnectionAttempt()
retained.clearSecrets()
self[key] = retained
result[key] = attempt
}
return result
}
+61 -40
View File
@@ -1,33 +1,64 @@
import Darwin
import Foundation
/// Centralized clearing for short-lived buffers that temporarily contain secrets.
public nonisolated enum SecretMemory {
public static func clear(_ bytes: inout Data) {
bytes.withUnsafeMutableBytes { raw in
guard let base = raw.baseAddress, raw.count > 0 else { return }
_ = memset_s(base, raw.count, 0, raw.count)
}
bytes.removeAll(keepingCapacity: false)
}
}
/// A one-shot UTF-8 secret used only while authenticating a connection.
///
/// Copies of an `SSHSecret` share one backing object. Consuming or clearing any
/// reference therefore invalidates every reference instead of creating another
/// plaintext lifetime through Swift value copying. The backing bytes are
/// overwritten with `memset_s` after use and again from `deinit` as a
/// defensive fallback.
/// reference therefore invalidates every reference. The plaintext lives in one
/// explicitly owned, non-copy-on-write allocation that is overwritten before it
/// is released.
public nonisolated final class SSHSecret: @unchecked Sendable {
public struct Consumed: Error, Sendable {
public init() {}
}
private let lock = NSLock()
private var storage: Data?
private struct Storage {
var pointer: UnsafeMutableRawPointer
var count: Int
public init(_ value: String) {
var bytes = Data(value.utf8)
bytes.append(0)
storage = bytes
func clearAndDeallocate() {
_ = memset_s(pointer, count + 1, 0, count + 1)
pointer.deallocate()
}
}
/// Transfers a freshly read UTF-8 buffer into wipeable ownership without
/// first materializing an immutable Swift `String`.
private let lock = NSLock()
private var storage: Storage?
public convenience init(_ value: String) {
var bytes = Data(value.utf8)
self.init(consumingUTF8: &bytes)
}
/// Copies a freshly read UTF-8 buffer into wipeable ownership, then clears
/// the source buffer before returning. The final allocation includes a NUL
/// terminator without asking `Data` to grow or reallocate its plaintext.
public init(consumingUTF8 bytes: inout Data) {
bytes.append(0)
storage = bytes
bytes = Data()
let count = bytes.count
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: count + 1,
alignment: MemoryLayout<UInt8>.alignment
)
if count > 0 {
bytes.withUnsafeBytes { raw in
guard let source = raw.baseAddress else { return }
pointer.copyMemory(from: source, byteCount: count)
}
}
pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self)
SecretMemory.clear(&bytes)
storage = Storage(pointer: pointer, count: count)
}
deinit { clear() }
@@ -37,56 +68,46 @@ public nonisolated final class SSHSecret: @unchecked Sendable {
}
/// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`.
/// The memory is wiped before this method returns or throws.
/// The allocation is wiped before this method returns or throws.
public func withUnsafeCStringAndConsume<Result>(
_ body: (UnsafePointer<CChar>, Int) throws -> Result
) throws -> Result {
var bytes = try take()
defer { Self.wipe(&bytes) }
return try bytes.withUnsafeBytes { raw in
guard let base = raw.baseAddress?.assumingMemoryBound(to: CChar.self) else {
throw Consumed()
}
return try body(base, max(0, raw.count - 1))
}
let storage = try take()
defer { storage.clearAndDeallocate() }
return try body(
UnsafePointer(storage.pointer.assumingMemoryBound(to: CChar.self)),
storage.count
)
}
/// Test and diagnostic support without extending the secret's lifetime.
/// Production authentication should use `withUnsafeCStringAndConsume`.
/// Test support without extending the secret's lifetime.
public func matches(_ value: String) -> Bool {
let expected = Array(value.utf8)
return lock.withLock {
guard let storage, storage.count == expected.count + 1 else { return false }
return storage.dropLast().elementsEqual(expected)
guard let storage, storage.count == expected.count else { return false }
let bytes = UnsafeRawBufferPointer(start: storage.pointer, count: storage.count)
return bytes.elementsEqual(expected)
}
}
public func clear() {
guard
var bytes = lock.withLock({ () -> Data? in
let storage = lock.withLock({ () -> Storage? in
let value = storage
storage = nil
self.storage = nil
return value
})
else { return }
Self.wipe(&bytes)
storage.clearAndDeallocate()
}
private func take() throws -> Data {
private func take() throws -> Storage {
try lock.withLock {
guard let value = storage else { throw Consumed() }
storage = nil
return value
}
}
private static func wipe(_ bytes: inout Data) {
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
guard let base = raw.baseAddress, raw.count > 0 else { return }
_ = memset_s(base, raw.count, 0, raw.count)
}
bytes.removeAll(keepingCapacity: false)
}
}
public nonisolated func == (lhs: SSHSecret, rhs: String) -> Bool {
@@ -9,16 +9,21 @@ public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable {
}
public nonisolated protocol SecretStoring: Sendable {
func string(for account: String, storage: PasswordStorage) throws -> String?
public nonisolated protocol SSHRuntimeSecretResolving: Sendable {
func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret?
func sshKeyCredential(
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential?
}
public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving {
func string(for account: String, storage: PasswordStorage) throws -> String?
func containsString(for account: String, storage: PasswordStorage) throws -> Bool
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws
func keyCredential(
for account: String, storage: KeyStorage
) throws -> StoredSSHKeyCredential?
func sshKeyCredential(
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential?
func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws
@@ -27,6 +32,14 @@ public nonisolated protocol SecretStoring: Sendable {
}
nonisolated extension SecretStoring {
public func containsString(for account: String, storage: PasswordStorage) throws -> Bool {
try string(for: account, storage: storage) != nil
}
public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool {
try keyCredential(for: account, storage: storage) != nil
}
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
try string(for: account, storage: storage).map(SSHSecret.init)
}
@@ -248,13 +248,15 @@ public final class AgentHostResolver: Sendable {
}
var retainedSubmissions = submissions
let attemptSubmissions = retainedSubmissions.takeAuthenticationSecrets()
return InteractiveResolution(
configuration: try store.sshConnectionConfiguration(
for: destination, submissions: submissions,
for: destination, submissions: attemptSubmissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
submissions: submissions, temporaryHostKeys: temporaryHostKeys
submissions: retainedSubmissions, temporaryHostKeys: temporaryHostKeys
)
}
@@ -327,9 +327,7 @@ public final class PortForwardingController {
host: host, route: hosts.route(for: host), purpose: .portForwarding,
username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username)
update(context, state: .preparing)
if let passwordHost = context.route.first(where: { routeHost in
context.submissions[routeHost.id]?.requiresPasswordReentry == true
}) {
if let passwordHost = context.submissions.firstHostRequiringPassword(in: context.route) {
requestAuthentication(
context,
error: .routeHost(
@@ -341,14 +339,9 @@ public final class PortForwardingController {
context.attemptID = nil
return
}
if let passphraseHost = context.route.first(where: { routeHost in
guard let submission = context.submissions[routeHost.id],
submission.passphrase == nil,
let keyID = submission.keyID,
let key = hosts.key(keyID)
else { return false }
return key.requiresPassphraseEntry
}) {
if let passphraseHost = context.submissions.firstHostRequiringPassphrase(
in: context.route, key: hosts.key
) {
requestAuthentication(
context,
error: .routeHost(
@@ -111,8 +111,18 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable {
_ publicKey: String, named keyName: String,
using configuration: SSHConnectionConfiguration
) async throws {
try await SSHPTYChannel.installAuthorizedKey(
publicKey, named: keyName,
try await installAuthorizedKeys(
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
using: configuration
)
}
public func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall],
using configuration: SSHConnectionConfiguration
) async throws {
try await SSHPTYChannel.installAuthorizedKeys(
keys,
using: SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive),
logger: logger, maxSessionsPolicy: maxSessionsPolicy
)
@@ -32,8 +32,20 @@ public nonisolated enum SSHConnectionOpener {
logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
try await SSHPTYChannelWorker.installAuthorizedKey(
publicKey, named: keyName, using: request, logger: logger,
try await installAuthorizedKeys(
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
using: request, logger: logger,
maxSessionsPolicy: maxSessionsPolicy
)
}
public static func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest,
logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
try await SSHPTYChannelWorker.installAuthorizedKeys(
keys, using: request, logger: logger,
maxSessionsPolicy: maxSessionsPolicy)
}
@@ -594,8 +594,20 @@ public actor SSHPTYChannel {
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
try await SSHConnectionOpener.installAuthorizedKey(
publicKey, named: keyName, using: request, logger: logger,
try await installAuthorizedKeys(
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
using: request, logger: logger,
maxSessionsPolicy: maxSessionsPolicy
)
}
public static func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
try await SSHConnectionOpener.installAuthorizedKeys(
keys, using: request, logger: logger,
maxSessionsPolicy: maxSessionsPolicy)
}
@@ -883,25 +895,51 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName)
let session = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy)
try await installAuthorizedKeys(
[SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)],
using: request, logger: logger,
maxSessionsPolicy: maxSessionsPolicy
)
}
public static func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall],
using request: SSHConnectionRequest,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
) async throws {
let connection = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy)
try await installAuthorizedKeys(keys, using: request, connection: connection)
}
static func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall],
using request: SSHConnectionRequest,
connection: any SSHConnectionProtocol
) async throws {
guard !keys.isEmpty else { return }
let inputs = try keys.map {
try authorizedKeyInstallInput(publicKey: $0.publicKey, keyName: $0.name)
}
do {
try await session.connect(using: request)
try await connection.connect(using: request)
let command =
#"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"#
let result = try await session.execute(
command, workingDirectory: nil, stdin: input, timeout: 20,
outputLimit: 64 * 1024, onOutput: nil)
guard result.exitCode == 0 else {
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
throw TermindSSHError.library(
detail.isEmpty
? "The remote authorized_keys update exited with status \(result.exitCode)."
: "The remote authorized_keys update failed: \(detail)")
for input in inputs {
let result = try await connection.execute(
command, workingDirectory: nil, stdin: input, timeout: 20,
outputLimit: 64 * 1024, onOutput: nil)
guard result.exitCode == 0 else {
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
throw TermindSSHError.library(
detail.isEmpty
? "The remote authorized_keys update exited with status \(result.exitCode)."
: "The remote authorized_keys update failed: \(detail)")
}
}
await session.disconnect()
await connection.disconnect()
} catch {
await session.disconnect()
await connection.disconnect()
throw error
}
}
@@ -90,11 +90,11 @@ public nonisolated enum SSHSocketConnector {
var request = Data(
"CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n".utf8
)
defer { wipe(&request) }
defer { SecretMemory.clear(&request) }
if let password = proxy.password, !proxy.username.isEmpty {
var plaintext = Data(proxy.username.utf8)
plaintext.append(UInt8(ascii: ":"))
defer { wipe(&plaintext) }
defer { SecretMemory.clear(&plaintext) }
try password.withUnsafeCStringAndConsume { buffer, count in
plaintext.append(
buffer.withMemoryRebound(to: UInt8.self, capacity: count) {
@@ -103,7 +103,7 @@ public nonisolated enum SSHSocketConnector {
)
}
var token = plaintext.base64EncodedData()
defer { wipe(&token) }
defer { SecretMemory.clear(&token) }
request.append(Data("Proxy-Authorization: Basic ".utf8))
request.append(token)
request.append(Data("\r\n".utf8))
@@ -152,7 +152,7 @@ public nonisolated enum SSHSocketConnector {
throw TermindSSHError.proxy("The SOCKS5 username or password is too long.")
}
var auth = Data([0x01, UInt8(username.count)])
defer { wipe(&auth) }
defer { SecretMemory.clear(&auth) }
auth.append(username)
auth.append(UInt8(count))
auth.append(
@@ -206,14 +206,6 @@ public nonisolated enum SSHSocketConnector {
_ = try readExactly(socket, count: addressLength + 2)
}
private static func wipe(_ data: inout Data) {
data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
guard let base = raw.baseAddress, raw.count > 0 else { return }
_ = memset_s(base, raw.count, 0, raw.count)
}
data.removeAll(keepingCapacity: false)
}
private static func openTCP(host: String, port: Int, timeout: TimeInterval) throws -> Int32 {
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
@@ -51,6 +51,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
return value
}
public func containsString(for account: String, storage: PasswordStorage) throws -> Bool {
try validateStringReference(account)
return try contains(
account, service: Self.passwordService,
synchronizable: storage == .iCloud
)
}
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
try validateStringReference(account)
guard
@@ -66,7 +74,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
try validateStringReference(account)
var data = value?.data(using: .utf8)
defer {
if data != nil { Self.wipe(&data!) }
if data != nil { SecretMemory.clear(&data!) }
}
try setData(
data, for: account, service: Self.passwordService,
@@ -96,6 +104,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
}
}
public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool {
try validate(account, prefix: "termind.key.")
return try contains(
account, service: service(for: storage),
synchronizable: storage == .iCloud
)
}
public func sshKeyCredential(
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential? {
@@ -106,11 +122,10 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
synchronizable: storage == .iCloud
)
else { return nil }
defer { SecretMemory.clear(&data) }
switch storage {
case .thisDevice, .iCloud:
defer { Self.wipe(&data) }
var privateKey = try Self.decodePrivateKeyBytes(from: data)
return .software(SSHSecret(consumingUTF8: &privateKey))
return .software(try Self.decodePrivateKeySecret(from: &data))
case .secureEnclave:
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
@@ -133,7 +148,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
throw KeychainSecretError.unexpectedValue
}
defer {
if data != nil { Self.wipe(&data!) }
if data != nil { SecretMemory.clear(&data!) }
}
try setData(
data, for: account, service: service(for: storage),
@@ -144,45 +159,51 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
)
}
private static func wipe(_ data: inout Data) {
data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
guard let base = raw.baseAddress, raw.count > 0 else { return }
_ = memset_s(base, raw.count, 0, raw.count)
}
data.removeAll(keepingCapacity: false)
}
/// Decodes the synthesized `SSHPrivateKeySecret` JSON directly into a
/// mutable byte buffer so connection setup never materializes another
/// immutable Swift `String` containing the private key.
private static func decodePrivateKeyBytes(from data: Data) throws -> Data {
let key = Data("\"privateKey\"".utf8)
guard let keyRange = data.range(of: key) else {
throw KeychainSecretError.unexpectedValue
}
var index = keyRange.upperBound
/// 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)
}
}
skipWhitespace()
guard index < data.endIndex, data[index] == 0x3A else {
throw KeychainSecretError.unexpectedValue
func consume(_ expected: UInt8) throws {
guard index < data.endIndex, data[index] == expected else {
throw KeychainSecretError.unexpectedValue
}
index = data.index(after: index)
}
index = data.index(after: index)
skipWhitespace()
guard index < data.endIndex, data[index] == 0x22 else {
throw KeychainSecretError.unexpectedValue
try consume(0x7B)
skipWhitespace()
for expected in Array("\"privateKey\"".utf8) {
try consume(expected)
}
index = data.index(after: index)
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 { return result }
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
}
@@ -260,6 +281,18 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
return data
}
private func contains(_ account: String, service: String, synchronizable: Bool) throws -> Bool {
guard !account.isEmpty else { return false }
var query = baseQuery(account, service: service, synchronizable: synchronizable)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnAttributes as String] = true
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return false }
guard status == errSecSuccess else { throw KeychainSecretError.status(status) }
return true
}
private func setData(
_ value: Data?, for account: String, service: String,
synchronizable: Bool, accessible: CFString
+9 -4
View File
@@ -94,10 +94,13 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
socket/libssh2 handshake-to-auth attempt, never a discovery connection. Generations reject retired callbacks,
credential results and timers.
- Every resolved Password, software Private Key, Key Passphrase and proxy Password is one-shot per physical connection
attempt. Runtime copies share a wipeable byte buffer; the transport clears it after the authentication call and every
success, failure and cancellation boundary clears it again. Retry/session/Agent/Metrics/transfer state retains only
non-secret Identity/Key provenance. Saved material is reread from Keychain for another physical connection; temporary
Passwords and every Passphrase must be entered again. UI authentication fields clear synchronously on submission.
attempt. Runtime copies share one explicitly owned, non-copy-on-write allocation; source `Data`, proxy frames and the
allocation are overwritten before release. The transport clears it after the authentication call and every success,
failure and cancellation boundary clears it again. UI submissions move plaintext into that one-shot owner before
leaving retry state; retry/session/Agent/Metrics/transfer state retains only reentry flags and non-secret Identity/Key
provenance. Saved material is reread from Keychain for another physical connection; temporary Passwords and every
Passphrase must be entered again. UI authentication fields clear synchronously on submission. Availability checks query
Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed.
- Desktop SSH/SFTP submission uses `RemoteSessionHostStore`: explicit password wins; custom input never borrows an
unselected Identity password. Only explicit input changes selected credentials; custom input reuses the Host Identity.
Identity/secret writes precede separate Host association. Keep temporary credentials, validation/passphrase ordering,
@@ -120,6 +123,8 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
- Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret
reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/
process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply.
- Exporting several authorized Keys resolves credentials immediately before work, authenticates once per Host and opens
one exec Channel per Key on that connection. Local validation completes before the remote account is changed.
- Welcome parses bounded SSH grammar without shell/OpenSSH config execution. Exact aliases supply metadata; overrides
create session route snapshots, never replaced with saved endpoints. Routes cap at eight Hosts and reject cycles.
Connect saves nothing. Explicit Save copies changed records/prerequisites in one Vault transaction, rolling back new
+18
View File
@@ -239,3 +239,21 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc
`"channels_only"` raw value, so synced settings need no migration.
- **Verification:** TFM and generic iOS Simulator builds passed. Sixty-six focused connection/Channel/SFTP tests passed;
changed-file strict formatting and Chinese localization checks passed.
### 2026-09-20 — one-shot SSH secret ownership and connection attempts (complete)
- **Evidence:** runtime credentials were represented by copy-on-write `Data` after leaving Keychain, so appending a NUL
could abandon an uncleared allocation. Temporary form answers were copied as `String` values into attempt dictionaries;
five feature owners repeated password/passphrase reentry routing. Authorized-Key export resolved every selected Host's
credentials eagerly and reused one one-shot configuration across several physical connections.
- **Action:** give `SSHSecret` one explicitly owned allocation; move submitted plaintext into that owner before an attempt;
centralize route reentry selection; narrow connection-time secret resolution; query Keychain existence without reading
values; fail closed on malformed legacy private-Key envelopes; authenticate once per Host for multi-Key export.
- **Result:** source `Data`, proxy frames and one-shot allocations are cleared through one `SecretMemory` boundary. Retry
state retains only selection metadata and reentry flags, while attempt copies share invalidating secret owners. The
session host port no longer exposes its legacy plaintext credential reads. Export preparations contain references only,
resolve immediately before work and open one connection with sequential exec Channels for all selected Keys. This is a
safety/ownership refactor; no production-line reduction is claimed.
- **Verification:** macOS and generic iOS Simulator builds passed. Fifty-three focused authentication, Agent and
authorized-Key tests passed; all 381 Framework tests passed. The default macOS run finished with 1,792 passed, 64
opt-in skips and no failures. Changed-file strict formatting, diff checks and Chinese localization passed.
+4 -11
View File
@@ -559,9 +559,7 @@ final class MobileWorkspace: Identifiable {
purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
username: hosts.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection)
if let passwordHost = route.first(where: { routeHost in
submissions[routeHost.id]?.requiresPasswordReentry == true
}) {
if let passwordHost = submissions.firstHostRequiringPassword(in: route) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passwordHost.id
@@ -573,14 +571,9 @@ final class MobileWorkspace: Identifiable {
busy = false
return
}
if let passphraseHost = route.first(where: { routeHost in
guard let submission = submissions[routeHost.id],
submission.passphrase == nil,
let keyID = submission.keyID,
let key = hosts.key(keyID)
else { return false }
return key.requiresPassphraseEntry
}) {
if let passphraseHost = submissions.firstHostRequiringPassphrase(
in: route, key: hosts.key
) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passphraseHost.id
@@ -85,17 +85,9 @@ extension TransferLibraryModel: RemoteSessionHostStore {
func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
library.sshCredentials(for: identity)
}
func passwordCredential(for identity: Identity) throws -> String? {
try library.passwordCredential(for: identity)
}
func passwordSecret(for identity: Identity) throws -> SSHSecret? {
try library.passwordSecret(for: identity)
}
func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? {
try library.keyCredential(for: key)
}
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try library.keySecretCredential(for: key)
}
+11 -10
View File
@@ -1389,15 +1389,15 @@ struct ExportKeyToHostsSheet: View {
}
guard !targets.isEmpty else { return }
var configurations: [(Host.ID, SSHConnectionConfiguration)] = []
var preparations: [(Host.ID, SSHConnectionPreparation)] = []
for host in targets {
if let reason = app.credentials.keyExportUnavailableReason(for: host) {
states[host.id] = .failed(reason)
continue
}
do {
configurations.append(
(host.id, try app.credentials.sshConnectionConfiguration(for: host))
preparations.append(
(host.id, try app.credentials.prepareSSHConnection(for: host))
)
states[host.id] = .running
} catch {
@@ -1405,18 +1405,19 @@ struct ExportKeyToHostsSheet: View {
}
}
let exportKeys = keys.map { (name: $0.label, publicKey: $0.publicKey) }
let exportKeys = keys.map {
SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label)
}
let connectionCoordinator = app.connections
Task {
await withTaskGroup(of: (Host.ID, String?).self) { group in
for (id, configuration) in configurations {
for (id, preparation) in preparations {
group.addTask {
do {
for key in exportKeys {
try await connectionCoordinator.installAuthorizedKey(
key.publicKey, named: key.name, using: configuration
)
}
let configuration = try await preparation.resolve()
try await connectionCoordinator.installAuthorizedKeys(
exportKeys, using: configuration
)
return (id, nil)
} catch {
return (id, error.localizedDescription)
+11 -18
View File
@@ -314,23 +314,16 @@ final class SFTPChannelModel: Identifiable {
username: stagedAuthentication?.username ?? store.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection
)
if let passwordHost = route.first(where: { routeHost in
suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true
}) {
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route) {
requestAuthentication(
host: passwordHost, store: store,
requiresPassphrase: false
)
return
}
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
}) {
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
in: route, key: store.key
) {
requestAuthentication(
host: passphraseHost, store: store,
requiresPassphrase: true, preserveSubmission: true
@@ -366,12 +359,12 @@ final class SFTPChannelModel: Identifiable {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: suppliedAuthentication.passphrase,
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
))
}
if let password = suppliedAuthentication.password {
if let password = suppliedAuthentication.passwordForConnection() {
credentials.append(.password(password))
} else if let identityID = suppliedAuthentication.identityID,
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
@@ -398,9 +391,9 @@ final class SFTPChannelModel: Identifiable {
? suppliedAuthentication.keyID : nil,
username: suppliedAuthentication.username,
permitsNone: suppliedAuthentication.keyID == nil
&& suppliedAuthentication.password == nil,
hasEphemeralSecret: suppliedAuthentication.password != nil
|| suppliedAuthentication.passphrase != nil
&& !suppliedAuthentication.hasPassword,
hasEphemeralSecret: suppliedAuthentication.hasPassword
|| suppliedAuthentication.hasPassphrase
)
)
} else {
@@ -489,10 +482,10 @@ final class SFTPChannelModel: Identifiable {
let submitted = self.suppliedAuthentications[authenticationHost.id]
let retryNeedsPassphrase =
submitted?.requiresPasswordReentry != true
&& submitted?.passphrase == nil
&& submitted?.hasPassphrase != true
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
self.requestAuthentication(
host: authenticationHost, store: store,
failure: error.localizedDescription,
+17 -22
View File
@@ -489,23 +489,18 @@ final class SSHSessionModel: Identifiable {
host: host, route: route, purpose: .sshSession, username: username,
mode: reconnecting ? .reconnection : .connection
)
if let passwordHost = route.first(where: { routeHost in
suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true
}), let submission = suppliedAuthentications[passwordHost.id] {
if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route),
let submission = suppliedAuthentications[passwordHost.id]
{
requestAuthentication(
for: passwordHost, username: submission.username,
selectedKeyID: submission.keyID, requiresPassphrase: false
)
return
}
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] {
if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase(
in: route, key: store.key
), let submission = suppliedAuthentications[passphraseHost.id] {
requestAuthentication(
for: passphraseHost, username: submission.username,
selectedKeyID: submission.keyID, requiresPassphrase: true,
@@ -534,7 +529,7 @@ final class SSHSessionModel: Identifiable {
if let suppliedAuthentication {
hasConfiguredCredential =
suppliedAuthentication.keyID != nil
|| suppliedAuthentication.password != nil
|| suppliedAuthentication.hasPassword
|| suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true
if let keyID = suppliedAuthentication.keyID {
if let key = store.key(keyID) {
@@ -545,7 +540,7 @@ final class SSHSessionModel: Identifiable {
credentials.append(
credential.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: suppliedAuthentication.passphrase,
passphraseOverride: suppliedAuthentication.passphraseForConnection(),
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
@@ -564,7 +559,7 @@ final class SSHSessionModel: Identifiable {
)
}
}
if let password = suppliedAuthentication.password {
if let password = suppliedAuthentication.passwordForConnection() {
credentials.append(.password(password))
} else if let identityID = suppliedAuthentication.identityID,
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
@@ -634,7 +629,7 @@ final class SSHSessionModel: Identifiable {
let identity = store.identity(for: jump)
let submitted = suppliedAuthentications[jump.id]
let retryNeedsPassphrase =
submitted?.passphrase == nil
submitted?.hasPassphrase != true
&& (submitted?.keyID ?? identity?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
connectionAttemptID = nil
@@ -665,16 +660,16 @@ final class SSHSessionModel: Identifiable {
identityID: identityID,
username: username,
permitsNone: identity?.hasSecret == false,
hasEphemeralSecret: suppliedAuthentication?.password != nil
|| suppliedAuthentication?.passphrase != nil
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|| suppliedAuthentication?.hasPassphrase == true
)
} else {
authenticationReference = SSHAuthenticationReference(
fallbackKeyID: selectedKeyID,
username: username,
permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil,
hasEphemeralSecret: suppliedAuthentication?.password != nil
|| suppliedAuthentication?.passphrase != nil
permitsNone: selectedKeyID == nil && suppliedAuthentication?.hasPassword != true,
hasEphemeralSecret: suppliedAuthentication?.hasPassword == true
|| suppliedAuthentication?.hasPassphrase == true
)
}
connectionTask = Task { [weak self, weak store] in
@@ -714,10 +709,10 @@ final class SSHSessionModel: Identifiable {
?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil)
let retryNeedsPassphrase =
submitted?.requiresPasswordReentry != true
&& submitted?.passphrase == nil
&& submitted?.hasPassphrase != true
&& authenticationKeyID.flatMap { store.key($0) }?
.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
let returnsToIdentity = submitted?.requiresPassphraseReentry == true
requestAuthentication(
for: authenticationHost,
username: submitted?.username ?? authenticationIdentity?.username
+101 -1
View File
@@ -4,10 +4,10 @@ import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@testable import TermindRemoteInfrastructure
struct AuthorizedKeyExportTests {
@Test
@@ -35,4 +35,104 @@ struct AuthorizedKeyExportTests {
== "# Production Server Key by Termind\nssh-ed25519 AAAAC3Nza\n"
)
}
@Test
func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws {
var encoded = try JSONEncoder().encode(
SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----")
)
let secret = try KeychainSecretStore.decodePrivateKeySecret(from: &encoded)
#expect(encoded.isEmpty)
#expect(secret.matches("-----BEGIN TEST-----\nsecret\n-----END TEST-----"))
#expect(throws: KeychainSecretError.self) {
var data = Data(#"{"privateKey":"secret""#.utf8)
_ = try KeychainSecretStore.decodePrivateKeySecret(
from: &data
)
}
#expect(throws: KeychainSecretError.self) {
var data = Data(#"{"privateKey":"secret"} trailing"#.utf8)
_ = try KeychainSecretStore.decodePrivateKeySecret(
from: &data
)
}
}
@Test
func multipleKeysReuseOneAuthenticatedConnection() async throws {
let password = SSHSecret("one-use")
let request = SSHConnectionRequest(
configuration: SSHConnectionConfiguration(
hostID: UUID(), host: "server.invalid", port: 22,
username: "deploy", credentials: [.password(password)],
expectedHostKeys: []
)
)
let connection = AuthorizedKeyConnection()
try await SSHPTYChannelWorker.installAuthorizedKeys(
[
SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 first", name: "First"),
SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 second", name: "Second"),
],
using: request, connection: connection
)
let snapshot = await connection.snapshot()
#expect(snapshot.connects == 1)
#expect(snapshot.executions == 2)
#expect(snapshot.disconnects == 1)
#expect(
snapshot.inputs == [
"# First Key by Termind\nssh-ed25519 first\n",
"# Second Key by Termind\nssh-ed25519 second\n",
])
#expect(password.isConsumed)
}
}
private actor AuthorizedKeyConnection: SSHConnectionProtocol {
struct Snapshot: Sendable {
var connects: Int
var executions: Int
var disconnects: Int
var inputs: [String]
}
private var connects = 0
private var executions = 0
private var disconnects = 0
private var inputs: [String] = []
func connect(using configuration: SSHConnectionConfiguration) async throws {
connects += 1
}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
executions += 1
inputs.append(stdin.map { String(decoding: $0, as: UTF8.self) } ?? "")
return SSHExecResult(
stdout: "", stderr: "", exitCode: 0,
stdoutTruncated: false, stderrTruncated: false
)
}
func openSFTPChannel() async throws -> any SFTPChannelProtocol {
throw TermindSSHError.disconnected
}
func disconnect() async {
disconnects += 1
}
func snapshot() -> Snapshot {
Snapshot(
connects: connects, executions: executions,
disconnects: disconnects, inputs: inputs
)
}
}