Fix identities last used time

This commit is contained in:
Kaede Akino committed 2026-09-15 13:23:37 +08:00
1 parent 0bf1144039
commit 9e1426393a
10 files changed
+573 -42

No files matched your search

@@ -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: [])
}
}
@@ -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<Identity.ID> = []
private var recordedCallCount = 0
var identityIDs: Set<Identity.ID> { lock.withLock { recordedIdentityIDs } }
var callCount: Int { lock.withLock { recordedCallCount } }
func record(_ identityIDs: Set<Identity.ID>, 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
}
@@ -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<Identity.ID>, 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<SSHHostKeyIdentity> {
@@ -3,6 +3,8 @@ import TermindDomain
import struct TermindDomain.Host
public typealias SSHCredentialAccessHandler = @Sendable (Set<Identity.ID>, 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<Identity.ID> = []
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<Identity.ID>
) 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<Identity.ID>
) 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
}
}
@@ -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<Identity.ID> = []
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<Identity.ID>
) 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<Identity.ID> = []
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<Identity.ID>
) 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<Identity.ID>
) 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<Identity.ID>
) 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<Identity.ID>
) 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<Identity.ID> = []
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
)
}
private func transportProxy(
for host: Host, accessedIdentityIDs: inout Set<Identity.ID>
) 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<Identity.ID> = []
defer { recordIdentityCredentialAccess(accessedIdentityIDs, at: Date()) }
return sshCredentials(
for: identity, accessedIdentityIDs: &accessedIdentityIDs
)
}
private func sshCredentials(
for identity: Identity, accessedIdentityIDs: inout Set<Identity.ID>
) -> 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<Identity.ID>, 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))
@@ -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
+7
View File
@@ -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
@@ -69,6 +69,9 @@ extension TransferLibraryModel: RemoteSessionHostStore {
func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? {
try library.keyCredential(for: key)
}
func recordIdentityCredentialAccess(_ identityIDs: Set<Identity.ID>, at date: Date) {
library.recordIdentityCredentialAccess(identityIDs, at: date)
}
func setConnectionStatus(
_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID
) {
+5 -2
View File
@@ -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)
+4 -2
View File
@@ -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(