Fix identities last used time
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user