Clear SSH secrets after each connection attempt

This commit is contained in:
Kaede Akino committed 2026-09-20 20:33:17 +08:00
1 parent d9df7b631e
commit cb106ba15d
30 files changed
+910 -148

No files matched your search

+9 -7
View File
@@ -165,10 +165,11 @@ private struct BenchmarkRunner {
let connectionRequest = request(jumps: jumps)
try await session.connect(connectionRequest, progress: nil)
if effectiveMaximum > 1 {
await session.configureAdditionalConnections(
SSHConnectionRequest(
configuration: connectionRequest.configuration,
interactionPolicy: .forbidden))
await session.configureAdditionalConnections {
var request = self.request(jumps: jumps)
request.interactionPolicy = .forbidden
return request.enforcingInteractionPolicy()
}
}
do {
let rule = try rule(kind)
@@ -268,10 +269,11 @@ private struct BenchmarkRunner {
private func request(jumps: Int) -> SSHConnectionRequest {
let username = fixture.username ?? "none"
let credentials: [SSHCredentials] =
func credentials() -> [SSHCredentials] {
fixture.username.flatMap { _ in fixture.privateKey }.map {
[.privateKey(privateKey: $0, publicKey: nil, passphrase: nil)]
} ?? []
}
let expectedHostKeys: Set<SSHHostKeyIdentity> =
fixture.hostKeyFingerprint.map {
[SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: $0)]
@@ -281,7 +283,7 @@ private struct BenchmarkRunner {
host: "127.0.0.1",
port: fixture.port,
username: username,
credentials: credentials,
credentials: credentials(),
expectedHostKeys: expectedHostKeys)
configuration.jumps = (0..<jumps).map { _ in
SSHRouteHopConfiguration(
@@ -290,7 +292,7 @@ private struct BenchmarkRunner {
host: "127.0.0.1",
port: fixture.port,
username: username,
credentials: credentials,
credentials: credentials(),
expectedHostKeys: expectedHostKeys)
}
return SSHConnectionRequest(configuration: configuration, authorizeHostKey: { _ in .accept })
@@ -6,7 +6,7 @@ import Testing
@Suite @MainActor
struct SSHAuthenticationSubmissionTests {
@Test(arguments: [String?.none, "", "supplied"])
func selectedPasswordFallbackRecordsOnlySavedCredentialAccess(_ password: String?) throws {
func selectedPasswordRemainsReferenceOnlyUnlessExplicitlySupplied(_ password: String?) throws {
let secrets = InMemorySecretStore()
let identity = Identity(name: "Deploy", username: "deploy", credentialRef: "password")
let store = HostLibraryModel(identities: [identity], secrets: secrets)
@@ -16,10 +16,10 @@ struct SSHAuthenticationSubmissionTests {
username: "deploy", password: password, passphrase: "one-use", keyID: identity.keyID,
selectedIdentity: identity)
#expect(submission.password == (password ?? "saved"))
#expect(submission.password == password)
#expect(submission.passphrase == "one-use")
#expect(submission.identityID == identity.id)
#expect((store.identity(identity.id)?.lastUsedAt != nil) == (password == nil))
#expect(store.identity(identity.id)?.lastUsedAt == nil)
#expect(try store.passwordCredential(for: identity) == "saved")
}
@@ -41,14 +41,14 @@ struct SSHAuthenticationSubmissionTests {
}
@Test
func failedPasswordReadThrowsButExplicitInputBypassesIt() throws {
func selectingIdentityDefersUnreadablePasswordUntilConnectionResolution() throws {
let identity = Identity(name: "Deploy", username: "deploy", credentialRef: "unreadable")
let store = HostLibraryModel(identities: [identity], secrets: UnreadableAuthenticationSecrets())
#expect(throws: CocoaError.self) {
try store.authenticationSubmission(
username: "deploy", password: nil, passphrase: nil, keyID: nil, selectedIdentity: identity)
}
let referenced = try store.authenticationSubmission(
username: "deploy", password: nil, passphrase: nil, keyID: nil, selectedIdentity: identity)
#expect(referenced.password == nil)
#expect(referenced.identityID == identity.id)
let submission = try store.authenticationSubmission(
username: "deploy", password: "supplied", passphrase: nil, keyID: nil, selectedIdentity: identity)
#expect(submission.password == "supplied")
@@ -139,6 +139,26 @@ struct SSHAuthenticationSubmissionTests {
#expect(updated.identityID == identity.id)
#expect(try store.passwordCredential(for: identity) == "replacement")
}
@Test
func takingAttemptSecretsLeavesOnlyReentryMarkersInSessionState() {
let hostID = UUID()
var retained = [
hostID: SSHAuthenticationSubmission(
username: "deploy", password: "one-use-password",
passphrase: "one-use-passphrase", keyID: UUID()
)
]
let attempt = retained.takeAuthenticationSecrets()
#expect(attempt[hostID]?.password == "one-use-password")
#expect(attempt[hostID]?.passphrase == "one-use-passphrase")
#expect(retained[hostID]?.password == nil)
#expect(retained[hostID]?.passphrase == nil)
#expect(retained[hostID]?.requiresPasswordReentry == true)
#expect(retained[hostID]?.requiresPassphraseReentry == true)
}
}
private struct UnreadableAuthenticationSecrets: SecretStoring {
@@ -133,7 +133,7 @@ struct SSHConnectionPreparationTests {
}
@Test
func protectedKeyReadIsReportedBeforePassphraseIsRequested() async {
func protectedKeyIsNotReadBeforePassphraseIsRequested() async {
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
credentialRef: "key.protected", hasPassphrase: true
@@ -155,8 +155,8 @@ struct SSHConnectionPreparationTests {
await #expect(throws: TermindSSHError.self) {
_ = try await preparation.resolve(trustPolicy: .allowChallenge)
}
#expect(recorder.identityIDs == [identity.id])
#expect(recorder.callCount == 1)
#expect(recorder.identityIDs.isEmpty)
#expect(recorder.callCount == 0)
}
@Test
@@ -185,6 +185,62 @@ struct SSHInteractionPolicyTests {
}
#expect(password == "saved-password")
}
@Test func runtimeSecretCanBeConsumedOnlyOnceAcrossAllReferences() throws {
let secret = SSHSecret("one-use")
let alias = secret
var observedCount = 0
try secret.withUnsafeCStringAndConsume { buffer, count in
observedCount = count
#expect(buffer[0] == CChar(bitPattern: Character("o").asciiValue!))
}
#expect(observedCount == 7)
#expect(secret.isConsumed)
#expect(alias.isConsumed)
#expect(throws: SSHSecret.Consumed.self) {
try alias.withUnsafeCStringAndConsume { _, _ in }
}
}
@Test func clearingAConfigurationInvalidatesEveryCredentialCopy() {
let password = SSHSecret("password")
let privateKey = SSHSecret("private-key")
let passphrase = SSHSecret("passphrase")
let proxyPassword = SSHSecret("proxy-password")
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "example.invalid", port: 22, username: "user",
credentials: [
.password(password),
.privateKey(
privateKey: privateKey, publicKey: nil,
passphrase: passphrase, requiresPassphrase: true
),
], expectedHostKeys: [],
proxy: SSHTransportProxy(
kind: .socks5, hostname: "proxy.invalid", port: 1080,
username: "proxy", password: proxyPassword
)
)
let copy = configuration
copy.clearSecrets()
#expect(password.isConsumed)
#expect(privateKey.isConsumed)
#expect(passphrase.isConsumed)
#expect(proxyPassword.isConsumed)
#expect(
configuration.credentials.allSatisfy { credential in
switch credential {
case .password(let value): value.isConsumed
case .privateKey(let value, _, let phrase, _):
value.isConsumed && phrase?.isConsumed == true
case .secureEnclave, .interactionRequired: true
}
})
}
}
private nonisolated final class CountingSecretStore: SecretStoring, @unchecked Sendable {
@@ -45,7 +45,9 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
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)
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID)
@@ -57,15 +59,16 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
}
extension RemoteSessionHostStore {
/// Resolves a desktop authentication form without saving its input. Only a
/// selected Identity can supply a saved password; explicit input wins.
/// Captures a desktop authentication form without saving its input. A
/// selected Identity stays reference-only so its saved password is read
/// directly into a one-shot buffer only when the connection is resolved.
public func authenticationSubmission(
username: String, password: String?, passphrase: String?, keyID: SSHKeyRef.ID?,
selectedIdentity: Identity?
) throws -> SSHAuthenticationSubmission {
SSHAuthenticationSubmission(
username: username,
password: try password ?? selectedIdentity.flatMap { try connectionPasswordCredential(for: $0) },
password: password,
passphrase: passphrase, keyID: keyID, identityID: selectedIdentity?.id
)
}
@@ -162,7 +165,7 @@ extension RemoteSessionHostStore {
guard let identity = identity(identityID) else {
throw TermindSSHError.missingCredential
}
let password = try connectionPasswordCredential(for: identity)
let password = try connectionPasswordSecret(for: identity)
guard let password else { throw TermindSSHError.missingCredential }
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
@@ -193,7 +196,7 @@ extension RemoteSessionHostStore {
credentials.append(.interactionRequired)
} else {
do {
if let stored = try connectionKeyCredential(
if let stored = try connectionKeySecretCredential(
for: key, identityID: resolvedIdentity?.id
) {
credentials.append(
@@ -218,7 +221,7 @@ extension RemoteSessionHostStore {
if let resolvedIdentity, resolvedIdentity.usesPassword {
do {
if let password = try connectionPasswordCredential(for: resolvedIdentity) {
if let password = try connectionPasswordSecret(for: resolvedIdentity) {
credentials.append(.password(password))
} else {
failures.append(TermindSSHError.missingCredential)
@@ -267,6 +270,14 @@ extension RemoteSessionHostStore {
return password
}
public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? {
let password = try passwordSecret(for: identity)
if password != nil {
recordIdentityCredentialAccess([identity.id], at: Date())
}
return password
}
public func connectionKeyCredential(
for key: SSHKeyRef, identityID: Identity.ID?
) throws -> StoredSSHKeyCredential? {
@@ -277,6 +288,16 @@ extension RemoteSessionHostStore {
return credential
}
public func connectionKeySecretCredential(
for key: SSHKeyRef, identityID: Identity.ID?
) throws -> SSHResolvedKeyCredential? {
let credential = try keySecretCredential(for: key)
if credential != nil, let identityID {
recordIdentityCredentialAccess([identityID], at: Date())
}
return credential
}
public func expectedHostKeys(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> Set<SSHHostKeyIdentity> {
@@ -132,9 +132,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
var result: [SSHCredentials] = []
var retrievalFailure: (any Error)?
if let key = node.key {
let stored: StoredSSHKeyCredential?
if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false {
throw TermindSSHError.keyPassphraseRequired
}
let stored: SSHResolvedKeyCredential?
do {
stored = try secrets.keyCredential(for: key.credentialRef, storage: key.storage)
stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage)
if stored != nil, let identity = connectionIdentity(for: node) {
accessedIdentityIDs.insert(identity.id)
}
@@ -143,9 +146,6 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
retrievalFailure = error
}
if let stored {
if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false {
throw TermindSSHError.keyPassphraseRequired
}
result.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
@@ -162,7 +162,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
let identity = node.identity, identity.usesPassword
{
do {
if let password = try secrets.string(
if let password = try secrets.sshSecret(
for: identity.credentialRef, storage: identity.passwordStorage
) {
result.append(.password(password))
@@ -187,12 +187,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password: String?
var password: SSHSecret?
if proxy.identityID != nil {
guard let identity = node.proxyIdentity, identity.usesPassword else {
throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable."))
}
password = try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage)
password = try secrets.sshSecret(for: identity.credentialRef, storage: identity.passwordStorage)
guard password != nil else {
throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable."))
}
@@ -662,15 +662,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
) throws -> [SSHCredentials] {
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID) {
if let stored = try keyCredential(for: key) {
if key.requiresPassphraseEntry,
submission.passphrase?.isEmpty != false
{
throw TermindSSHError.keyPassphraseRequired
}
if let stored = try keySecretCredential(for: key) {
if let identityID = submission.identityID {
accessedIdentityIDs.insert(identityID)
}
if key.requiresPassphraseEntry,
submission.passphrase?.isEmpty != false
{
throw TermindSSHError.keyPassphraseRequired
}
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
@@ -681,7 +681,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
)
}
}
if let password = submission.password { credentials.append(.password(password)) }
if let password = submission.password {
credentials.append(.password(password))
} else if let identityID = submission.identityID,
let identity = identity(identityID), identity.usesPassword,
let password = try passwordSecret(for: identity)
{
credentials.append(.password(password))
accessedIdentityIDs.insert(identityID)
}
return credentials
}
@@ -700,12 +708,12 @@ public final class HostLibraryModel: RemoteSessionHostStore {
guard proxy.isValid else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
var password: String?
var password: SSHSecret?
if let identityID = proxy.identityID {
guard let identity = identity(identityID), identity.usesPassword else {
throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable."))
}
password = try passwordCredential(for: identity)
password = try passwordSecret(for: identity)
guard password != nil else {
throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable."))
}
@@ -737,12 +745,12 @@ public final class HostLibraryModel: RemoteSessionHostStore {
if let keyID = identity.keyID {
if let key = key(keyID) {
do {
if let credential = try keyCredential(for: key) {
accessedIdentityIDs.insert(identity.id)
if key.requiresPassphraseEntry {
requiredPassphraseKeyID = key.id
} else {
if key.requiresPassphraseEntry {
requiredPassphraseKeyID = key.id
} else {
do {
if let credential = try keySecretCredential(for: key) {
accessedIdentityIDs.insert(identity.id)
credentials.append(
credential.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
@@ -750,15 +758,15 @@ public final class HostLibraryModel: RemoteSessionHostStore {
requiresUserPresence: key.requiresUserPresence
)
)
} else {
failures.append(
String(localized: "The selected key has no saved private key.")
)
}
} else {
failures.append(
String(localized: "The selected key has no saved private key.")
)
} catch {
storageError = error.localizedDescription
failures.append(error.localizedDescription)
}
} catch {
storageError = error.localizedDescription
failures.append(error.localizedDescription)
}
} else {
failures.append(
@@ -769,7 +777,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
if identity.usesPassword {
do {
if let password = try passwordCredential(for: identity) {
if let password = try passwordSecret(for: identity) {
credentials.append(.password(password))
accessedIdentityIDs.insert(identity.id)
} else {
@@ -1215,6 +1223,13 @@ public final class HostLibraryModel: RemoteSessionHostStore {
)
}
public func passwordSecret(for identity: Identity) throws -> SSHSecret? {
guard identity.usesPassword else { return nil }
return try keychain.sshSecret(
for: identity.credentialRef, storage: identity.passwordStorage
)
}
public func passwordAvailability(for identity: Identity) -> SecretAvailability {
guard identity.usesPassword else { return .notConfigured }
do {
@@ -1394,6 +1409,10 @@ public final class HostLibraryModel: RemoteSessionHostStore {
try keychain.keyCredential(for: key.credentialRef, storage: key.storage)
}
public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage)
}
public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability {
do {
return try keyCredential(for: key) == nil ? .missing : .available
@@ -80,7 +80,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
private nonisolated struct RunAttempt: Sendable {
var host: Host
var preparation: SSHConnectionPreparation
var preparation: SSHConnectionPreparation?
var sampler: any MetricsSampling
}
private nonisolated struct HostKeyAuthorization: Sendable {
@@ -307,10 +307,13 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
finishHostKeyAuthorization(for: server.id, decision: .accept)
}
private func preparation(for host: Host, server: ServerMetric) throws -> SSHConnectionPreparation {
private func preparation(
for host: Host, server: ServerMetric,
submissions: [UUID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try hosts.prepareSSHConnection(
for: host,
submissions: submissions[server.id] ?? [:],
submissions: submissions,
temporaryHostKeys: temporaryKeys[server.id] ?? [:]
)
}
@@ -320,7 +323,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
let staged = submissions[server.id]?[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase = keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.passphrase == nil
let passphrase =
staged?.requiresPasswordReentry != true
&& keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true
&& staged?.passphrase == nil
server.authenticationHost = host
server.authenticationPrompt = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
@@ -339,6 +345,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
public func suspend() {
sleeping = true
submissions.removeAll()
for server in servers {
cancel(server.id)
server.isConnecting = false
@@ -533,7 +540,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
private nonisolated func run(_ serverID: UUID, token: UUID, clock: MetricsClock) async {
var delay = 2.0
while await isCurrent(serverID, token: token) {
let attempt: RunAttempt
var attempt: RunAttempt
do {
guard let next = try await beginRunAttempt(serverID, token: token) else { return }
attempt = next
@@ -547,7 +554,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
let sampler = attempt.sampler
do {
let configuration = try await attempt.preparation.resolve(
guard let preparation = attempt.preparation else { throw CancellationError() }
attempt.preparation = nil
let configuration = try await preparation.resolve(
trustPolicy: .allowChallenge
)
guard await isCurrent(serverID, token: token) else {
@@ -659,7 +668,36 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.isConnecting = true
let preparation = try preparation(for: host, server: server)
let route = hosts.route(for: host)
if let passwordHost = route.first(where: { routeHost in
submissions[server.id]?[routeHost.id]?.requiresPasswordReentry == true
}) {
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
}) {
throw TermindSSHError.routeHost(
hostID: passphraseHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.keyPassphraseRequired.localizedDescription
)
}
var retained = submissions[server.id] ?? [:]
let attemptSubmissions = retained.takeAuthenticationSecrets()
submissions[server.id] = retained
let preparation = try preparation(
for: host, server: server, submissions: attemptSubmissions
)
let sampler = makeSampler()
samplers[serverID] = sampler
return RunAttempt(host: host, preparation: preparation, sampler: sampler)
@@ -114,11 +114,13 @@ public nonisolated struct SSHConnectionRequest: Sendable {
nonisolated extension SSHCredentials {
fileprivate var withoutInteraction: SSHCredentials {
switch self {
case .privateKey(_, _, _, requiresPassphrase: true),
.secureEnclave(_, _, requiresUserPresence: true):
.interactionRequired
case .privateKey(_, _, _, requiresPassphrase: true):
clearSecrets()
return .interactionRequired
case .secureEnclave(_, _, requiresUserPresence: true):
return .interactionRequired
default:
self
return self
}
}
}
@@ -215,9 +217,9 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable {
}
public nonisolated enum SSHCredentials: Sendable {
case password(String)
case password(SSHSecret)
case privateKey(
privateKey: String, publicKey: String?, passphrase: String?,
privateKey: SSHSecret, publicKey: String?, passphrase: SSHSecret?,
requiresPassphrase: Bool = false
)
case secureEnclave(
@@ -227,6 +229,60 @@ public nonisolated enum SSHCredentials: Sendable {
/// A public-key credential exists, but the current policy may not obtain it
/// without presenting Touch ID, a device-password prompt, or a passphrase form.
case interactionRequired
public static func password(_ value: String) -> Self {
.password(SSHSecret(value))
}
public static func privateKey(
privateKey: String, publicKey: String?, passphrase: String?,
requiresPassphrase: Bool = false
) -> Self {
.privateKey(
privateKey: SSHSecret(privateKey), publicKey: publicKey,
passphrase: passphrase.map(SSHSecret.init),
requiresPassphrase: requiresPassphrase
)
}
public func clearSecrets() {
switch self {
case .password(let password):
password.clear()
case .privateKey(let privateKey, _, let passphrase, _):
privateKey.clear()
passphrase?.clear()
case .secureEnclave, .interactionRequired:
break
}
}
}
public nonisolated enum SSHResolvedKeyCredential: Sendable {
case software(SSHSecret)
case secureEnclave(SecureEnclaveKeyCredential)
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
case .software(let privateKey):
.privateKey(
privateKey: privateKey,
publicKey: certificate.isEmpty
? (publicKey.isEmpty ? nil : publicKey)
: certificate,
passphrase: passphraseOverride.map(SSHSecret.init),
requiresPassphrase: requiresPassphrase
)
case .secureEnclave(let credential):
.secureEnclave(
credential: credential, publicKey: publicKey,
requiresUserPresence: requiresUserPresence
)
}
}
}
nonisolated extension StoredSSHKeyCredential {
@@ -236,17 +292,17 @@ nonisolated extension StoredSSHKeyCredential {
) -> SSHCredentials {
switch self {
case .software(let secret):
.privateKey(
privateKey: secret.privateKey,
publicKey: certificate.isEmpty
? (publicKey.isEmpty ? nil : publicKey)
: certificate,
passphrase: passphraseOverride,
requiresPassphrase: requiresPassphrase
SSHResolvedKeyCredential.software(SSHSecret(secret.privateKey)).sshCredential(
publicKey: publicKey, certificate: certificate,
passphraseOverride: passphraseOverride,
requiresPassphrase: requiresPassphrase,
requiresUserPresence: requiresUserPresence
)
case .secureEnclave(let credential):
.secureEnclave(
credential: credential, publicKey: publicKey,
SSHResolvedKeyCredential.secureEnclave(credential).sshCredential(
publicKey: publicKey, certificate: certificate,
passphraseOverride: passphraseOverride,
requiresPassphrase: requiresPassphrase,
requiresUserPresence: requiresUserPresence
)
}
@@ -312,6 +368,14 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
)
}
/// Invalidates every authentication secret shared by copies of this
/// one-shot configuration. Network and provenance fields remain readable.
public func clearSecrets() {
for credential in credentials { credential.clearSecrets() }
proxy?.clearSecret()
for jump in jumps { jump.clearSecrets() }
}
}
public nonisolated struct SSHCredentialResolution: Sendable {
@@ -19,6 +19,8 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
public var passphrase: String?
public var keyID: SSHKeyRef.ID?
public var identityID: Identity.ID? = nil
public var requiresPasswordReentry = false
public var requiresPassphraseReentry = false
public init(
username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil,
identityID: Identity.ID? = nil
@@ -30,4 +32,23 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
self.identityID = identityID
}
public mutating func clearSecrets() {
if password != nil { requiresPasswordReentry = true }
if passphrase != nil { requiresPassphraseReentry = true }
password = nil
passphrase = nil
}
}
nonisolated extension Dictionary where Value == SSHAuthenticationSubmission {
/// 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()
}
return result
}
}
@@ -35,4 +35,9 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable {
)
}
public func clearSecrets() {
for credential in credentials { credential.clearSecrets() }
proxy?.clearSecret()
}
}
@@ -0,0 +1,98 @@
import Darwin
import Foundation
/// 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.
public nonisolated final class SSHSecret: @unchecked Sendable {
public struct Consumed: Error, Sendable {
public init() {}
}
private let lock = NSLock()
private var storage: Data?
public init(_ value: String) {
var bytes = Data(value.utf8)
bytes.append(0)
storage = bytes
}
/// Transfers a freshly read UTF-8 buffer into wipeable ownership without
/// first materializing an immutable Swift `String`.
public init(consumingUTF8 bytes: inout Data) {
bytes.append(0)
storage = bytes
bytes = Data()
}
deinit { clear() }
public var isConsumed: Bool {
lock.withLock { storage == nil }
}
/// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`.
/// The memory 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))
}
}
/// Test and diagnostic support without extending the secret's lifetime.
/// Production authentication should use `withUnsafeCStringAndConsume`.
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)
}
}
public func clear() {
guard
var bytes = lock.withLock({ () -> Data? in
let value = storage
storage = nil
return value
})
else { return }
Self.wipe(&bytes)
}
private func take() throws -> Data {
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 {
lhs.matches(rhs)
}
public nonisolated func == (lhs: String, rhs: SSHSecret) -> Bool {
rhs.matches(lhs)
}
@@ -8,11 +8,11 @@ public nonisolated struct SSHTransportProxy: Sendable {
public var hostname: String
public var port: Int
public var username: String
public var password: String?
public var password: SSHSecret?
public var identityID: Identity.ID?
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
password: String? = nil, identityID: Identity.ID? = nil
password: SSHSecret? = nil, identityID: Identity.ID? = nil
) {
self.kind = kind
self.hostname = hostname
@@ -22,4 +22,16 @@ public nonisolated struct SSHTransportProxy: Sendable {
self.identityID = identityID
}
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
password: String?, identityID: Identity.ID? = nil
) {
self.init(
kind: kind, hostname: hostname, port: port, username: username,
password: password.map(SSHSecret.init), identityID: identityID
)
}
public func clearSecret() { password?.clear() }
}
@@ -11,10 +11,14 @@ public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable {
public nonisolated protocol SecretStoring: Sendable {
func string(for account: String, storage: PasswordStorage) throws -> String?
func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret?
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 setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws
@@ -22,6 +26,22 @@ public nonisolated protocol SecretStoring: Sendable {
func removeKeyCredential(_ account: String, storage: KeyStorage) throws
}
nonisolated extension SecretStoring {
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
try string(for: account, storage: storage).map(SSHSecret.init)
}
public func sshKeyCredential(
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential? {
switch try keyCredential(for: account, storage: storage) {
case .software(let secret): .software(SSHSecret(secret.privateKey))
case .secureEnclave(let credential): .secureEnclave(credential)
case nil: nil
}
}
}
public nonisolated final class InMemorySecretStore: SecretStoring, @unchecked Sendable {
public init() {}
@@ -269,9 +269,13 @@ public struct SSHAuthenticationForm: View {
}
private func commit(save: Bool) {
let submittedPassword = password.isEmpty ? nil : password
let submittedPassphrase = passphrase.isEmpty ? nil : passphrase
password = ""
passphrase = ""
submit(
identityID, username, password.isEmpty ? nil : password,
passphrase.isEmpty ? nil : passphrase, keyID, save
identityID, username, submittedPassword,
submittedPassphrase, keyID, save
)
}
@@ -354,14 +354,13 @@ public final class AgentHostResolver: Sendable {
guard let identity = store.identity(identityID) else {
throw TermindSSHError.missingCredential
}
let password = try store.connectionPasswordCredential(for: identity)
if answer.save {
var updated = host
updated.identityID = identity.id
store.upsert(updated)
}
return SSHAuthenticationSubmission(
username: identity.username, password: password,
username: identity.username,
passphrase: answer.passphrase, keyID: identity.keyID,
identityID: identity.id
)
@@ -560,11 +559,11 @@ public actor AgentSSHAuthenticationContext {
guard failedGeneration == generation else { return }
let target = error.routeHostID ?? hostID
if var retained = submissions[target] {
retained.password = nil
retained.passphrase = nil
retained.clearSecrets()
submissions[target] = retained
}
generation &+= 1
cached?.clearSecrets()
cached = nil
pending?.task.cancel()
pending = nil
@@ -572,6 +571,15 @@ public actor AgentSSHAuthenticationContext {
failure = error.localizedDescription
}
public func didAuthenticate(generation authenticatedGeneration: UInt64) {
guard authenticatedGeneration == generation else { return }
submissions.removeAll()
cached?.clearSecrets()
cached = nil
pending?.task.cancel()
pending = nil
}
private func makeAttempt(
configuration: SSHConnectionConfiguration, generation: UInt64
) -> Attempt {
@@ -861,7 +869,9 @@ public actor AgentSSHAuthenticationRegistry {
open: @Sendable (AgentSSHAuthenticationContext.Attempt?) async throws -> Client
) async throws -> Client {
var attempt: AgentSSHAuthenticationContext.Attempt?
if await authentication.canResolveTarget() {
if await pool.isConnected(hostID: hostID) {
attempt = nil
} else if await authentication.canResolveTarget() {
attempt = try await authentication.configurationAttempt()
}
while true {
@@ -874,7 +884,9 @@ public actor AgentSSHAuthenticationRegistry {
attemptedGeneration = await authentication.currentGeneration
}
do {
return try await open(attempt)
let client = try await open(attempt)
await authentication.didAuthenticate(generation: attemptedGeneration)
return client
} catch let error as TermindSSHError where error.shouldRequestAuthentication {
if cancellation?.isCancelled == true { throw SFTPError.cancelled }
if evictsPooledConnection { await pool.disconnect(hostID: hostID) }
@@ -327,7 +327,41 @@ 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
}) {
requestAuthentication(
context,
error: .routeHost(
hostID: passwordHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.missingCredential.localizedDescription
)
)
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
}) {
requestAuthentication(
context,
error: .routeHost(
hostID: passphraseHost.id, authentication: true,
serverChallenge: false, hostKey: false,
message: TermindSSHError.keyPassphraseRequired.localizedDescription
)
)
context.attemptID = nil
return
}
}
let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets()
let forwardingConnection = makeConnection(
context.maximumConnections,
{ [weak self, weak context] id, generation, count in
@@ -365,7 +399,7 @@ public final class PortForwardingController {
configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration
} else {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: context.submissions,
for: host, submissions: attemptSubmissions,
temporaryHostKeys: context.connection.temporaryHostKeys)
configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
}
@@ -472,7 +506,10 @@ public final class PortForwardingController {
let staged = context.submissions[host.id]
let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase = keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true && staged?.passphrase == nil
let passphrase =
staged?.requiresPasswordReentry != true
&& keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true
&& staged?.passphrase == nil
context.setPrompt(
SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
@@ -246,6 +246,7 @@ nonisolated extension SFTPClientProtocol {
progress: SSHRouteProgressHandler?,
connectionFailure: (@Sendable (String) -> Void)?
) async throws {
defer { request.configuration.clearSecrets() }
try await connect(
using: request.configuration, progress: progress,
connectionFailure: connectionFailure
@@ -342,6 +343,7 @@ public actor SFTPClient: SFTPClientProtocol {
progress: SSHRouteProgressHandler?,
connectionFailure: (@Sendable (String) -> Void)?
) async throws {
defer { request.configuration.clearSecrets() }
generation &+= 1
let token = generation
let previousChannel = channel
@@ -53,6 +53,7 @@ public nonisolated protocol SSHConnectionProtocol: AnyObject, Sendable {
nonisolated extension SSHConnectionProtocol {
public func connect(using request: SSHConnectionRequest) async throws {
defer { request.configuration.clearSecrets() }
try await connect(using: request.configuration)
}
}
@@ -86,6 +87,7 @@ public actor SSHConnection: SSHConnectionProtocol {
progress: SSHRouteProgressHandler?,
connectionFailure: (@Sendable (String) -> Void)?
) async throws {
defer { request.configuration.clearSecrets() }
try await worker.connect(
using: request.withKeyboardInteractiveFallback(keyboardInteractive),
progress: progress, connectionFailure: connectionFailure)
@@ -59,6 +59,7 @@ public actor SSHForwardingConnection: SSHForwardingConnectionProtocol {
}
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler? = nil) async throws {
defer { request.configuration.clearSecrets() }
try await worker.connect(request, progress: progress)
}
@@ -632,6 +632,7 @@ public actor SSHPTYChannel {
rows: UInt16,
progress: SSHRouteProgressHandler? = nil
) async throws {
defer { request.configuration.clearSecrets() }
try await worker.connect(
using: request.withKeyboardInteractiveFallback(keyboardInteractive),
shellIntegrationEnabled: shellIntegrationEnabled,
@@ -734,6 +735,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
) throws -> SSHAuthenticatedConnection {
_ = initialization
defer { configuration.clearSecrets() }
let operationID = UUID().uuidString.lowercased()
logger.info(
"ssh.authentication.started",
@@ -808,6 +810,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
let operationID = UUID().uuidString.lowercased()
let request = request.enforcingInteractionPolicy()
let configuration = request.configuration
defer { configuration.clearSecrets() }
logger.info(
"ssh.authentication.started",
metadata: [
@@ -1071,11 +1074,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
if methods.contains("password") {
attempted = true
lastMethod = "password"
let result = username.withCString { user in
password.withCString { password in
let result = try username.withCString { user in
try password.withUnsafeCStringAndConsume { password, passwordLength in
libssh2_userauth_password_ex(
session, user, UInt32(username.utf8.count),
password, UInt32(strlen(password)), nil
password, UInt32(passwordLength), nil
)
}
}
@@ -1085,25 +1088,25 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
guard methods.contains("publickey") else { continue }
attempted = true
lastMethod = "public_key"
let result = username.withCString { user in
privateKey.withCString { privateBuffer in
let call: (UnsafePointer<CChar>?) -> Int32 = { publicBuffer in
let result = try username.withCString { user in
try privateKey.withUnsafeCStringAndConsume { privateBuffer, privateKeyLength in
let call: (UnsafePointer<CChar>?) throws -> Int32 = { publicBuffer in
if let passphrase {
return passphrase.withCString { passphraseBuffer in
return try passphrase.withUnsafeCStringAndConsume { passphraseBuffer, _ in
libssh2_userauth_publickey_frommemory(
session, user, username.utf8.count,
publicBuffer, publicKey?.utf8.count ?? 0,
privateBuffer, privateKey.utf8.count, passphraseBuffer
privateBuffer, privateKeyLength, passphraseBuffer
)
}
}
return libssh2_userauth_publickey_frommemory(
session, user, username.utf8.count,
publicBuffer, publicKey?.utf8.count ?? 0,
privateBuffer, privateKey.utf8.count, nil
privateBuffer, privateKeyLength, nil
)
}
return publicKey?.withCString(call) ?? call(nil)
return try publicKey?.withCString(call) ?? call(nil)
}
}
if result == 0 { return lastMethod }
@@ -11,6 +11,7 @@ public nonisolated enum SSHSocketConnector {
host: String, port: Int, proxy: SSHTransportProxy? = nil,
timeout: TimeInterval = 20
) throws -> Int32 {
defer { proxy?.clearSecret() }
let socket = try openTCP(
host: proxy?.hostname ?? host,
port: proxy?.port ?? port,
@@ -72,6 +73,7 @@ public nonisolated enum SSHSocketConnector {
public static func negotiate(
socket: Int32, host: String, port: Int, proxy: SSHTransportProxy
) throws {
defer { proxy.clearSecret() }
switch proxy.kind {
case .http:
try negotiateHTTP(socket: socket, host: host, port: port, proxy: proxy)
@@ -84,13 +86,31 @@ public nonisolated enum SSHSocketConnector {
socket: Int32, host: String, port: Int, proxy: SSHTransportProxy
) throws {
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
var request = "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n"
if let password = proxy.password, !proxy.username.isEmpty {
let token = Data("\(proxy.username):\(password)".utf8).base64EncodedString()
request += "Proxy-Authorization: Basic \(token)\r\n"
}
request += "\r\n"
try writeAll(socket, Data(request.utf8))
try {
var request = Data(
"CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n".utf8
)
defer { wipe(&request) }
if let password = proxy.password, !proxy.username.isEmpty {
var plaintext = Data(proxy.username.utf8)
plaintext.append(UInt8(ascii: ":"))
defer { wipe(&plaintext) }
try password.withUnsafeCStringAndConsume { buffer, count in
plaintext.append(
buffer.withMemoryRebound(to: UInt8.self, capacity: count) {
UnsafeBufferPointer(start: $0, count: count)
}
)
}
var token = plaintext.base64EncodedData()
defer { wipe(&token) }
request.append(Data("Proxy-Authorization: Basic ".utf8))
request.append(token)
request.append(Data("\r\n".utf8))
}
request.append(Data("\r\n".utf8))
try writeAll(socket, request)
}()
var response = Data()
while response.range(of: Data("\r\n\r\n".utf8)) == nil {
@@ -127,15 +147,21 @@ public nonisolated enum SSHSocketConnector {
throw TermindSSHError.proxy("The SOCKS5 proxy requires a password.")
}
let username = Data(proxy.username.utf8)
let secret = Data(password.utf8)
guard username.count <= 255, secret.count <= 255 else {
throw TermindSSHError.proxy("The SOCKS5 username or password is too long.")
try password.withUnsafeCStringAndConsume { buffer, count in
guard username.count <= 255, count <= 255 else {
throw TermindSSHError.proxy("The SOCKS5 username or password is too long.")
}
var auth = Data([0x01, UInt8(username.count)])
defer { wipe(&auth) }
auth.append(username)
auth.append(UInt8(count))
auth.append(
buffer.withMemoryRebound(to: UInt8.self, capacity: count) {
UnsafeBufferPointer(start: $0, count: count)
}
)
try writeAll(socket, auth)
}
var auth = Data([0x01, UInt8(username.count)])
auth.append(username)
auth.append(UInt8(secret.count))
auth.append(secret)
try writeAll(socket, auth)
let result = try readExactly(socket, count: 2)
guard result[1] == 0 else {
throw TermindSSHError.proxy("SOCKS5 proxy authentication failed.")
@@ -180,6 +206,14 @@ 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,10 +51,25 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
return value
}
public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? {
try validateStringReference(account)
guard
var data = try data(
for: account, service: Self.passwordService,
synchronizable: storage == .iCloud
)
else { return nil }
return SSHSecret(consumingUTF8: &data)
}
public func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try validateStringReference(account)
var data = value?.data(using: .utf8)
defer {
if data != nil { Self.wipe(&data!) }
}
try setData(
value?.data(using: .utf8), for: account, service: Self.passwordService,
data, for: account, service: Self.passwordService,
synchronizable: storage == .iCloud,
accessible: storage == .iCloud
? kSecAttrAccessibleWhenUnlocked
@@ -81,11 +96,32 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
}
}
public func sshKeyCredential(
for account: String, storage: KeyStorage
) throws -> SSHResolvedKeyCredential? {
try validate(account, prefix: "termind.key.")
guard
var data = try data(
for: account, service: service(for: storage),
synchronizable: storage == .iCloud
)
else { return nil }
switch storage {
case .thisDevice, .iCloud:
defer { Self.wipe(&data) }
var privateKey = try Self.decodePrivateKeyBytes(from: data)
return .software(SSHSecret(consumingUTF8: &privateKey))
case .secureEnclave:
guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue }
return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data))
}
}
public func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
try validate(account, prefix: "termind.key.")
let data: Data?
var data: Data?
switch (value, storage) {
case (.software(let secret), .thisDevice), (.software(let secret), .iCloud):
data = try JSONEncoder().encode(secret)
@@ -96,6 +132,9 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable {
default:
throw KeychainSecretError.unexpectedValue
}
defer {
if data != nil { Self.wipe(&data!) }
}
try setData(
data, for: account, service: service(for: storage),
synchronizable: storage == .iCloud,
@@ -105,6 +144,87 @@ 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
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
}
index = data.index(after: index)
skipWhitespace()
guard index < data.endIndex, data[index] == 0x22 else {
throw KeychainSecretError.unexpectedValue
}
index = data.index(after: index)
var result = Data()
while index < data.endIndex {
let byte = data[index]
index = data.index(after: index)
if byte == 0x22 { return result }
guard byte == 0x5C else {
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
}
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 removePassword(
_ account: String, storage: PasswordStorage = .thisDevice
) throws {
+5
View File
@@ -93,6 +93,11 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
- SSH/SFTP/mobile Workspaces/Metrics share preparation/recovery policy. Host-key approval occurs inside the actual
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.
- 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,
+40 -2
View File
@@ -284,13 +284,16 @@ final class MobileWorkspace: Identifiable {
host: host, selectedIdentityID: identity?.id, username: username,
selectedKeyID: keyID, failureMessage: connectionFailure,
requiresPassphrase: keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true
&& staged?.requiresPasswordReentry != true
&& staged?.passphrase == nil,
mode: authenticationMode)
}
var hostKeyPrompt: SSHHostKeyPrompt? { connection.pendingHostKey }
private func preparation(for host: Host) throws -> SSHConnectionPreparation {
private func preparation(
for host: Host, submissions: [UUID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try hosts.prepareSSHConnection(
for: host,
submissions: submissions,
@@ -306,6 +309,7 @@ final class MobileWorkspace: Identifiable {
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] {
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
submissions[host.id] = staged
} else {
@@ -555,6 +559,40 @@ 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
}) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passwordHost.id
username =
submissions[passwordHost.id]?.username
?? hosts.identity(for: passwordHost)?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForLocalIdentity(at: passwordHost.id)
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
}) {
authenticationRequired = true
authenticationMode = .localIdentity
authenticationHostID = passphraseHost.id
username =
submissions[passphraseHost.id]?.username
?? hosts.identity(for: passphraseHost)?.username ?? ""
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForLocalPassphrase(at: passphraseHost.id)
busy = false
return
}
let attemptSubmissions = submissions.takeAuthenticationSecrets()
let progressHandler = connection.progressHandler(attemptID: token) { [weak self] in
self?.generation == $0
}
@@ -564,7 +602,7 @@ final class MobileWorkspace: Identifiable {
defer { if generation == token { busy = false } }
guard generation == token, !Task.isCancelled else { return }
do {
let preparation = try preparation(for: host)
let preparation = try preparation(for: host, submissions: attemptSubmissions)
let config = try await preparation.resolve(
trustPolicy: .allowChallenge
)
@@ -88,9 +88,17 @@ extension TransferLibraryModel: RemoteSessionHostStore {
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)
}
func recordIdentityCredentialAccess(_ identityIDs: Set<Identity.ID>, at date: Date) {
library.recordIdentityCredentialAccess(identityIDs, at: date)
}
+26 -7
View File
@@ -308,12 +308,21 @@ final class SFTPChannelModel: Identifiable {
let attemptID = UUID()
connectionAttemptID = attemptID
let route = store.route(for: host)
let suppliedAuthentication = suppliedAuthentications[host.id]
let stagedAuthentication = suppliedAuthentications[host.id]
connectionJourney.begin(
host: host, route: route, purpose: .sftpChannel,
username: suppliedAuthentication?.username ?? store.identity(for: host)?.username,
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
}) {
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,
@@ -328,10 +337,12 @@ final class SFTPChannelModel: Identifiable {
)
return
}
if reconnecting, usesEphemeralCredentials, suppliedAuthentication == nil {
if reconnecting, usesEphemeralCredentials {
requestAuthentication(host: host, store: store)
return
}
let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
let suppliedAuthentication = attemptAuthentications[host.id]
status = reconnecting ? .reconnecting : .connecting
hint =
reconnecting
@@ -348,7 +359,7 @@ final class SFTPChannelModel: Identifiable {
var credentials: [SSHCredentials] = []
if let keyID = suppliedAuthentication.keyID,
let key = store.key(keyID),
let stored = try? store.connectionKeyCredential(
let stored = try? store.connectionKeySecretCredential(
for: key, identityID: suppliedAuthentication.identityID
)
{
@@ -362,6 +373,13 @@ final class SFTPChannelModel: Identifiable {
}
if let password = suppliedAuthentication.password {
credentials.append(.password(password))
} else if let identityID = suppliedAuthentication.identityID,
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
{
guard let password = try store.connectionPasswordSecret(for: selectedIdentity) else {
throw TermindSSHError.missingCredential
}
credentials.append(.password(password))
}
configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port,
@@ -370,7 +388,7 @@ final class SFTPChannelModel: Identifiable {
expectedHostKeys: expectedHostKeys,
proxy: try store.transportProxy(for: host),
jumps: try store.sshJumpConfigurations(
for: host, submissions: suppliedAuthentications,
for: host, submissions: attemptAuthentications,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
@@ -387,7 +405,7 @@ final class SFTPChannelModel: Identifiable {
)
} else {
configuration = try store.sshConnectionConfiguration(
for: host, submissions: suppliedAuthentications,
for: host, submissions: attemptAuthentications,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
)
@@ -470,7 +488,8 @@ final class SFTPChannelModel: Identifiable {
}
let submitted = self.suppliedAuthentications[authenticationHost.id]
let retryNeedsPassphrase =
submitted?.passphrase == nil
submitted?.requiresPasswordReentry != true
&& submitted?.passphrase == nil
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
+33 -6
View File
@@ -480,15 +480,24 @@ final class SSHSessionModel: Identifiable {
homeResolution = isShellIntegrationEnabled ? .pending : .unavailable
let route = connectionRoute?.hosts ?? store.route(for: host)
let identity = store.identity(for: host)
let suppliedAuthentication = suppliedAuthentications[host.id]
let stagedAuthentication = suppliedAuthentications[host.id]
let username =
suppliedAuthentication?.username
stagedAuthentication?.username
?? identity?.username
?? usernameHint
connectionJourney.begin(
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] {
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,
@@ -511,6 +520,8 @@ final class SSHSessionModel: Identifiable {
)
return
}
let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets()
let suppliedAuthentication = attemptAuthentications[host.id]
var credentials: [SSHCredentials] = []
var credentialFailures: [String] = []
var hasConfiguredCredential = false
@@ -524,10 +535,11 @@ final class SSHSessionModel: Identifiable {
hasConfiguredCredential =
suppliedAuthentication.keyID != nil
|| suppliedAuthentication.password != nil
|| suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true
if let keyID = suppliedAuthentication.keyID {
if let key = store.key(keyID) {
do {
if let credential = try store.connectionKeyCredential(
if let credential = try store.connectionKeySecretCredential(
for: key, identityID: suppliedAuthentication.identityID
) {
credentials.append(
@@ -554,6 +566,20 @@ final class SSHSessionModel: Identifiable {
}
if let password = suppliedAuthentication.password {
credentials.append(.password(password))
} else if let identityID = suppliedAuthentication.identityID,
let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword
{
do {
if let password = try store.connectionPasswordSecret(for: selectedIdentity) {
credentials.append(.password(password))
} else {
credentialFailures.append(
String(localized: "The saved password is no longer available.")
)
}
} catch {
credentialFailures.append(error.localizedDescription)
}
}
} else if let identity {
let resolution = store.sshCredentials(for: identity)
@@ -586,12 +612,12 @@ final class SSHSessionModel: Identifiable {
proxy = try store.transportProxy(for: host)
if let connectionRoute {
jumps = try store.sshJumpConfigurations(
for: connectionRoute, submissions: suppliedAuthentications,
for: connectionRoute, submissions: attemptAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
} else {
jumps = try store.sshJumpConfigurations(
for: host, submissions: suppliedAuthentications,
for: host, submissions: attemptAuthentications,
temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge
)
}
@@ -687,7 +713,8 @@ final class SSHSessionModel: Identifiable {
submitted?.keyID
?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil)
let retryNeedsPassphrase =
submitted?.passphrase == nil
submitted?.requiresPasswordReentry != true
&& submitted?.passphrase == nil
&& authenticationKeyID.flatMap { store.key($0) }?
.requiresPassphraseEntry == true
let returnsToIdentity = submitted?.passphrase != nil
+81 -11
View File
@@ -199,7 +199,7 @@ struct AgentHostAuthenticationTests {
Issue.record("Expected a private-key credential")
return
}
#expect(passphrase == "one-use")
#expect(passphrase?.matches("one-use") == true)
guard configuration.credentials.count == 2,
case .password(let password) = configuration.credentials[1]
else {
@@ -280,7 +280,7 @@ struct AgentHostAuthenticationTests {
Issue.record("Expected a private-key credential")
return
}
#expect(passphrase == "one-use")
#expect(passphrase?.matches("one-use") == true)
#expect(coordinator.pending.isEmpty)
}
@@ -318,8 +318,10 @@ struct AgentHostAuthenticationTests {
#expect(requestedHosts == [jump.id, destination.id])
let configuration = try #require(await backend.sftpConfigurations.first)
let destinationPassword = try #require(await backend.observedDestinationPasswords.first)
let jumpPasswords = try #require(await backend.observedJumpPasswords.first)
#expect(configuration.username == "files-user")
#expect(password(in: configuration) == "files-secret")
#expect(destinationPassword == "files-secret")
#expect(configuration.jumps.count == 1)
#expect(configuration.jumps[0].id == jump.id)
#expect(configuration.jumps[0].username == "jump-user")
@@ -328,7 +330,8 @@ struct AgentHostAuthenticationTests {
Issue.record("Expected a jump-host password")
return
}
#expect(jumpPassword == "jump-secret")
#expect(jumpPasswords == ["jump-secret"])
#expect(jumpPassword.isConsumed)
}
@Test
@@ -377,12 +380,14 @@ struct AgentHostAuthenticationTests {
#expect(!result.isError)
#expect(await backend.sftpOpens == 1)
let configuration = try #require(await backend.sftpConfigurations.first)
let observedPassword = try #require(await backend.observedDestinationPasswords.first)
#expect(configuration.username == "deploy")
guard case .password(let password) = configuration.credentials.first else {
Issue.record("Expected the one-use password in the SFTP configuration")
return
}
#expect(password == "secret")
#expect(observedPassword == "secret")
#expect(password.isConsumed)
}
@Test
@@ -491,8 +496,14 @@ struct AgentHostAuthenticationTests {
#expect(await setup.backend.execOpens == 2)
#expect(await setup.backend.sftpOpens == 1)
let configurations = await setup.backend.sftpConfigurations
#expect(password(in: configurations[0]) == "old-secret")
#expect(password(in: configurations[1]) == "new-secret")
#expect(await setup.backend.observedDestinationPasswords == ["old-secret", "new-secret"])
#expect(
configurations.allSatisfy { configuration in
configuration.credentials.allSatisfy { credential in
guard case .password(let password) = credential else { return true }
return password.isConsumed
}
})
}
@Test
@@ -536,10 +547,50 @@ struct AgentHostAuthenticationTests {
)
let retained = try await session.configurationAttempt()
#expect(retained.generation == second.generation)
#expect(password(in: retained.configuration) == "new-secret")
#expect(password(in: retained.configuration, matches: "new-secret"))
#expect(await interaction.requestCount == 2)
}
@Test
func successfulAuthenticationConsumesAgentSecretsAndPromptsForAnotherConnection() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
var requests = 0
let interaction = AuthenticationResponder { _ in
requests += 1
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "one-use-\(requests)", save: false
))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let first = try await session.configurationAttempt()
guard case .password(let firstPassword) = first.configuration.credentials.first else {
Issue.record("Expected the first one-shot password")
return
}
#expect(firstPassword.matches("one-use-1"))
await session.didAuthenticate(generation: first.generation)
#expect(firstPassword.isConsumed)
let second = try await session.configurationAttempt()
guard case .password(let secondPassword) = second.configuration.credentials.first else {
Issue.record("Expected a newly entered password")
return
}
#expect(requests == 2)
#expect(secondPassword.matches("one-use-2"))
#expect(!secondPassword.isConsumed)
}
@Test
func connectionLossEvictsSFTPWithoutReplayingTheOperation() async throws {
let setup = authenticationSetup(sftpOperationFailures: 1)
@@ -1081,11 +1132,13 @@ struct AgentHostAuthenticationTests {
)
}
private func password(in configuration: SSHConnectionConfiguration) -> String? {
private func password(
in configuration: SSHConnectionConfiguration, matches expected: String
) -> Bool {
for credential in configuration.credentials {
if case .password(let password) = credential { return password }
if case .password(let password) = credential { return password.matches(expected) }
}
return nil
return false
}
}
@@ -1220,6 +1273,8 @@ private actor AuthenticationConnectionBackend {
private(set) var sftpSiblingChannels = 0
private(set) var sftpOperations = 0
private(set) var sftpConfigurations: [SSHConnectionConfiguration] = []
private(set) var observedDestinationPasswords: [String?] = []
private(set) var observedJumpPasswords: [[String?]] = []
private var sftpAuthenticationFailures: Int
private var sftpOperationFailures: Int
private var remoteFiles: [String: Data]
@@ -1236,6 +1291,10 @@ private actor AuthenticationConnectionBackend {
func openConnection(configuration: SSHConnectionConfiguration) throws -> AuthenticationSSHConnection {
execOpens += 1
observedDestinationPasswords.append(Self.passwordLabel(in: configuration.credentials))
observedJumpPasswords.append(
configuration.jumps.map { Self.passwordLabel(in: $0.credentials) }
)
sftpConfigurations.append(configuration)
if sftpAuthenticationFailures > 0 {
sftpAuthenticationFailures -= 1
@@ -1244,6 +1303,17 @@ private actor AuthenticationConnectionBackend {
return AuthenticationSSHConnection(backend: self)
}
private static func passwordLabel(in credentials: [SSHCredentials]) -> String? {
guard
case .password(let password)? = credentials.first(where: {
if case .password = $0 { return true }
return false
})
else { return nil }
return ["files-secret", "jump-secret", "secret", "old-secret", "new-secret", "fallback-password"]
.first(where: password.matches)
}
func openSFTP(
configuration: SSHConnectionConfiguration
) throws -> AuthenticationSFTPClient {
+10 -6
View File
@@ -125,9 +125,12 @@ nonisolated struct SSHForwardingWireTests {
primary.authorizeHostKey = nil
try await pool.connect(primary, progress: nil)
await pool.configureAdditionalConnections {
SSHConnectionRequest(
configuration: primary.configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
var additional = request(fixture, jumps: 0)
additional.configuration.expectedHostKeys = [
SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: fingerprint)
]
additional.interactionPolicy = .forbidden
return additional.enforcingInteractionPolicy()
}
let local = try rule(.local, fixture: fixture)
try await pool.start(local, generation: UUID())
@@ -276,21 +279,22 @@ nonisolated struct SSHForwardingWireTests {
private func request(_ fixture: Fixture, jumps: Int, username: String = "none") -> SSHConnectionRequest {
let user = fixture.username ?? username
let credentials: [SSHCredentials] =
func credentials() -> [SSHCredentials] {
fixture.username.flatMap { _ in fixture.privateKey }.map {
[.privateKey(privateKey: $0, publicKey: nil, passphrase: nil)]
} ?? []
}
let expectedHostKeys: Set<SSHHostKeyIdentity> =
fixture.hostKeyFingerprint.map {
[SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: $0)]
} ?? []
var configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: user, credentials: credentials,
hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: user, credentials: credentials(),
expectedHostKeys: expectedHostKeys)
configuration.jumps = (0..<jumps).map { _ in
SSHRouteHopConfiguration(
id: UUID(), name: "Fixture", host: "127.0.0.1", port: fixture.port, username: user,
credentials: credentials, expectedHostKeys: expectedHostKeys)
credentials: credentials(), expectedHostKeys: expectedHostKeys)
}
return SSHConnectionRequest(configuration: configuration, authorizeHostKey: { _ in .accept })
}