diff --git a/FrameworkTests/IdentityCredentialUsageTests.swift b/FrameworkTests/IdentityCredentialUsageTests.swift new file mode 100644 index 00000000..19e38eb8 --- /dev/null +++ b/FrameworkTests/IdentityCredentialUsageTests.swift @@ -0,0 +1,209 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import Testing + +@Suite(.serialized) +@MainActor +struct IdentityCredentialUsageTests { + @Test + func connectionReadsUpdateUsageButAvailabilityChecksDoNot() throws { + let keychain = InMemorySecretStore() + let reference = "termind.password.\(UUID().uuidString)" + let identity = Identity( + name: "Deploy", username: "deploy", + credentialRef: reference, passwordStorage: .thisDevice + ) + let store = HostLibraryModel(identities: [identity], secrets: keychain) + try keychain.setString("secret", for: reference, storage: .thisDevice) + + #expect(store.passwordAvailability(for: identity) == .available) + #expect(store.identity(identity.id)?.lastUsedAt == nil) + + _ = store.sshCredentials(for: identity) + #expect(store.identity(identity.id)?.lastUsedAt != nil) + } + + @Test + func missingConnectionSecretsDoNotUpdateUsage() { + let identity = Identity( + name: "Deploy", username: "deploy", + credentialRef: "termind.password.missing", passwordStorage: .thisDevice + ) + let store = HostLibraryModel( + identities: [identity], secrets: InMemorySecretStore() + ) + + _ = store.sshCredentials(for: identity) + + #expect(store.identity(identity.id)?.lastUsedAt == nil) + } + + @Test + func privateKeyAvailabilityCheckDoesNotUpdateUsage() throws { + let keychain = InMemorySecretStore() + let key = SSHKeyRef( + label: "Deploy", publicKey: "ssh-ed25519 test", + credentialRef: "termind.key.availability" + ) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let store = HostLibraryModel( + keys: [key], identities: [identity], secrets: keychain + ) + try keychain.setKeyCredential( + .software(SSHPrivateKeySecret(privateKey: "private")), + for: key.credentialRef, storage: key.storage + ) + + #expect(store.keyAvailability(for: key) == .available) + #expect(store.identity(identity.id)?.lastUsedAt == nil) + } + + @Test(arguments: [ + StoredSSHKeyCredential.software(SSHPrivateKeySecret(privateKey: "private")), + StoredSSHKeyCredential.secureEnclave( + SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8)) + ), + ]) + func savedPrivateKeyReadsUpdateUsage(_ credential: StoredSSHKeyCredential) throws { + let keychain = InMemorySecretStore() + let key = SSHKeyRef( + label: "Deploy", publicKey: "ssh-ed25519 test", + credentialRef: "termind.key.\(UUID().uuidString)" + ) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let store = HostLibraryModel( + keys: [key], identities: [identity], secrets: keychain + ) + try keychain.setKeyCredential( + credential, for: key.credentialRef, storage: key.storage + ) + + _ = store.sshCredentials(for: identity) + + #expect(store.identity(identity.id)?.lastUsedAt != nil) + } + + @Test + func routeCredentialReadsArePersistedOnceForTargetJumpAndProxy() throws { + let keychain = InMemorySecretStore() + let targetIdentity = Identity( + name: "Target", username: "target", + credentialRef: "password.target", passwordStorage: .thisDevice + ) + let jumpIdentity = Identity( + name: "Jump", username: "jump", + credentialRef: "password.jump", passwordStorage: .thisDevice + ) + let proxyIdentity = Identity( + name: "Proxy", username: "proxy", + credentialRef: "password.proxy", passwordStorage: .thisDevice + ) + let jump = Host( + name: "Jump", host: "jump.invalid", identityID: jumpIdentity.id, + proxy: HostProxy( + hostname: "proxy.invalid", username: "proxy", + identityID: proxyIdentity.id + ) + ) + let target = Host( + name: "Target", host: "target.invalid", identityID: targetIdentity.id, + jumpHostID: jump.id + ) + let snapshot = VaultSnapshot( + hosts: [jump, target], + identities: [targetIdentity, jumpIdentity, proxyIdentity] + ) + let persistence = CredentialAccessPersistence(snapshot: snapshot) + let store = HostLibraryModel(persistence: persistence, secrets: keychain) + for identity in snapshot.identities { + try keychain.setString( + "secret", for: identity.credentialRef, storage: identity.passwordStorage + ) + } + + _ = try store.sshConnectionConfiguration( + for: target, trustPolicy: .allowChallenge + ) + + let dates = snapshot.identities.compactMap { store.identity($0.id)?.lastUsedAt } + #expect(dates.count == 3) + #expect(Set(dates).count == 1) + #expect(persistence.saveCount == 1) + } + + @Test + func usageDatesAreMonotonicAndFailedPersistenceRollsBack() { + let identity = Identity(name: "Deploy", username: "deploy") + let persistence = CredentialAccessPersistence( + snapshot: VaultSnapshot(identities: [identity]) + ) + let store = HostLibraryModel(persistence: persistence) + let newer = Date(timeIntervalSince1970: 200) + + store.recordIdentityCredentialAccess([identity.id, UUID()], at: newer) + store.recordIdentityCredentialAccess( + [identity.id], at: Date(timeIntervalSince1970: 100) + ) + + #expect(store.identity(identity.id)?.lastUsedAt == newer) + #expect(persistence.saveCount == 1) + + persistence.failsSave = true + store.recordIdentityCredentialAccess( + [identity.id], at: Date(timeIntervalSince1970: 300) + ) + #expect(store.identity(identity.id)?.lastUsedAt == newer) + #expect(store.storageError != nil) + } + + @Test + func detachedPreparationPublishesCredentialUsageBackToTheLibrary() async throws { + let keychain = InMemorySecretStore() + let identity = Identity( + name: "Deploy", username: "deploy", + credentialRef: "password.detached", passwordStorage: .thisDevice + ) + let host = Host( + name: "Server", host: "server.invalid", identityID: identity.id + ) + let store = HostLibraryModel( + hosts: [host], identities: [identity], secrets: keychain + ) + try keychain.setString( + "secret", for: identity.credentialRef, storage: identity.passwordStorage + ) + + let preparation = try store.prepareSSHConnection(for: host, submissions: [:]) + _ = try await preparation.resolve(trustPolicy: .allowChallenge) + for _ in 0..<100 where store.identity(identity.id)?.lastUsedAt == nil { + await Task.yield() + } + + #expect(store.identity(identity.id)?.lastUsedAt != nil) + } +} + +@MainActor +private final class CredentialAccessPersistence: VaultPersisting { + let snapshot: VaultSnapshot + var saveCount = 0 + var failsSave = false + + init(snapshot: VaultSnapshot) { + self.snapshot = snapshot + } + + func load() throws -> VaultSnapshot { snapshot } + + func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { + saveCount += 1 + if failsSave { throw CocoaError(.fileWriteUnknown) } + return .empty + } + + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: []) + } +} diff --git a/FrameworkTests/SSHConnectionPreparationTests.swift b/FrameworkTests/SSHConnectionPreparationTests.swift index 34e5f58d..91077c61 100644 --- a/FrameworkTests/SSHConnectionPreparationTests.swift +++ b/FrameworkTests/SSHConnectionPreparationTests.swift @@ -61,9 +61,113 @@ struct SSHConnectionPreparationTests { } } + @Test + func reportsJumpAndProxyReadsEvenWhenTheTargetCredentialIsMissing() async { + let jumpIdentity = Identity( + name: "Jump", username: "jump", credentialRef: "password.jump" + ) + let proxyIdentity = Identity( + name: "Proxy", username: "proxy", credentialRef: "password.proxy" + ) + let targetIdentity = Identity( + name: "Target", username: "target", credentialRef: "password.target" + ) + let jump = Host( + name: "Jump", host: "jump.invalid", identityID: jumpIdentity.id, + proxy: HostProxy( + hostname: "proxy.invalid", username: "proxy", + identityID: proxyIdentity.id + ) + ) + let target = Host( + name: "Target", host: "target.invalid", identityID: targetIdentity.id, + jumpHostID: jump.id + ) + let recorder = PreparationAccessRecorder() + let preparation = SSHConnectionPreparation( + nodes: [ + SSHConnectionPreparation.Node( + host: jump, identity: jumpIdentity, key: nil, submission: nil, + trustedKeys: [], proxyIdentity: proxyIdentity + ), + SSHConnectionPreparation.Node( + host: target, identity: targetIdentity, key: nil, submission: nil, + trustedKeys: [], proxyIdentity: nil + ), + ], + secrets: MappedPreparationSecrets( + passwords: [ + jumpIdentity.credentialRef: "jump-secret", + proxyIdentity.credentialRef: "proxy-secret", + ] + ), + credentialAccessHandler: { recorder.record($0, at: $1) } + ) + + await #expect(throws: TermindSSHError.self) { + _ = try await preparation.resolve(trustPolicy: .allowChallenge) + } + #expect(recorder.identityIDs == [jumpIdentity.id, proxyIdentity.id]) + #expect(recorder.callCount == 1) + } + + @Test + func protectedKeyReadIsReportedBeforePassphraseIsRequested() async { + let key = SSHKeyRef( + label: "Protected", publicKey: "ssh-ed25519 test", + credentialRef: "key.protected", hasPassphrase: true + ) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let recorder = PreparationAccessRecorder() + let preparation = makePreparation( + identity: identity, key: key, + secrets: MappedPreparationSecrets( + keys: [ + key.credentialRef: .software( + SSHPrivateKeySecret(privateKey: "encrypted") + ) + ] + ), + credentialAccessHandler: { recorder.record($0, at: $1) } + ) + + await #expect(throws: TermindSSHError.self) { + _ = try await preparation.resolve(trustPolicy: .allowChallenge) + } + #expect(recorder.identityIDs == [identity.id]) + #expect(recorder.callCount == 1) + } + + @Test + func inlinePasswordIsNotReportedAsSavedIdentityAccess() async throws { + let identity = Identity(name: "Deploy", username: "deploy") + let host = Host(name: "Server", host: "server.invalid", identityID: identity.id) + let recorder = PreparationAccessRecorder() + let preparation = SSHConnectionPreparation( + nodes: [ + SSHConnectionPreparation.Node( + host: host, identity: identity, key: nil, + submission: SSHAuthenticationSubmission( + username: identity.username, password: "inline", + identityID: identity.id + ), + trustedKeys: [], proxyIdentity: nil + ) + ], + secrets: MappedPreparationSecrets(), + credentialAccessHandler: { recorder.record($0, at: $1) } + ) + + _ = try await preparation.resolve(trustPolicy: .allowChallenge) + + #expect(recorder.identityIDs.isEmpty) + #expect(recorder.callCount == 0) + } + private func makePreparation( identity: Identity, key: SSHKeyRef? = nil, - secrets: any SecretStoring + secrets: any SecretStoring, + credentialAccessHandler: SSHCredentialAccessHandler? = nil ) -> SSHConnectionPreparation { let host = Host(name: "Monitor", host: "example.invalid", identityID: identity.id) return SSHConnectionPreparation( @@ -73,11 +177,60 @@ struct SSHConnectionPreparationTests { trustedKeys: [], proxyIdentity: nil ) ], - secrets: secrets + secrets: secrets, + credentialAccessHandler: credentialAccessHandler ) } } +private nonisolated final class PreparationAccessRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedIdentityIDs: Set = [] + private var recordedCallCount = 0 + + var identityIDs: Set { lock.withLock { recordedIdentityIDs } } + var callCount: Int { lock.withLock { recordedCallCount } } + + func record(_ identityIDs: Set, at date: Date) { + lock.withLock { + recordedIdentityIDs.formUnion(identityIDs) + recordedCallCount += 1 + } + } +} + +private final class MappedPreparationSecrets: SecretStoring, @unchecked Sendable { + private let passwords: [String: String] + private let keys: [String: StoredSSHKeyCredential] + + init( + passwords: [String: String] = [:], + keys: [String: StoredSSHKeyCredential] = [:] + ) { + self.passwords = passwords + self.keys = keys + } + + func string(for account: String, storage: PasswordStorage) throws -> String? { + passwords[account] + } + + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {} + + func keyCredential( + for account: String, storage: KeyStorage + ) throws -> StoredSSHKeyCredential? { + keys[account] + } + + func setKeyCredential( + _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage + ) throws {} + + func removePassword(_ account: String, storage: PasswordStorage) throws {} + func removeKeyCredential(_ account: String, storage: KeyStorage) throws {} +} + private enum PreparationSecretError: Error { case unavailable } diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index d7755eac..38e797db 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -35,6 +35,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { func sshCredentials(for identity: Identity) -> SSHCredentialResolution func passwordCredential(for identity: Identity) throws -> String? func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? + func recordIdentityCredentialAccess(_ identityIDs: Set, at date: Date) func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) func trust(_ entry: KnownHostEntry) @@ -45,6 +46,24 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { } extension RemoteSessionHostStore { + public func connectionPasswordCredential(for identity: Identity) throws -> String? { + let password = try passwordCredential(for: identity) + if password != nil { + recordIdentityCredentialAccess([identity.id], at: Date()) + } + return password + } + + public func connectionKeyCredential( + for key: SSHKeyRef, identityID: Identity.ID? + ) throws -> StoredSSHKeyCredential? { + let credential = try keyCredential(for: key) + if credential != nil, let identityID { + recordIdentityCredentialAccess([identityID], at: Date()) + } + return credential + } + public func expectedHostKeys( for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] ) throws -> Set { diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 5899be9c..97a91a6e 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -3,6 +3,8 @@ import TermindDomain import struct TermindDomain.Host +public typealias SSHCredentialAccessHandler = @Sendable (Set, Date) -> Void + /// A value snapshot taken on MainActor. Resolving Keychain references can block and /// therefore happens on a detached worker, before any SSH transport is opened. public nonisolated struct SSHConnectionPreparation: Sendable { @@ -28,6 +30,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } public var nodes: [Node] public var secrets: any SecretStoring + public var credentialAccessHandler: SSHCredentialAccessHandler? public func resolve( discoveryOnly: Bool = false, @@ -51,6 +54,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable { private func resolveSynchronously( discoveryOnly: Bool, trustPolicy: SSHHostTrustPolicy ) throws -> SSHConnectionConfiguration { + var accessedIdentityIDs: Set = [] + defer { + if !accessedIdentityIDs.isEmpty { + credentialAccessHandler?(accessedIdentityIDs, Date()) + } + } guard let target = nodes.last else { throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) } @@ -58,7 +67,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { for (offset, node) in nodes.dropLast().enumerated() { do { try Task.checkCancellation() - let material = try credentials(node) + let material = try credentials(node, accessedIdentityIDs: &accessedIdentityIDs) if trustPolicy == .requireKnownHost, node.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } @@ -66,7 +75,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable { SSHRouteHopConfiguration( id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port, username: material.0, credentials: material.1, - expectedHostKeys: node.trustedKeys, proxy: try proxy(node) + expectedHostKeys: node.trustedKeys, + proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs) )) } catch { if error is CancellationError { throw error } @@ -80,38 +90,49 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } } try Task.checkCancellation() - let material = discoveryOnly ? ("", [SSHCredentials]()) : try credentials(target) + let material = + discoveryOnly + ? ("", [SSHCredentials]()) + : try credentials(target, accessedIdentityIDs: &accessedIdentityIDs) if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } return SSHConnectionConfiguration( hostID: target.host.id, host: target.host.host, port: target.host.port, username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys, - proxy: try proxy(target), jumps: jumps + proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps ) } - private func credentials(_ node: Node) throws -> (String, [SSHCredentials]) { + private func credentials( + _ node: Node, accessedIdentityIDs: inout Set + ) throws -> (String, [SSHCredentials]) { let username = (node.submission?.username ?? node.identity?.username ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) guard !username.isEmpty else { throw TermindSSHError.missingUsername } var result: [SSHCredentials] = [] var retrievalFailure: (any Error)? if let key = node.key { - if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { - throw TermindSSHError.keyPassphraseRequired - } + let stored: StoredSSHKeyCredential? do { - if let credential = try secrets.keyCredential(for: key.credentialRef, storage: key.storage) { - result.append( - credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: node.submission?.passphrase - )) + stored = try secrets.keyCredential(for: key.credentialRef, storage: key.storage) + if stored != nil, let identity = connectionIdentity(for: node) { + accessedIdentityIDs.insert(identity.id) } } catch { + stored = nil 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, + passphraseOverride: node.submission?.passphrase + )) + } } try Task.checkCancellation() if let password = node.submission?.password { @@ -124,6 +145,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { for: identity.credentialRef, storage: identity.passwordStorage ) { result.append(.password(password)) + accessedIdentityIDs.insert(identity.id) } } catch { if retrievalFailure == nil { retrievalFailure = error } @@ -134,7 +156,9 @@ public nonisolated struct SSHConnectionPreparation: Sendable { return (username, result) } - private func proxy(_ node: Node) throws -> SSHTransportProxy? { + private func proxy( + _ node: Node, accessedIdentityIDs: inout Set + ) throws -> SSHTransportProxy? { guard let proxy = node.host.proxy else { return nil } guard proxy.isValid else { throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) @@ -148,6 +172,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { guard password != nil else { throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) } + accessedIdentityIDs.insert(identity.id) } return SSHTransportProxy( kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, @@ -159,9 +184,18 @@ public nonisolated struct SSHConnectionPreparation: Sendable { if case .untrustedHost = error { return true } return false } - public init(nodes: [Node], secrets: any SecretStoring) { + private func connectionIdentity(for node: Node) -> Identity? { + if node.submission != nil, node.submission?.identityID == nil { return nil } + return node.identity + } + + public init( + nodes: [Node], secrets: any SecretStoring, + credentialAccessHandler: SSHCredentialAccessHandler? = nil + ) { self.nodes = nodes self.secrets = secrets + self.credentialAccessHandler = credentialAccessHandler } } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 17416607..9186ad2a 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -333,7 +333,12 @@ public final class HostLibraryModel: RemoteSessionHostStore { proxyIdentity: node.proxy?.identityID.flatMap { self.identity($0) } ) }, - secrets: keychain + secrets: keychain, + credentialAccessHandler: { [weak self] identityIDs, date in + Task { @MainActor [weak self] in + self?.recordIdentityCredentialAccess(identityIDs, at: date) + } + } ) } @@ -342,20 +347,40 @@ public final class HostLibraryModel: RemoteSessionHostStore { submissions: [Host.ID: SSHAuthenticationSubmission] = [:], temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:], trustPolicy: SSHHostTrustPolicy = .requireKnownHost + ) throws -> SSHConnectionConfiguration { + var accessedIdentityIDs: Set = [] + defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) } + return try sshConnectionConfiguration( + for: host, submissions: submissions, + temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy, + accessedIdentityIDs: &accessedIdentityIDs + ) + } + + private func sshConnectionConfiguration( + for host: Host, + submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], + trustPolicy: SSHHostTrustPolicy, + accessedIdentityIDs: inout Set ) throws -> SSHConnectionConfiguration { let username: String let credentials: [SSHCredentials] if let submission = submissions[host.id] { username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) guard !username.isEmpty else { throw TermindSSHError.missingUsername } - credentials = try sshCredentials(for: submission) + credentials = try sshCredentials( + for: submission, accessedIdentityIDs: &accessedIdentityIDs + ) } else { guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential } username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) guard !username.isEmpty else { throw TermindSSHError.missingUsername } - let resolution = sshCredentials(for: identity) + let resolution = sshCredentials( + for: identity, accessedIdentityIDs: &accessedIdentityIDs + ) guard resolution.requiredPassphraseKeyID == nil else { throw TermindSSHError.keyPassphraseRequired } @@ -371,11 +396,14 @@ public final class HostLibraryModel: RemoteSessionHostStore { hostID: host.id, host: host.host, port: host.port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host), + proxy: try transportProxy( + for: host, accessedIdentityIDs: &accessedIdentityIDs + ), jumps: try sshJumpConfigurations( for: host, submissions: submissions, temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy + trustPolicy: trustPolicy, + accessedIdentityIDs: &accessedIdentityIDs ) ) } @@ -385,6 +413,22 @@ public final class HostLibraryModel: RemoteSessionHostStore { submissions: [Host.ID: SSHAuthenticationSubmission], temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:], trustPolicy: SSHHostTrustPolicy = .requireKnownHost + ) throws -> [SSHRouteHopConfiguration] { + var accessedIdentityIDs: Set = [] + defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) } + return try sshJumpConfigurations( + for: host, submissions: submissions, + temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy, + accessedIdentityIDs: &accessedIdentityIDs + ) + } + + private func sshJumpConfigurations( + for host: Host, + submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], + trustPolicy: SSHHostTrustPolicy, + accessedIdentityIDs: inout Set ) throws -> [SSHRouteHopConfiguration] { guard let route = validatedRoute(for: host) else { throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) @@ -395,12 +439,14 @@ public final class HostLibraryModel: RemoteSessionHostStore { return try sshRouteHopConfiguration( for: jump, submission: submission, temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy + trustPolicy: trustPolicy, + accessedIdentityIDs: &accessedIdentityIDs ) } return try sshRouteHopConfiguration( for: jump, temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy + trustPolicy: trustPolicy, + accessedIdentityIDs: &accessedIdentityIDs ) } catch { let authentication = @@ -424,7 +470,8 @@ public final class HostLibraryModel: RemoteSessionHostStore { private func sshRouteHopConfiguration( for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], - trustPolicy: SSHHostTrustPolicy + trustPolicy: SSHHostTrustPolicy, + accessedIdentityIDs: inout Set ) throws -> SSHRouteHopConfiguration { guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential } let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) @@ -433,7 +480,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { for: host, temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy ) - let resolution = sshCredentials(for: identity) + let resolution = sshCredentials( + for: identity, accessedIdentityIDs: &accessedIdentityIDs + ) guard resolution.requiredPassphraseKeyID == nil else { throw TermindSSHError.keyPassphraseRequired } @@ -442,14 +491,17 @@ public final class HostLibraryModel: RemoteSessionHostStore { id: host.id, name: host.name, host: host.host, port: host.port, username: username, credentials: resolution.credentials, expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host) + proxy: try transportProxy( + for: host, accessedIdentityIDs: &accessedIdentityIDs + ) ) } private func sshRouteHopConfiguration( for host: Host, submission: SSHAuthenticationSubmission, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], - trustPolicy: SSHHostTrustPolicy + trustPolicy: SSHHostTrustPolicy, + accessedIdentityIDs: inout Set ) throws -> SSHRouteHopConfiguration { let username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) guard !username.isEmpty else { throw TermindSSHError.missingUsername } @@ -457,27 +509,35 @@ public final class HostLibraryModel: RemoteSessionHostStore { for: host, temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy ) - let credentials = try sshCredentials(for: submission) + let credentials = try sshCredentials( + for: submission, accessedIdentityIDs: &accessedIdentityIDs + ) guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } return SSHRouteHopConfiguration( id: host.id, name: host.name, host: host.host, port: host.port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host) + proxy: try transportProxy( + for: host, accessedIdentityIDs: &accessedIdentityIDs + ) ) } private func sshCredentials( - for submission: SSHAuthenticationSubmission + for submission: SSHAuthenticationSubmission, + accessedIdentityIDs: inout Set ) throws -> [SSHCredentials] { var credentials: [SSHCredentials] = [] if let keyID = submission.keyID, let key = key(keyID) { - if key.requiresPassphraseEntry, - submission.passphrase?.isEmpty != false - { - throw TermindSSHError.keyPassphraseRequired - } if let stored = try keyCredential(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, @@ -491,6 +551,16 @@ public final class HostLibraryModel: RemoteSessionHostStore { } public func transportProxy(for host: Host) throws -> SSHTransportProxy? { + var accessedIdentityIDs: Set = [] + defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) } + return try transportProxy( + for: host, accessedIdentityIDs: &accessedIdentityIDs + ) + } + + private func transportProxy( + for host: Host, accessedIdentityIDs: inout Set + ) throws -> SSHTransportProxy? { guard let proxy = host.proxy else { return nil } guard proxy.isValid else { throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) @@ -504,6 +574,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { guard password != nil else { throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) } + accessedIdentityIDs.insert(identity.id) } return SSHTransportProxy( kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, @@ -514,6 +585,16 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// Loads Key and Password independently so one damaged or missing Keychain /// item does not suppress the other usable credential. public func sshCredentials(for identity: Identity) -> SSHCredentialResolution { + var accessedIdentityIDs: Set = [] + defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) } + return sshCredentials( + for: identity, accessedIdentityIDs: &accessedIdentityIDs + ) + } + + private func sshCredentials( + for identity: Identity, accessedIdentityIDs: inout Set + ) -> SSHCredentialResolution { var credentials: [SSHCredentials] = [] var failures: [String] = [] var requiredPassphraseKeyID: SSHKeyRef.ID? @@ -522,6 +603,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { if let key = key(keyID) { do { if let credential = try keyCredential(for: key) { + accessedIdentityIDs.insert(identity.id) if key.requiresPassphraseEntry { requiredPassphraseKeyID = key.id } else { @@ -551,6 +633,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { do { if let password = try passwordCredential(for: identity) { credentials.append(.password(password)) + accessedIdentityIDs.insert(identity.id) } else { failures.append( String(localized: "The saved password is no longer available.") @@ -569,6 +652,24 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) } + /// Persists connection-specific credential access without turning ordinary + /// Keychain inspection into Identity usage. A failed save restores the + /// observable projection while retaining the storage error for presentation. + public func recordIdentityCredentialAccess( + _ identityIDs: Set, at date: Date = Date() + ) { + guard !identityIDs.isEmpty else { return } + let previous = identities + var changed = false + for index in identities.indices where identityIDs.contains(identities[index].id) { + if identities[index].lastUsedAt.map({ $0 >= date }) == true { continue } + identities[index].lastUsedAt = date + changed = true + } + guard changed else { return } + if !persistCore() { identities = previous } + } + /// Every host tag, deduplicated case-insensitively and sorted. public var allTags: [String] { uniqueSortedTags(hosts.flatMap(\.tags)) diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index b1af15de..a1335857 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -356,7 +356,7 @@ public final class AgentHostResolver: Sendable { guard let identity = store.identity(identityID) else { throw TermindSSHError.missingCredential } - let password = try store.passwordCredential(for: identity) + let password = try store.connectionPasswordCredential(for: identity) if answer.save { var updated = host updated.identityID = identity.id diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 9e6c85db..70cd6199 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -97,6 +97,13 @@ authentication; callers never perform a separate discovery connection. Generation tokens prevent callbacks, credential results, and retry timers from a retired connection mutating its replacement. +An Identity's persisted `lastUsedAt` means its saved password or private key was +successfully read for connection setup, not that remote authentication succeeded. +Connection-specific resolvers batch those accesses across destinations, jump hosts, +and authenticated proxies; missing secrets and ordinary Keychain availability or +editing reads never update usage. Later transport failure does not roll the access +back, while pooled or borrowed live connections do not reread or refresh it. + The SSH worker's serial queue owns PTY dimensions. It retains the latest valid viewport during authentication and uses it for the initial PTY request, including pixel dimensions. Nonblocking window-change requests retain their original size diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index ad1f9366..69ecec25 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -69,6 +69,9 @@ extension TransferLibraryModel: RemoteSessionHostStore { func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { try library.keyCredential(for: key) } + func recordIdentityCredentialAccess(_ identityIDs: Set, at date: Date) { + library.recordIdentityCredentialAccess(identityIDs, at: date) + } func setConnectionStatus( _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID ) { diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index 1387a377..eaf42dde 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -358,7 +358,10 @@ final class SFTPSessionModel: Identifiable { ) var credentials: [SSHCredentials] = [] if let keyID = suppliedAuthentication.keyID, - let key = store.key(keyID), let stored = try? store.keyCredential(for: key) + let key = store.key(keyID), + let stored = try? store.connectionKeyCredential( + for: key, identityID: suppliedAuthentication.identityID + ) { credentials.append( stored.sshCredential( @@ -604,7 +607,7 @@ final class SFTPSessionModel: Identifiable { submittedPassword = try password ?? selectedIdentity.flatMap { - try store.passwordCredential(for: $0) + try store.connectionPasswordCredential(for: $0) } } catch { requestAuthentication(host: host, store: store, failure: error.localizedDescription) diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index c2da9fa2..e74fe5a6 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -478,7 +478,9 @@ final class SSHSessionModel: Identifiable { if let keyID = suppliedAuthentication.keyID { if let key = store.key(keyID) { do { - if let credential = try store.keyCredential(for: key) { + if let credential = try store.connectionKeyCredential( + for: key, identityID: suppliedAuthentication.identityID + ) { credentials.append( credential.sshCredential( publicKey: key.publicKey, certificate: key.certificate, @@ -664,7 +666,7 @@ final class SSHSessionModel: Identifiable { submittedPassword = try password ?? selectedIdentity.flatMap { - try store.passwordCredential(for: $0) + try store.connectionPasswordCredential(for: $0) } } catch { requestAuthentication(