// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import OpenSSL import TermindDomain nonisolated extension SSHKeyMaterial { public struct PrivateKeyMetadata: Sendable { public var algorithm: SSHKeyAlgorithm public var keySize: Int? public var isEncrypted: Bool } /// Reads the key envelope without deriving or populating a public key. An encrypted /// PKCS#8 envelope deliberately leaves its algorithm unknown until it is unlocked. public static func privateKeyMetadata(_ text: String) throws -> PrivateKeyMetadata { let lines = text.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) guard let header = lines.first, let footer = lines.last, header.hasPrefix("-----BEGIN "), header.hasSuffix("PRIVATE KEY-----"), footer == header.replacingOccurrences(of: "BEGIN", with: "END") else { throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key envelope.") } let body = lines.dropFirst().dropLast().filter { !$0.contains(":") }.joined() guard let bytes = Data(base64Encoded: body), !bytes.isEmpty else { throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key encoding.") } if header == "-----BEGIN OPENSSH PRIVATE KEY-----" { let magic = Data("openssh-key-v1\0".utf8) guard bytes.starts(with: magic) else { throw invalidEnvelope } var offset = magic.count guard let cipher = readSSHField(bytes, offset: &offset), let kdf = readSSHField(bytes, offset: &offset), let options = readSSHField(bytes, offset: &offset), offset + 4 <= bytes.count else { throw invalidEnvelope } let count = bytes[offset..= 8) else { throw invalidEnvelope } if !encrypted { try validateOpenSSHPrivateBlock(privateBlob, algorithm: material.algorithm) } return PrivateKeyMetadata( algorithm: material.algorithm, keySize: material.keySize, isEncrypted: encrypted) } if header == "-----BEGIN ENCRYPTED PRIVATE KEY-----" { let valid = bytes.withUnsafeBytes { buffer -> Bool in var pointer = buffer.bindMemory(to: UInt8.self).baseAddress let start = pointer guard let value = d2i_X509_SIG(nil, &pointer, bytes.count) else { return false } defer { X509_SIG_free(value) } return pointer == start?.advanced(by: bytes.count) } guard valid else { throw invalidEnvelope } return PrivateKeyMetadata(algorithm: .unknown, keySize: nil, isEncrypted: true) } if lines.contains(where: { $0.hasPrefix("DEK-Info:") }) { guard lines.contains(where: { $0.contains("Proc-Type: 4,ENCRYPTED") }), bytes.count >= 16, ["-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----"].contains(header) else { throw invalidEnvelope } return PrivateKeyMetadata( algorithm: header.contains("RSA") ? .rsa : .unknown, keySize: nil, isEncrypted: true) } return try text.withCString { buffer in guard let bio = BIO_new_mem_buf(buffer, Int32(text.utf8.count)) else { throw invalidEnvelope } defer { BIO_free(bio) } guard let key = PEM_read_bio_PrivateKey(bio, nil, { _, _, _, _ in 0 }, nil) else { throw invalidEnvelope } defer { EVP_PKEY_free(key) } let bits = Int(EVP_PKEY_get_bits(key)) let algorithm: SSHKeyAlgorithm switch EVP_PKEY_get_base_id(key) { case EVP_PKEY_ED25519: algorithm = .ed25519 case EVP_PKEY_RSA: algorithm = .rsa case EVP_PKEY_EC: switch bits { case 256: algorithm = .ecdsa256 case 384: algorithm = .ecdsa384 case 521: algorithm = .ecdsa521 default: throw SSHKeyMaterialError.unsupportedAlgorithm("EC \(bits)") } default: throw SSHKeyMaterialError.unsupportedAlgorithm("PEM") } return PrivateKeyMetadata(algorithm: algorithm, keySize: bits, isEncrypted: false) } } private static func validateOpenSSHPrivateBlock(_ bytes: Data, algorithm: SSHKeyAlgorithm) throws { guard bytes.count >= 8, bytes.prefix(4) == bytes.dropFirst(4).prefix(4) else { throw invalidEnvelope } var offset = 8 guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.utf8) else { throw invalidEnvelope } switch algorithm { case .ed25519: guard let publicKey = readSSHField(bytes, offset: &offset), publicKey.count == 32, let privateKey = readSSHField(bytes, offset: &offset), privateKey.count == 64, privateKey.suffix(32) == publicKey else { throw invalidEnvelope } case .rsa: for _ in 0..<6 { guard let field = readSSHField(bytes, offset: &offset), !field.isEmpty else { throw invalidEnvelope } } case .ecdsa256, .ecdsa384, .ecdsa521: guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.dropFirst(11).utf8), let point = readSSHField(bytes, offset: &offset), point.first == 4, let scalar = readSSHField(bytes, offset: &offset), !scalar.isEmpty else { throw invalidEnvelope } case .unknown, .securityKey: throw invalidEnvelope } guard readSSHField(bytes, offset: &offset) != nil else { throw invalidEnvelope } let padding = bytes.dropFirst(offset) guard padding.count <= 255, padding.enumerated().allSatisfy({ $0.element == UInt8($0.offset + 1) }) else { throw invalidEnvelope } } public struct ImportAnalysis: Sendable { public let metadata: PrivateKeyMetadata public let material: Result } /// Parses the private envelope once, retaining its metadata even if the supplied public key is invalid. public static func analyzeImport(privateKey: String, publicKey: String) throws -> ImportAnalysis { let metadata = try privateKeyMetadata(privateKey) let material = Result { let publicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !publicKey.isEmpty else { return SSHKeyMaterial( privateKey: privateKey, publicKey: "", algorithm: metadata.algorithm, fingerprint: "", keySize: metadata.keySize) } var material = try publicMaterial(publicKey) guard material.algorithm != .securityKey, metadata.algorithm == .unknown || material.algorithm == metadata.algorithm else { throw SSHUserCertificateError.keyMismatch } material.privateKey = privateKey return material } return ImportAnalysis(metadata: metadata, material: material) } public static func imported(privateKey: String, publicKey: String) throws -> SSHKeyMaterial { try analyzeImport(privateKey: privateKey, publicKey: publicKey).material.get() } /// Validates a single OpenSSH public key, including the method inside its wire blob. public static func publicMaterial(_ text: String) throws -> SSHKeyMaterial { let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let fields = text.split(whereSeparator: \.isWhitespace) guard !text.contains("\n"), !text.contains("\r"), !text.contains("\0"), fields.count >= 2, let algorithm = SSHKeyAlgorithm(rawValue: String(fields[0])), algorithm != .unknown, let blob = Data(base64Encoded: String(fields[1])) else { throw SSHUserCertificateError.invalidFormat } var offset = 0 guard readSSHField(blob, offset: &offset) == Data(fields[0].utf8) else { throw SSHUserCertificateError.invalidFormat } switch algorithm { case .ed25519: guard readSSHField(blob, offset: &offset)?.count == 32 else { throw SSHUserCertificateError.invalidFormat } case .rsa: guard let exponent = readSSHField(blob, offset: &offset), !exponent.isEmpty, let modulus = readSSHField(blob, offset: &offset), !modulus.isEmpty else { throw SSHUserCertificateError.invalidFormat } case .ecdsa256, .ecdsa384, .ecdsa521, .securityKey: let curve = algorithm == .securityKey ? "nistp256" : String(algorithm.rawValue.dropFirst(11)) let length = algorithm == .ecdsa521 ? 133 : algorithm == .ecdsa384 ? 97 : 65 guard readSSHField(blob, offset: &offset) == Data(curve.utf8), let point = readSSHField(blob, offset: &offset), point.count == length, point.first == 4 else { throw SSHUserCertificateError.invalidFormat } if algorithm == .securityKey { guard let application = readSSHField(blob, offset: &offset), !application.isEmpty else { throw SSHUserCertificateError.invalidFormat } } case .unknown: throw SSHUserCertificateError.invalidFormat } guard offset == blob.count else { throw SSHUserCertificateError.invalidFormat } return SSHKeyMaterial( privateKey: "", publicKey: text, algorithm: algorithm, fingerprint: SSHWireEncoding.fingerprint(of: blob), keySize: inferredKeySize(for: algorithm, publicBlob: blob)) } private static var invalidEnvelope: SSHKeyMaterialError { .invalidPrivateKey("Invalid private key envelope.") } }