Files
Termind/FrameworkTests/WebAuthnVaultMetadataTests.swift

194 lines
9.8 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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: .external,
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(saved.storage == .external)
#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)
#expect(reloaded.storage == .external)
let payload = VaultRecordPayload.sshKey(reloaded)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(envelope.formatVersion == SyncRecordFormats.vault.writingVersion(for: "sshKey"))
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))
#expect(synced.storage == .external)
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(arguments: [KeyStorage.thisDevice, .iCloud])
func legacyHardwareStorageLoadsAsExternal(_ storage: KeyStorage) throws {
var legacy = key()
legacy.storage = storage
let container = try TermindPersistence.makeContainer(inMemory: true)
let row = TermindSchemaV45KeyModels.PersistentSSHKey(legacy)
container.mainContext.insert(row)
try container.mainContext.save()
let persistence = HostPersistence(context: container.mainContext)
let restored = try #require(try persistence.load().keys.first)
#expect(restored.storage == .external)
#expect(restored.securityKey == legacy.securityKey)
#expect(try JSONDecoder().decode(SSHKeyRef.self, from: JSONEncoder().encode(legacy)).storage == .external)
let library = HostLibraryModel(secrets: NoKeychain())
try library.upsert(legacy)
#expect(library.key(legacy.id)?.storage == .external)
}
@Test func externalHardwareRetainsCompatibleVaultWireStorage() throws {
let payload = VaultRecordPayload.sshKey(key())
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let data = try VaultSyncCodec.encode(envelope)
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let payloadJSON = try #require(json["payload"] as? [String: Any])
let keyJSON = try #require(payloadJSON["sshKey"] as? [String: Any])
let fields = try #require(keyJSON["_0"] as? [String: Any])
#expect(fields["storage"] as? String == "thisDevice")
#expect(try VaultSyncCodec.decode(data).payload == payload)
}
@Test func softwareKeysCannotUseExternalStorage() throws {
let software = SSHKeyRef(label: "Invalid", storage: .external)
let library = HostLibraryModel(secrets: NoKeychain())
#expect(throws: (any Error).self) { try library.upsert(software) }
#expect(library.keys.isEmpty)
let payload = VaultRecordPayload.sshKey(software)
for version in [1, 2] {
let envelope = VaultEnvelope(
key: payload.key, version: .baseline(writerID: UUID()), payload: payload, formatVersion: version)
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
}
}
@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
}
}