197 lines
10 KiB
Swift
197 lines
10 KiB
Swift
// 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..<offset + 4].reduce(0) { ($0 << 8) | Int($1) }
|
|
offset += 4
|
|
guard count == 1, let publicBlob = readSSHField(bytes, offset: &offset),
|
|
let privateBlob = readSSHField(bytes, offset: &offset), !privateBlob.isEmpty,
|
|
offset == bytes.count
|
|
else { throw invalidEnvelope }
|
|
var publicOffset = 0
|
|
guard let method = readSSHField(publicBlob, offset: &publicOffset),
|
|
let name = String(data: method, encoding: .utf8)
|
|
else { throw invalidEnvelope }
|
|
let material = try publicMaterial("\(name) \(publicBlob.base64EncodedString())")
|
|
let encrypted = cipher != Data("none".utf8)
|
|
guard encrypted || (kdf == Data("none".utf8) && options.isEmpty && privateBlob.count >= 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<SSHKeyMaterial, any Error>
|
|
}
|
|
|
|
/// 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<SSHKeyMaterial, any Error> {
|
|
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.")
|
|
}
|
|
}
|