Files
Termind/FrameworkTests/SSHInteractionPolicyTests.swift
T
Kaede Akino 816a6ff121 feat(ssh): support hardware security key authentication
Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows.

Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
2026-09-28 23:37:49 +08:00

309 lines
14 KiB
Swift

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 }
}
}