Files
Termind/TFMTests/SecurityKeySSHWireTests.swift

172 lines
8.6 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CryptoKit
import Foundation
import Logging
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SECURITY_KEY_FIXTURE"] != nil), .serialized)
struct SecurityKeySSHWireTests {
private struct Fixture: Decodable {
var port: Int
var username: String
var privateKey: Data
var fingerprint: String
}
@Test(arguments: [false, true]) func authenticatesAgainstOpenSSHWithWebAuthnSignature(throughJump: Bool)
async throws
{
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_SECURITY_KEY_FIXTURE"])
let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
let key = try P256.Signing.PrivateKey(rawRepresentation: fixture.privateKey)
let credential = SecurityKeyCredential(
credentialID: Data([1, 2, 3]), publicKeyX963: key.publicKey.x963Representation)
let expected: Set<SSHHostKeyIdentity> = [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)]
var configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "127.0.0.1", port: fixture.port,
username: fixture.username, credentials: [.securityKey(credential)], expectedHostKeys: expected)
if throughJump {
configuration.jumps = [
.init(
id: UUID(), name: "Fixture jump", host: "127.0.0.1", port: fixture.port,
username: fixture.username, credentials: [.securityKey(credential)], expectedHostKeys: expected)
]
}
let request = SSHConnectionRequest(
configuration: configuration,
securityKeySigning: { request in
let encoded = request.challenge.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let client = Data(
"{\"type\":\"webauthn.get\",\"challenge\":\"\(encoded)\",\"origin\":\"https://\(SecurityKeyCredential.relyingParty)\"}"
.utf8)
let auth =
Data(SHA256.hash(data: Data(SecurityKeyCredential.relyingParty.utf8))) + Data([1, 0, 0, 0, 1])
return SecurityKeyAssertion(
credentialID: credential.credentialID, authenticatorData: auth,
clientDataJSON: client,
signature: try key.signature(for: auth + Data(SHA256.hash(data: client))).derRepresentation)
})
let host = Host(
id: configuration.hostID, name: "Forwarding fixture", host: "127.0.0.1", agentForwardingEnabled: true)
let reference = try WebAuthnSSH.keyReference(credential, label: "Forwarded YubiKey")
let library = await MainActor.run { HostLibraryModel(hosts: [host], keys: [reference]) }
let coordinator = RemoteConnectionCoordinator()
let factory = await MainActor.run {
VaultSSHAgent.factory(library: library, securityKeySigning: request.securityKeySigning)
}
coordinator.updateAgentForwarding(factory)
let connection = try await coordinator.connectConnection(using: request)
do {
let result = try await connection.execute(
"printf fido-ok", workingDirectory: nil, stdin: nil,
timeout: 10, outputLimit: 4096, onOutput: nil)
#expect(result.exitCode == 0)
#expect(result.stdout == "fido-ok")
let forwarded = try await connection.execute(
"/usr/bin/ssh -F /dev/null -o BatchMode=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p \(fixture.port) \(fixture.username)@127.0.0.1 'printf forwarded-fido-ok'",
workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 4096, onOutput: nil)
#expect(forwarded.exitCode == 0)
#expect(forwarded.stdout == "forwarded-fido-ok")
} catch {
await connection.disconnect()
throw error
}
await connection.disconnect()
let sftp = try await coordinator.connectSFTP(using: request)
await sftp.disconnect()
}
@Test(arguments: [false, true])
func rejectedPublicKeyPreservesFailureAndLogsBeforeSigning(throughJump: Bool) async throws {
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_SECURITY_KEY_FIXTURE"])
let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
let key = P256.Signing.PrivateKey()
let credential = SecurityKeyCredential(
credentialID: Data("sensitive-credential-id".utf8), publicKeyX963: key.publicKey.x963Representation)
let expected: Set<SSHHostKeyIdentity> = [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)]
var configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "127.0.0.1", port: fixture.port,
username: fixture.username, credentials: [.securityKey(credential)], expectedHostKeys: expected)
if throughJump {
configuration.jumps = [
.init(
id: UUID(), name: "Fixture jump", host: "127.0.0.1", port: fixture.port,
username: fixture.username, credentials: [.securityKey(credential)], expectedHostKeys: expected)
]
}
let request = SSHConnectionRequest(
configuration: configuration,
securityKeySigning: { _ in
Issue.record("A rejected public key must not prompt for a signature")
throw CancellationError()
})
let capture = SecurityKeyLogCapture()
let logger = Logger(label: "termind.security-key-test") { _ in SecurityKeyLogHandler(capture: capture) }
let coordinator = RemoteConnectionCoordinator(logger: logger)
do {
let connection = try await coordinator.connectConnection(using: request)
await connection.disconnect()
Issue.record("An unregistered public key must fail authentication")
} catch {
#expect(error.localizedDescription.contains("Username/PublicKey combination invalid"))
#expect(error.localizedDescription.contains("[libssh2 -18]"))
#expect(
error.localizedDescription.contains(
String(
localized:
"The server rejected the username or public key. Check the login user and the key in authorized_keys."
)))
}
let logs = capture.entries
let failure = try #require(logs.first { $0.message == "ssh.security_key.failed" })
#expect(failure.metadata["stage"] == "public_key_offer")
#expect(failure.metadata["libssh2_code"] == "-18")
#expect(failure.metadata["operation_id"] != nil)
#expect(!logs.contains { $0.message == "ssh.security_key.signing" })
let serialized = String(describing: logs)
#expect(!serialized.contains("sensitive-credential-id"))
#expect(!serialized.contains(credential.credentialID.base64EncodedString()))
#expect(!serialized.contains(key.publicKey.x963Representation.base64EncodedString()))
}
}
private nonisolated struct SecurityKeyLogEntry: Sendable {
let message: String
let metadata: [String: String]
}
private nonisolated final class SecurityKeyLogCapture: @unchecked Sendable {
private let lock = NSLock()
private var storage: [SecurityKeyLogEntry] = []
var entries: [SecurityKeyLogEntry] { lock.withLock { storage } }
func append(_ event: LogEvent, metadata: Logger.Metadata) {
lock.withLock {
storage.append(.init(message: event.message.description, metadata: metadata.mapValues(\.description)))
}
}
}
private nonisolated struct SecurityKeyLogHandler: LogHandler {
let capture: SecurityKeyLogCapture
var metadataProvider: Logger.MetadataProvider?
var metadata: Logger.Metadata = [:]
var logLevel: Logger.Level = .trace
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { metadata[key] }
set { metadata[key] = newValue }
}
func log(event: LogEvent) {
var combined = metadata
if let explicit = event.metadata { combined.merge(explicit) { _, new in new } }
capture.append(event, metadata: combined)
}
}