Files
Termind/FrameworkTests/SSHInteractionPolicyTests.swift
T

312 lines
14 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import Testing
@Suite @MainActor
struct SSHInteractionPolicyTests {
@Test func allSecurityKeysRequireInteractionRegardlessOfUPIncludingJumps() {
let silent = SecurityKeyCredential(credentialID: Data([1]), publicKeyX963: Data(), requiresUserPresence: false)
let touching = SecurityKeyCredential(credentialID: Data([2]), publicKeyX963: Data(), requiresUserPresence: true)
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "fixture.invalid", port: 22, username: "user",
credentials: [.securityKey(silent), .securityKey(touching)], expectedHostKeys: [],
jumps: [
.init(
id: UUID(), name: "Jump", host: "jump.invalid", port: 22, username: "user",
credentials: [.securityKey(silent), .securityKey(touching)], expectedHostKeys: [])
])
let request = SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
.enforcingInteractionPolicy()
for credentials in [request.configuration.credentials, request.configuration.jumps[0].credentials] {
guard case .interactionRequired = credentials[0], case .interactionRequired = credentials[1] else {
Issue.record("Every security key must be blocked during silent authentication")
return
}
}
}
@Test func forbiddenPolicyCannotInvokeExplicitOrFallbackForms() async throws {
let queue = SSHKeyboardInteractiveQueue()
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "example.invalid", port: 22, username: "user", credentials: [], expectedHostKeys: [])
let request = SSHConnectionRequest(
configuration: configuration,
authorizeHostKey: { _ in
Issue.record("Host key form must not open")
return .accept
},
keyboardInteractive: queue.handler, interactionPolicy: .forbidden
).withKeyboardInteractiveFallback(queue.handler).enforcingInteractionPolicy()
let handler = try #require(request.keyboardInteractive)
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: configuration.hostID, hostname: configuration.host,
port: 22, username: "user", round: 1, name: "OTP", instruction: "",
prompts: [.init(text: "Code", echo: false)])
await #expect(throws: SSHInteractionRequired.self) { try await handler(challenge) }
#expect(queue.pending.isEmpty)
let authorize = try #require(request.authorizeHostKey)
await #expect(throws: SSHInteractionRequired.self) {
try await authorize(
SSHHostKeyChallenge(
hostID: configuration.hostID,
observation: SSHHostKeyObservation(
hostname: configuration.host, port: 22, algorithm: .ed25519, fingerprint: "new"),
expectedHostKeys: []))
}
}
@Test func additionalSnapshotRetainsRouteAndIDsWithoutCredentialMaterial() {
let host = Host(name: "Server", host: "example.invalid")
let jump = Host(name: "Jump", host: "jump.invalid")
let identityID = UUID()
let keyID = UUID()
let trusted = KnownHostEntry(hostname: host.host, port: 22, algorithm: .ed25519, fingerprint: "saved")
let store = HostLibraryModel(hosts: [host, jump], knownHosts: [trusted])
let configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: 22, username: "temporary-user",
credentials: [.password("temporary-password")], expectedHostKeys: [],
jumps: [
SSHRouteHopConfiguration(
id: jump.id, name: jump.name, host: jump.host, port: 22, username: "jump-user", credentials: [],
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: "once-only")])
],
authenticationReference: SSHAuthenticationReference(
identityID: identityID, fallbackKeyID: keyID,
username: "temporary-user"
))
var withReference = configuration
withReference.jumps[0].authenticationReference = SSHAuthenticationReference(
fallbackKeyID: keyID, username: "jump-user", permitsNone: false
)
let additional = store.additionalConnectionSnapshot(from: withReference)
#expect(additional.target.expectedHostKeys == [trusted.identity])
#expect(additional.jumps[0].expectedHostKeys.isEmpty)
#expect(additional.target.host == host.host)
#expect(additional.target.authentication.username == "temporary-user")
#expect(additional.target.authentication.identityID == identityID)
#expect(additional.target.authentication.fallbackKeyID == keyID)
#expect(additional.jumps[0].authentication.username == "jump-user")
#expect(additional.jumps[0].authentication.fallbackKeyID == keyID)
}
@Test func forbiddenPolicyReplacesProtectedKeysButKeepsPasswordFallback() {
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "example.invalid", port: 22, username: "user",
credentials: [
.secureEnclave(
credential: SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8)),
publicKey: "public", requiresUserPresence: true
),
.privateKey(
privateKey: "encrypted", publicKey: "public", passphrase: "one-time",
requiresPassphrase: true
),
.password("saved-password"),
], expectedHostKeys: [])
let request = SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
#expect(request.configuration.credentials.count == 3)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("Touch ID key must become an interaction marker")
return
}
guard case .interactionRequired = request.configuration.credentials[1] else {
Issue.record("Passphrase key must become an interaction marker")
return
}
guard case .password(let password) = request.configuration.credentials[2] else {
Issue.record("Password fallback must remain available")
return
}
#expect(password == "saved-password")
}
@Test func additionalResolutionUsesCurrentIdentityAndSkipsProtectedKeyReads() throws {
let secrets = CountingSecretStore()
let protectedKey = SSHKeyRef(
label: "Touch ID", publicKey: "ecdsa-sha2-nistp256 public",
credentialRef: "key.touch-id", storage: .secureEnclave,
requiresUserPresence: true
)
let fallbackKey = SSHKeyRef(
label: "Fallback", publicKey: "ssh-ed25519 fallback",
credentialRef: "key.fallback"
)
let identity = Identity(
name: "Current", username: "current-user",
credentialRef: "password.current", keyID: protectedKey.id
)
try secrets.setString("current-password", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
keys: [protectedKey, fallbackKey], identities: [identity], secrets: secrets
)
let node = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Server", host: "example.invalid", port: 22,
authentication: SSHAuthenticationReference(
identityID: identity.id, fallbackKeyID: fallbackKey.id,
username: "stale-user"
),
expectedHostKeys: [], proxy: nil
)
let request = try store.additionalConnectionRequest(
from: SSHAdditionalConnectionSnapshot(target: node)
)
#expect(request.configuration.username == "current-user")
#expect(secrets.keyReads == 0)
#expect(secrets.stringReads == 1)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("The current Identity key must take priority and remain unread")
return
}
guard case .password(let password) = request.configuration.credentials[1] else {
Issue.record("The current Identity password must remain available")
return
}
#expect(password == "current-password")
}
@Test func additionalResolutionChecksForSavedPassphraseAndUsesIdentityPassword() throws {
let secrets = CountingSecretStore()
let protectedKey = SSHKeyRef(
label: "Encrypted", publicKey: "ssh-ed25519 public",
credentialRef: "key.encrypted", hasPassphrase: true
)
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: "password.deploy", keyID: protectedKey.id
)
try secrets.setString("saved-password", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
keys: [protectedKey], identities: [identity], secrets: secrets
)
let node = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Server", host: "example.invalid", port: 22,
authentication: SSHAuthenticationReference(
identityID: identity.id, username: "stale"
),
expectedHostKeys: [], proxy: nil
)
let request = try store.additionalConnectionRequest(
from: SSHAdditionalConnectionSnapshot(target: node)
)
#expect(secrets.keyReads == 1)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("A key without a saved passphrase must require interaction")
return
}
guard case .password(let password) = request.configuration.credentials[1] else {
Issue.record("Identity password must remain available")
return
}
#expect(password == "saved-password")
}
@Test func runtimeSecretCanBeConsumedOnlyOnceAcrossAllReferences() throws {
var source = Data("one-use".utf8)
let secret = SSHSecret(consumingUTF8: &source)
let alias = secret
var observedCount = 0
#expect(source.isEmpty)
try secret.withUnsafeCStringAndConsume { buffer, count in
observedCount = count
#expect(buffer[0] == CChar(bitPattern: Character("o").asciiValue!))
}
#expect(observedCount == 7)
#expect(secret.isConsumed)
#expect(alias.isConsumed)
#expect(throws: SSHSecret.Consumed.self) {
try alias.withUnsafeCStringAndConsume { _, _ in }
}
}
@Test func clearingAConfigurationInvalidatesEveryCredentialCopy() {
let password = SSHSecret("password")
let privateKey = SSHSecret("private-key")
let passphrase = SSHSecret("passphrase")
let proxyPassword = SSHSecret("proxy-password")
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "example.invalid", port: 22, username: "user",
credentials: [
.password(password),
.privateKey(
privateKey: privateKey, publicKey: nil,
passphrase: passphrase, requiresPassphrase: true
),
], expectedHostKeys: [],
proxy: SSHTransportProxy(
kind: .socks5, hostname: "proxy.invalid", port: 1080,
username: "proxy", password: proxyPassword
)
)
let copy = configuration
copy.clearSecrets()
#expect(password.isConsumed)
#expect(privateKey.isConsumed)
#expect(passphrase.isConsumed)
#expect(proxyPassword.isConsumed)
#expect(
configuration.credentials.allSatisfy { credential in
switch credential {
case .password(let value): value.isConsumed
case .privateKey(let value, _, let phrase, _):
value.isConsumed && phrase?.isConsumed == true
case .securityKey, .secureEnclave, .interactionRequired: true
}
})
}
}
private nonisolated final class CountingSecretStore: SecretStoring, @unchecked Sendable {
private let lock = NSLock()
private var strings: [String: String] = [:]
private var keys: [String: StoredSSHKeyCredential] = [:]
private(set) var stringReads = 0
private(set) var keyReads = 0
func string(for account: String, storage: PasswordStorage) throws -> String? {
lock.withLock {
stringReads += 1
return strings[account]
}
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
lock.withLock { strings[account] = value }
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
lock.withLock {
keyReads += 1
return keys[account]
}
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
lock.withLock { keys[account] = value }
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
lock.withLock { strings[account] = nil }
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
lock.withLock { keys[account] = nil }
}
}