Files
Termind/TFMTests/WebAuthnSSHTests.swift

182 lines
9.0 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
struct WebAuthnSSHTests {
private struct Fixture {
let key = P256.Signing.PrivateKey()
let challenge = Data("ssh authentication message".utf8)
var credential: SecurityKeyCredential {
SecurityKeyCredential(credentialID: Data([1, 2, 3]), publicKeyX963: key.publicKey.x963Representation)
}
var request: SecurityKeySigningRequest {
SecurityKeySigningRequest(
hostID: UUID(), hostname: "fixture.invalid", credential: credential, challenge: challenge)
}
func assertion(flags: UInt8 = 1, extensions: Data = Data()) throws -> SecurityKeyAssertion {
let encoded = challenge.base64EncodedString().replacingOccurrences(of: "=", with: "")
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let client = Data(
"{\"type\":\"webauthn.get\",\"challenge\":\"\(encoded)\",\"origin\":\"https://\(SecurityKeyCredential.relyingParty)\",\"crossOrigin\":false}"
.utf8)
let auth =
Data(SHA256.hash(data: Data(SecurityKeyCredential.relyingParty.utf8)))
+ Data([flags, 0, 0, 0, 7]) + extensions
return SecurityKeyAssertion(
credentialID: credential.credentialID, authenticatorData: auth,
clientDataJSON: client,
signature: try key.signature(for: auth + Data(SHA256.hash(data: client))).derRepresentation)
}
}
@Test func verifiesAndPreservesSignedWebAuthnFields() throws {
let fixture = Fixture()
let assertion = try fixture.assertion(flags: 5)
let signature = try WebAuthnSSH.signature(assertion, for: fixture.request)
var reader = Reader(data: signature)
#expect(try reader.field() == Data(WebAuthnSSH.signatureAlgorithm.utf8))
var pair = Reader(data: try reader.field())
let r = try pair.field()
let s = try pair.field()
#expect(!r.isEmpty && !s.isEmpty && pair.offset == pair.data.count)
#expect(signature[reader.offset] == 5)
reader.offset += 5
#expect(try reader.field() == Data("https://\(SecurityKeyCredential.relyingParty)".utf8))
#expect(try reader.field() == assertion.clientDataJSON)
#expect(try reader.field().isEmpty)
#expect(reader.offset == signature.count)
#expect(assertion.userPresent && assertion.userVerified)
}
@Test func rejectsTamperingAndMissingUserVerification() throws {
let fixture = Fixture()
let assertion = try fixture.assertion()
var request = fixture.request
request.credential.userVerification = .required
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(assertion, for: request) }
request = fixture.request
request.challenge.append(0)
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(assertion, for: request) }
var changed = assertion
changed.credentialID.append(0)
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(changed, for: fixture.request) }
changed = assertion
changed.authenticatorData[0] ^= 1
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(changed, for: fixture.request) }
changed = assertion
changed.authenticatorData[36] ^= 1
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(changed, for: fixture.request) }
for length in 0..<37 {
changed.authenticatorData = Data(assertion.authenticatorData.prefix(length))
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(changed, for: fixture.request) }
}
let noPresence = try fixture.assertion(flags: 0)
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(noPresence, for: fixture.request) }
request = fixture.request
request.credential.requiresUserPresence = false
#expect(throws: Never.self) { try WebAuthnSSH.signature(noPresence, for: request) }
}
@Test func validatesExtensionsAndRejectsDuplicateCBORKeys() throws {
let fixture = Fixture()
let assertion = try fixture.assertion(flags: 0x81, extensions: Data([0xA1, 1, 2]))
#expect(throws: Never.self) { try WebAuthnSSH.signature(assertion, for: fixture.request) }
for extensions in [Data(), Data([0xA2, 1, 2, 1, 3]), Data([0xA0, 0]), Data([0xBF, 0xFF])] {
let malformed = try fixture.assertion(flags: 0x81, extensions: extensions)
#expect(throws: (any Error).self) { try WebAuthnSSH.signature(malformed, for: fixture.request) }
}
}
@Test func credentialReferencesRoundTripAndRejectOtherFormats() throws {
let credential = Fixture().credential
let reference = try WebAuthnSSH.exportReference(credential)
#expect(try WebAuthnSSH.importReference(reference) == credential)
#expect(throws: (any Error).self) { try WebAuthnSSH.importReference("-----BEGIN OPENSSH PRIVATE KEY-----") }
let key = try WebAuthnSSH.keyReference(credential, label: "Fixture")
#expect(key.algorithm == .securityKey && key.requiresUserPresence)
#expect(key.storage == .external)
#expect(SSHImportParser.validPublicKey(try WebAuthnSSH.publicBlob(credential), algorithm: .securityKey))
}
@Test func externalStorageNeverAccessesKeychainItems() throws {
let store = KeychainSecretStore(accessGroup: "invalid.test.group")
let account = "termind.key.external-test"
let operations: [() throws -> Void] = [
{ _ = try store.keyCredential(for: account, storage: .external) },
{ _ = try store.sshKeyCredential(for: account, storage: .external) },
{ _ = try store.containsKeyCredential(for: account, storage: .external) },
{ try store.setKeyCredential(nil, for: account, storage: .external) },
{ try store.removeKeyCredential(account, storage: .external) },
]
for operation in operations {
do {
try operation()
Issue.record("External authenticator storage must reject Keychain access")
} catch KeychainSecretError.unexpectedValue {
// A Keychain status error would mean the storage boundary was crossed.
} catch {
Issue.record("Unexpected error: \(error)")
}
}
}
@Test func credentialEnvelopesPreserveLegacySoftwareKeys() throws {
var software = Data("{\"privateKey\":\"legacy-key\",\"passphrase\":\"legacy-passphrase\"}".utf8)
guard case .software(let key, let phrase) = try KeychainSecretStore.decodeKeySecrets(from: &software) else {
Issue.record("Expected the legacy software envelope")
return
}
defer {
key.clear()
phrase?.clear()
}
#expect(key.matches("legacy-key"))
#expect(phrase?.matches("legacy-passphrase") == true)
#expect(software.isEmpty)
}
@Test func registrationsParseCOSEAndRejectEveryTruncation() throws {
let fixture = Fixture()
let raw = fixture.key.publicKey.x963Representation
let cose =
Data([0xA5, 1, 2, 3, 0x26, 0x20, 1, 0x21, 0x58, 32]) + raw[1..<33]
+ Data([0x22, 0x58, 32]) + raw[33..<65]
let auth =
Data(SHA256.hash(data: Data(SecurityKeyCredential.relyingParty.utf8)))
+ Data([0x41, 0, 0, 0, 0]) + Data(repeating: 0, count: 16) + Data([0, 3, 1, 2, 3]) + cose
let attestation = Data([0xA1, 0x68]) + Data("authData".utf8) + Data([0x58, UInt8(auth.count)]) + auth
let client = Data(
"{\"type\":\"webauthn.create\",\"challenge\":\"AQ\",\"origin\":\"https://\(SecurityKeyCredential.relyingParty)\"}"
.utf8)
func decode(_ attestation: Data) throws -> SecurityKeyCredential {
try WebAuthnSSH.registration(
credentialID: fixture.credential.credentialID, attestation: attestation,
clientDataJSON: client, challenge: Data([1]), userVerification: .preferred)
}
#expect(try decode(attestation) == fixture.credential)
for length in 0..<attestation.count {
#expect(throws: (any Error).self) { try decode(Data(attestation.prefix(length))) }
}
}
private struct Reader {
let data: Data
var offset = 0
mutating func field() throws -> Data {
guard data.count - offset >= 4 else { throw WebAuthnSSH.Failure.invalidResponse }
let count = data[offset..<offset + 4].reduce(0) { ($0 << 8) | Int($1) }
offset += 4
guard count <= data.count - offset else { throw WebAuthnSSH.Failure.invalidResponse }
defer { offset += count }
return Data(data[offset..<offset + count])
}
}
}