Clear SSH secrets after each connection attempt
This commit is contained in:
1 parent
d9df7b631e
commit
cb106ba15d
30 files changed
+910
-148
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user