Files
Termind/FrameworkTests/WebAuthnVaultMetadataTests.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

148 lines
7.4 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct WebAuthnVaultMetadataTests {
private func key() -> SSHKeyRef {
SSHKeyRef(
label: "", algorithm: .securityKey, publicKey: "public-key", fingerprint: "SHA256:metadata",
storage: .thisDevice,
securityKey: SecurityKeyCredential(
credentialID: Data([1, 2, 3]), publicKeyX963: Data([4]) + Data(repeating: 7, count: 64),
userVerification: .preferred), keySize: 256, requiresUserPresence: true)
}
@Test func connectionPreparationResolvesDestinationAndJumpWithoutKeychain() async throws {
var draft = key()
draft.label = "Personal YubiKey"
let identity = Identity(name: "Hardware", username: "root", keyID: draft.id)
let preparation = SSHConnectionPreparation(
nodes: ["jump.invalid", "server.invalid"].map { hostname in
SSHConnectionPreparation.Node(
host: Host(name: hostname, host: hostname, identityID: identity.id),
identity: identity, key: draft, submission: nil, trustedKeys: [], proxyIdentity: nil)
}, secrets: NoKeychain())
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
#expect(configuration.username == "root")
guard case .securityKey(let destination, let destinationName) = configuration.credentials.first,
case .securityKey(let jump, let jumpName) = configuration.jumps.first?.credentials.first
else {
Issue.record("Expected metadata-backed hardware credentials for both route nodes")
return
}
#expect(destination == draft.securityKey && jump == draft.securityKey)
#expect(destinationName == draft.label && jumpName == draft.label)
}
@Test func metadataPersistsSyncsAndResolvesWithoutKeychain() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = HostPersistence(context: container.mainContext)
let library = HostLibraryModel(persistence: persistence, secrets: NoKeychain())
let draft = key()
try library.upsert(draft)
let saved = try #require(library.key(draft.id))
#expect(saved.label == "Security Key" && saved.credentialRef.isEmpty)
#expect(library.keyAvailability(for: saved) == .available)
#expect(try library.keyCredential(for: saved) == nil)
let reloaded = try #require(try persistence.load().keys.first)
#expect(reloaded.securityKey == draft.securityKey)
let payload = VaultRecordPayload.sshKey(reloaded)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(envelope.formatVersion == 2)
let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
#expect(decoded.payload == payload)
let remoteContainer = try TermindPersistence.makeContainer(inMemory: true)
let remote = HostPersistence(context: remoteContainer.mainContext)
_ = try remote.applyRemote([decoded])
let remoteLibrary = HostLibraryModel(persistence: remote, secrets: NoKeychain())
var synced = try #require(remoteLibrary.key(saved.id))
guard case .securityKey(let resolved)? = try remoteLibrary.keySecretCredential(for: synced) else {
Issue.record("Expected a Vault hardware reference")
return
}
#expect(resolved == draft.securityKey)
synced.securityKey?.userVerification = .required
try remoteLibrary.upsert(synced)
#expect(try remote.load().keys.first?.securityKey?.userVerification == .required)
try remoteLibrary.delete(keyID: synced.id)
#expect(try remote.load().keys.isEmpty)
}
@Test func failedMetadataWritesAndDeletesRollBackWithoutKeychain() throws {
let persistence = MetadataPersistence()
let library = HostLibraryModel(persistence: persistence, secrets: NoKeychain())
let draft = key()
persistence.fails = true
#expect(throws: (any Error).self) { try library.upsert(draft) }
#expect(library.keys.isEmpty)
persistence.fails = false
try library.upsert(draft)
let saved = try #require(library.key(draft.id))
persistence.fails = true
var update = saved
update.securityKey?.userVerification = .required
#expect(throws: (any Error).self) { try library.upsert(update) }
#expect(library.key(saved.id) == saved)
#expect(throws: (any Error).self) { try library.delete(keyID: saved.id) }
#expect(library.key(saved.id) == saved)
}
@Test func version44SoftwareKeysSurviveSchemaUpgradeAndReopen() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Vault.store")
let software = SSHKeyRef(label: "Existing", credentialRef: "termind.key.existing")
try writeVersion44(software, to: url)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let persistence = HostPersistence(context: container.mainContext)
#expect(try persistence.load().keys == [software])
}
}
private func writeVersion44(_ key: SSHKeyRef, to url: URL) throws {
let schema = Schema(versionedSchema: TermindSchemaV44.self)
let container = try ModelContainer(
for: schema,
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
container.mainContext.insert(TermindSchemaV7Models.PersistentSSHKey(key))
try container.mainContext.save()
}
}
private final class NoKeychain: SecretStoring, @unchecked Sendable {
func string(for account: String, storage: PasswordStorage) throws -> String? {
throw CocoaError(.fileReadNoPermission)
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
throw CocoaError(.fileWriteNoPermission)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
throw CocoaError(.fileReadNoPermission)
}
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {
throw CocoaError(.fileWriteNoPermission)
}
func removePassword(_ account: String, storage: PasswordStorage) throws { throw CocoaError(.fileWriteNoPermission) }
func removeKeyCredential(_ account: String, storage: KeyStorage) throws { throw CocoaError(.fileWriteNoPermission) }
}
@MainActor private final class MetadataPersistence: VaultPersisting {
var snapshot = VaultSnapshot()
var fails = false
func load() throws -> VaultSnapshot { snapshot }
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
if fails { throw CocoaError(.fileWriteUnknown) }
self.snapshot = snapshot
return .empty
}
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
throw CocoaError(.coderInvalidValue)
}
}