Files

238 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
/// Independent WebAuthn/SSH wire adapter. The original clientDataJSON is signed and must never be reserialized.
public nonisolated enum WebAuthnSSH {
public enum Failure: LocalizedError {
case invalidCredential, invalidResponse, unsupportedResponse
public var errorDescription: String? {
switch self {
case .invalidCredential: String(localized: "The WebAuthn credential reference is invalid.")
case .invalidResponse: String(localized: "The WebAuthn response could not be verified.")
case .unsupportedResponse: String(localized: "The WebAuthn response is not compatible with SSH.")
}
}
}
public static let algorithm = "sk-ecdsa-sha2-nistp256@openssh.com"
public static let signatureAlgorithm = "webauthn-sk-ecdsa-sha2-nistp256@openssh.com"
public static func registration(
credentialID: Data, attestation: Data, clientDataJSON: Data, challenge: Data,
userVerification: SecurityKeyCredential.UserVerification
) throws -> SecurityKeyCredential {
try validateClientData(clientDataJSON, type: "webauthn.create", challenge: challenge)
var cbor = CBORReader(attestation)
guard case .map(let object) = try cbor.read(), cbor.isAtEnd,
case .bytes(let auth)? = object[.text("authData")], auth.count >= 55
else { throw Failure.invalidResponse }
try validateAuthenticator(auth, requiresVerification: userVerification == .required)
guard auth[32] & 0x40 != 0 else { throw Failure.invalidResponse }
let count = Int(auth[53]) << 8 | Int(auth[54])
guard count > 0, count <= auth.count - 55,
Data(auth[55..<(55 + count)]) == credentialID
else { throw Failure.invalidResponse }
var keyReader = CBORReader(Data(auth.dropFirst(55 + count)))
guard case .map(let key) = try keyReader.read(),
key[.integer(1)] == .integer(2), key[.integer(3)] == .integer(-7),
key[.integer(-1)] == .integer(1),
case .bytes(let x)? = key[.integer(-2)], x.count == 32,
case .bytes(let y)? = key[.integer(-3)], y.count == 32
else { throw Failure.unsupportedResponse }
if auth[32] & 0x80 != 0 {
guard case .map = try keyReader.read() else { throw Failure.invalidResponse }
}
guard keyReader.isAtEnd else { throw Failure.invalidResponse }
let credential = SecurityKeyCredential(
credentialID: credentialID, publicKeyX963: Data([4]) + x + y,
userVerification: userVerification)
try validate(credential)
return credential
}
public static func validate(_ credential: SecurityKeyCredential) throws {
guard !credential.credentialID.isEmpty, credential.credentialID.count <= 1023,
credential.publicKeyX963.count == 65, credential.publicKeyX963.first == 4
else { throw Failure.invalidCredential }
_ = try P256.Signing.PublicKey(x963Representation: credential.publicKeyX963)
}
public static func publicBlob(_ credential: SecurityKeyCredential) throws -> Data {
try validate(credential)
return SSHWireEncoding.field(Data(algorithm.utf8)) + SSHWireEncoding.field(Data("nistp256".utf8))
+ SSHWireEncoding.field(credential.publicKeyX963)
+ SSHWireEncoding.field(Data(SecurityKeyCredential.relyingParty.utf8))
}
public static func keyReference(
_ credential: SecurityKeyCredential, label: String, colorTag: TagColor = .none
) throws -> SSHKeyRef {
let blob = try publicBlob(credential)
return SSHKeyRef(
label: label, algorithm: .securityKey,
publicKey: "\(algorithm) \(blob.base64EncodedString())", fingerprint: SSHWireEncoding.fingerprint(of: blob),
storage: .external, securityKey: credential, keySize: 256,
requiresUserPresence: credential.requiresUserPresence, createdAt: .now, colorTag: colorTag)
}
/// Import/export only our RP-bound reference, never an OpenSSH private-key file or another app's credential.
public static func exportReference(_ credential: SecurityKeyCredential) throws -> String {
try validate(credential)
return "termind-fido2-v1:" + (try JSONEncoder().encode(credential)).base64EncodedString()
}
public static func readReferenceFile(_ url: URL) throws -> String {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 1024 * 1024 else {
throw CocoaError(.fileReadTooLarge)
}
let text = try String(contentsOf: url, encoding: .utf8)
_ = try importReference(text)
return text
}
public static func importReference(_ text: String) throws -> SecurityKeyCredential {
let prefix = "termind-fido2-v1:"
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.hasPrefix(prefix), text.utf8.count < 16384,
let data = Data(base64Encoded: String(text.dropFirst(prefix.count)))
else { throw Failure.invalidCredential }
let credential = try JSONDecoder().decode(SecurityKeyCredential.self, from: data)
try validate(credential)
return credential
}
public static func signature(_ assertion: SecurityKeyAssertion, for request: SecurityKeySigningRequest) throws
-> Data
{
try validate(request.credential)
guard assertion.credentialID == request.credential.credentialID else { throw Failure.invalidResponse }
let origin = try validateClientData(
assertion.clientDataJSON, type: "webauthn.get", challenge: request.challenge)
let auth = Data(assertion.authenticatorData)
try validateAuthenticator(
auth, requiresVerification: request.credential.userVerification == .required,
requiresPresence: request.credential.requiresUserPresence)
guard auth[32] & 0x40 == 0 else { throw Failure.invalidResponse }
let extensions = Data(auth.dropFirst(37))
if auth[32] & 0x80 != 0 {
var reader = CBORReader(extensions)
guard case .map = try reader.read(), reader.isAtEnd else { throw Failure.invalidResponse }
} else if !extensions.isEmpty {
throw Failure.invalidResponse
}
// OpenSSH verifies this exact prefix, including the unpadded base64url challenge.
let prefix =
"{\"type\":\"webauthn.get\",\"challenge\":\"\(base64URL(request.challenge))\",\"origin\":\"\(origin)\""
guard assertion.clientDataJSON.starts(with: Data(prefix.utf8)) else { throw Failure.unsupportedResponse }
let signature = try P256.Signing.ECDSASignature(derRepresentation: assertion.signature)
let publicKey = try P256.Signing.PublicKey(x963Representation: request.credential.publicKeyX963)
guard publicKey.isValidSignature(signature, for: auth + Data(SHA256.hash(data: assertion.clientDataJSON)))
else {
throw Failure.invalidResponse
}
let raw = signature.rawRepresentation
let pair = SSHWireEncoding.mpint(Data(raw.prefix(32))) + SSHWireEncoding.mpint(Data(raw.suffix(32)))
return SSHWireEncoding.field(Data(signatureAlgorithm.utf8)) + SSHWireEncoding.field(pair) + Data(auth[32..<37])
+ SSHWireEncoding.field(Data(origin.utf8)) + SSHWireEncoding.field(assertion.clientDataJSON)
+ SSHWireEncoding.field(extensions)
}
private static func validateAuthenticator(_ data: Data, requiresVerification: Bool, requiresPresence: Bool = true)
throws
{
guard data.count >= 37, data.count <= 65536,
Data(data.prefix(32)) == Data(SHA256.hash(data: Data(SecurityKeyCredential.relyingParty.utf8))),
!requiresPresence || data[32] & 1 != 0, !requiresVerification || data[32] & 4 != 0
else { throw Failure.invalidResponse }
}
@discardableResult
private static func validateClientData(_ data: Data, type: String, challenge: Data) throws -> String {
guard data.count <= 65536,
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
object["type"] as? String == type,
object["challenge"] as? String == base64URL(challenge),
let origin = object["origin"] as? String,
origin == "https://\(SecurityKeyCredential.relyingParty)",
object["crossOrigin"] == nil || object["crossOrigin"] as? Bool == false
else { throw Failure.invalidResponse }
return origin
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
}
/// Bounded definite-length CBOR parser for authenticator data, with duplicate-map-key rejection.
private nonisolated struct CBORReader {
indirect enum Value: Hashable {
case integer(Int64)
case bytes(Data)
case text(String)
case array([Value])
case map([Value: Value])
case simple(UInt8)
}
let bytes: [UInt8]
var offset = 0
var isAtEnd: Bool { offset == bytes.count }
init(_ data: Data) { bytes = Array(data) }
mutating func read(depth: Int = 0) throws -> Value {
guard depth < 16, bytes.count <= 65536 else { throw WebAuthnSSH.Failure.invalidResponse }
let head = try take(1).first!
let major = head >> 5
let info = head & 31
let length: UInt64
if info < 24 {
length = UInt64(info)
} else if info <= 27 {
length = try take(1 << Int(info - 24)).reduce(0) { ($0 << 8) | UInt64($1) }
} else {
throw WebAuthnSSH.Failure.unsupportedResponse
}
guard length <= UInt64(Int64.max) else { throw WebAuthnSSH.Failure.invalidResponse }
switch major {
case 0: return .integer(Int64(length))
case 1: return .integer(-1 - Int64(length))
case 2: return .bytes(Data(try take(Int(length))))
case 3:
guard let text = String(bytes: try take(Int(length)), encoding: .utf8) else {
throw WebAuthnSSH.Failure.invalidResponse
}
return .text(text)
case 4:
guard length <= 1024 else { throw WebAuthnSSH.Failure.invalidResponse }
return .array(try (0..<length).map { _ in try read(depth: depth + 1) })
case 5:
guard length <= 1024 else { throw WebAuthnSSH.Failure.invalidResponse }
var map: [Value: Value] = [:]
for _ in 0..<length {
let key = try read(depth: depth + 1)
guard map[key] == nil else { throw WebAuthnSSH.Failure.invalidResponse }
map[key] = try read(depth: depth + 1)
}
return .map(map)
case 7 where info < 24: return .simple(info)
default: throw WebAuthnSSH.Failure.unsupportedResponse
}
}
mutating func take(_ count: Int) throws -> ArraySlice<UInt8> {
guard count >= 0, count <= bytes.count - offset else { throw WebAuthnSSH.Failure.invalidResponse }
defer { offset += count }
return bytes[offset..<(offset + count)]
}
}