242 lines
11 KiB
Swift
242 lines
11 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CryptoKit
|
|
import Foundation
|
|
import OpenSSL
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite
|
|
struct SSHAgentForwardingTests {
|
|
@Test func framesSurviveFragmentationAndCoalescing() throws {
|
|
let first = SSHAgentProtocol.frame(Data([11]))
|
|
let second = SSHAgentProtocol.frame(Data([13, 0, 0, 0, 0]))
|
|
var decoder = SSHAgentProtocol()
|
|
for byte in first.dropLast() {
|
|
try decoder.append(Data([byte]))
|
|
#expect(try decoder.next() == nil)
|
|
}
|
|
try decoder.append(Data(first.suffix(1)) + second)
|
|
#expect(try decoder.next() == Data([11]))
|
|
#expect(try decoder.next() == Data([13, 0, 0, 0, 0]))
|
|
#expect(try decoder.next() == nil)
|
|
}
|
|
|
|
@Test func oversizedAndEmptyFramesAreRejected() {
|
|
for length: UInt32 in [0, UInt32(SSHAgentProtocol.maximumPacket + 1), UInt32.max] {
|
|
var decoder = SSHAgentProtocol()
|
|
#expect(throws: (any Error).self) { try decoder.append(SSHAgentProtocol.uint32(length)) }
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func managementAndMalformedSignRequestsNeverReachSigner() async {
|
|
let provider = RecordingAgent()
|
|
for packet in [Data([17]), Data([18]), Data([19]), Data([13]), Data([11, 0])] {
|
|
#expect(
|
|
await SSHAgentProtocol.response(to: packet, provider: provider) == SSHAgentProtocol.frame(Data([5])))
|
|
}
|
|
#expect(provider.signCalls == 0)
|
|
}
|
|
|
|
@Test @MainActor func signatureFlagsAndDataArePreserved() async {
|
|
let provider = RecordingAgent()
|
|
let payload =
|
|
Data([13]) + SSHAgentProtocol.field(Data([1, 2]))
|
|
+ SSHAgentProtocol.field(Data([3, 4])) + SSHAgentProtocol.uint32(4)
|
|
#expect(
|
|
await SSHAgentProtocol.response(to: payload, provider: provider)
|
|
== SSHAgentProtocol.frame(Data([14]) + SSHAgentProtocol.field(Data([9]))))
|
|
#expect(provider.signCalls == 1)
|
|
#expect(provider.lastFlags == 4)
|
|
#expect(provider.lastData == Data([3, 4]))
|
|
}
|
|
|
|
@Test(arguments: [
|
|
SSHKeyMaterial.Generation.ed25519, .ecdsa(bits: 256), .ecdsa(bits: 384), .ecdsa(bits: 521), .rsa(bits: 2048),
|
|
])
|
|
func signaturesVerifyIndependently(_ generation: SSHKeyMaterial.Generation) throws {
|
|
let material = try SSHKeyMaterial.generate(generation, comment: "fixture", passphrase: nil)
|
|
let data = Data("SSH agent independent verification".utf8)
|
|
let flags: [UInt32] = material.algorithm == .rsa ? [0, 2, 4] : [0]
|
|
for flag in flags {
|
|
let blob = try SSHAgentKeySigner.sign(
|
|
credential: .software(SSHSecret(material.privateKey)), passphrase: nil,
|
|
algorithm: material.algorithm, data: data, flags: flag)
|
|
try verify(blob, data: data, material: material)
|
|
}
|
|
}
|
|
|
|
@Test func encryptedKeyUsesSavedPassphraseAndClearsSecrets() throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password")
|
|
let key = SSHSecret(material.privateKey)
|
|
let password = SSHSecret("fixture-password")
|
|
let data = Data("encrypted agent key".utf8)
|
|
let signature = try SSHAgentKeySigner.sign(
|
|
credential: .software(key, passphrase: password), passphrase: nil,
|
|
algorithm: material.algorithm, data: data, flags: 0)
|
|
#expect(key.isConsumed && password.isConsumed)
|
|
try verify(signature, data: data, material: material, password: "fixture-password")
|
|
}
|
|
|
|
@Test @MainActor func disablingAndReenablingDoesNotReviveOldCapability() async throws {
|
|
var host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true)
|
|
let library = HostLibraryModel(hosts: [host])
|
|
let factory = VaultSSHAgent.factory(library: library)
|
|
let request = SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: host.id, host: host.host, port: 22, username: "fixture", credentials: [], expectedHostKeys: []))
|
|
let agent = try #require(try await factory(request))
|
|
#expect(try await agent.identities().isEmpty)
|
|
host.agentForwardingEnabled = false
|
|
library.upsert(host)
|
|
host.agentForwardingEnabled = true
|
|
library.upsert(host)
|
|
await #expect(throws: (any Error).self) { try await agent.identities() }
|
|
#expect(try await factory(request) != nil)
|
|
}
|
|
|
|
@Test @MainActor func forbiddenInteractionUsesSavedPasswordButNeverPrompts() async throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password")
|
|
let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true)
|
|
let key = SSHKeyRef(
|
|
label: "Protected", algorithm: .ed25519, publicKey: material.publicKey,
|
|
credentialRef: "fixture", storage: .thisDevice, hasPassphrase: true)
|
|
let secrets = InMemorySecretStore()
|
|
try secrets.setKeyCredential(
|
|
.software(.init(privateKey: material.privateKey)), for: "fixture", storage: .thisDevice)
|
|
let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets)
|
|
let factory = VaultSSHAgent.factory(
|
|
library: library,
|
|
unlock: { _ in
|
|
Issue.record("Forbidden request opened an unlock prompt")
|
|
return ["fixture-password"]
|
|
})
|
|
let request = SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: host.id, host: host.host, port: 22,
|
|
username: "fixture", credentials: [], expectedHostKeys: []), interactionPolicy: .forbidden)
|
|
let agent = try #require(try await factory(request))
|
|
let identity = try #require(try await agent.identities().first)
|
|
await #expect(throws: SSHInteractionRequired.self) {
|
|
try await agent.sign(key: identity.blob, data: Data([1]), flags: 0)
|
|
}
|
|
try secrets.setKeyCredential(
|
|
.software(.init(privateKey: material.privateKey, passphrase: "fixture-password")),
|
|
for: "fixture", storage: .thisDevice)
|
|
#expect(try await !agent.sign(key: identity.blob, data: Data([1]), flags: 0).isEmpty)
|
|
try library.delete(keyID: key.id)
|
|
#expect(try await agent.identities().isEmpty)
|
|
await #expect(throws: (any Error).self) { try await agent.sign(key: identity.blob, data: Data([1]), flags: 0) }
|
|
}
|
|
|
|
@Test @MainActor func revocationDuringUnlockDiscardsTheAnswer() async throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password")
|
|
let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true)
|
|
let key = SSHKeyRef(
|
|
label: "Protected", algorithm: .ed25519, publicKey: material.publicKey,
|
|
credentialRef: "fixture", storage: .thisDevice, hasPassphrase: true)
|
|
let secrets = InMemorySecretStore()
|
|
try secrets.setKeyCredential(
|
|
.software(.init(privateKey: material.privateKey)), for: "fixture", storage: .thisDevice)
|
|
let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets)
|
|
let factory = VaultSSHAgent.factory(
|
|
library: library,
|
|
unlock: { _ in
|
|
await MainActor.run {
|
|
var disabled = host
|
|
disabled.agentForwardingEnabled = false
|
|
library.upsert(disabled)
|
|
}
|
|
return ["fixture-password"]
|
|
})
|
|
let request = SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: host.id, host: host.host, port: 22,
|
|
username: "fixture", credentials: [], expectedHostKeys: []))
|
|
let agent = try #require(try await factory(request))
|
|
let identity = try #require(try await agent.identities().first)
|
|
await #expect(throws: (any Error).self) { try await agent.sign(key: identity.blob, data: Data([1]), flags: 0) }
|
|
await agent.close()
|
|
await #expect(throws: (any Error).self) { try await agent.identities() }
|
|
}
|
|
|
|
@Test func hostLegacyDecodeDefaultsOffAndRoundTrips() throws {
|
|
let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true)
|
|
let encoder = JSONEncoder()
|
|
let encoded = try encoder.encode(host)
|
|
#expect(try JSONDecoder().decode(Host.self, from: encoded).agentForwardingEnabled)
|
|
var json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
|
json.removeValue(forKey: "agentForwardingEnabled")
|
|
let legacy = try JSONSerialization.data(withJSONObject: json)
|
|
#expect(try !JSONDecoder().decode(Host.self, from: legacy).agentForwardingEnabled)
|
|
}
|
|
|
|
private func verify(_ blob: Data, data: Data, material: SSHKeyMaterial, password: String = "") throws {
|
|
var reader = SSHAgentProtocol.Reader(blob)
|
|
let name = String(decoding: try reader.field(), as: UTF8.self)
|
|
var signature = try reader.field()
|
|
#expect(reader.isEmpty)
|
|
if name.hasPrefix("ecdsa-") {
|
|
var fields = SSHAgentProtocol.Reader(signature)
|
|
let r = try fields.field()
|
|
let s = try fields.field()
|
|
let pair = try #require(ECDSA_SIG_new())
|
|
defer { ECDSA_SIG_free(pair) }
|
|
let rb = r.withUnsafeBytes { BN_bin2bn($0.bindMemory(to: UInt8.self).baseAddress, Int32($0.count), nil) }
|
|
let sb = s.withUnsafeBytes { BN_bin2bn($0.bindMemory(to: UInt8.self).baseAddress, Int32($0.count), nil) }
|
|
#expect(ECDSA_SIG_set0(pair, rb, sb) == 1)
|
|
var der: UnsafeMutablePointer<UInt8>?
|
|
let length = i2d_ECDSA_SIG(pair, &der)
|
|
let pointer = try #require(der)
|
|
signature = Data(bytes: pointer, count: Int(length))
|
|
CRYPTO_free(pointer, nil, 0)
|
|
}
|
|
let bio = try #require(BIO_new(BIO_s_mem()))
|
|
defer { BIO_free(bio) }
|
|
_ = material.privateKey.withCString { BIO_write(bio, $0, Int32(material.privateKey.utf8.count)) }
|
|
let key = try #require(
|
|
password.withCString {
|
|
PEM_read_bio_PrivateKey(bio, nil, nil, UnsafeMutableRawPointer(mutating: $0))
|
|
})
|
|
defer { EVP_PKEY_free(key) }
|
|
let digest: OpaquePointer? =
|
|
switch name {
|
|
case "ssh-ed25519": nil
|
|
case "ssh-rsa": EVP_sha1()
|
|
case "rsa-sha2-512", "ecdsa-sha2-nistp521": EVP_sha512()
|
|
case "ecdsa-sha2-nistp384": EVP_sha384()
|
|
default: EVP_sha256()
|
|
}
|
|
let context = try #require(EVP_MD_CTX_new())
|
|
defer { EVP_MD_CTX_free(context) }
|
|
#expect(EVP_DigestVerifyInit(context, nil, digest, nil, key) == 1)
|
|
let result = signature.withUnsafeBytes { sig in
|
|
data.withUnsafeBytes { message in
|
|
EVP_DigestVerify(
|
|
context, sig.bindMemory(to: UInt8.self).baseAddress, sig.count,
|
|
message.bindMemory(to: UInt8.self).baseAddress, message.count)
|
|
}
|
|
}
|
|
#expect(result == 1)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class RecordingAgent: SSHAgentForwarding {
|
|
var signCalls = 0
|
|
var lastFlags: UInt32 = 0
|
|
var lastData = Data()
|
|
func identities() async throws -> [SSHAgentIdentity] { [] }
|
|
func sign(key: Data, data: Data, flags: UInt32) async throws -> Data {
|
|
signCalls += 1
|
|
lastFlags = flags
|
|
lastData = data
|
|
return Data([9])
|
|
}
|
|
func close() async {}
|
|
}
|