diff --git a/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305.swift b/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305.swift deleted file mode 100644 index 3ab1fef..0000000 --- a/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305.swift +++ /dev/null @@ -1,369 +0,0 @@ -import Foundation - -// MARK: - ChaCha20-Poly1305 (OpenSSH variant) InlineArray / Span optimized - -/// ChaCha20-Poly1305 implementation for OpenSSH compatibility -/// Note: OpenSSH uses a custom construction that differs from standard ChaCha20-Poly1305 -struct ChaCha20Poly1305OpenSSH { - - /// Encrypt data using ChaCha20-Poly1305 (OpenSSH variant) - /// OpenSSH uses 64-byte keys: 32 bytes for main cipher, 32 bytes for header - static func encrypt(data: Data, key: Data, iv: Data) throws -> Data { - guard key.count == 64 else { throw SSHKeyError.invalidKeyData } - // OpenSSH layout: first 32 bytes main cipher+MAC key (K2), second 32 bytes header key (unused here) - var mainKey = InlineArray<32, UInt8>(repeating: 0) - for index in 0..<32 { mainKey[index] = key[index] } - - // Sequence (nonce) (8 bytes) — copy if provided - var sequence = InlineArray<8, UInt8>(repeating: 0) - if iv.count >= 8 { - let ivSpan = iv.span - for index in 0..<8 { sequence[index] = ivSpan[index] } - } - - // 16‑byte IV buffer: counter (8 bytes little‑endian) || sequence (8 bytes) - var ivCounter0 = InlineArray<16, UInt8>(repeating: 0) - for index in 0..<8 { ivCounter0[8 + index] = sequence[index] } - - var context = ChaCha20Context(key: mainKey) - context.ivSetup(ivInline: ivCounter0) - - // Derive Poly1305 key (32 bytes) = first half of keystream block 0 - let polyKeystream = context.generateKeystream() // does NOT mutate external counter state - var polyKey = InlineArray<32, UInt8>(repeating: 0) - for index in 0..<32 { polyKey[index] = polyKeystream[index] } - - // Prepare counter=1 for payload (increment low 32 bits) - var ivCounter1 = ivCounter0 - ivCounter1[0] = 1 // little-endian increment (only first byte needed in this construction) - context.ivSetup(ivInline: ivCounter1) - - // Encrypt payload streaming (avoid per-block allocations) - var encryptedData = Data(count: data.count) - context.encrypt(src: data, dst: &encryptedData) - - // MAC over ciphertext (no AAD) — Poly1305 expects Data key (convert once) - let tag = Poly1305.auth(message: encryptedData, key: polyKey.toData()) - return encryptedData + tag - } - - /// Decrypt data using ChaCha20-Poly1305 (OpenSSH variant) - static func decrypt(data: Data, key: Data, iv: Data) throws -> Data { - guard key.count == 64 else { throw SSHKeyError.invalidKeyData } - guard data.count >= 16 else { throw SSHKeyError.invalidKeyData } - - let ciphertextLength = data.count - 16 - let ciphertext = data.prefix(ciphertextLength) - let tag = data.suffix(16) - - // Reconstruct sequence - var sequence = InlineArray<8, UInt8>(repeating: 0) - if iv.count >= 8 { - let ivSpan = iv.span - for index in 0..<8 { sequence[index] = ivSpan[index] } - } - var ivCounter0 = InlineArray<16, UInt8>(repeating: 0) - for index in 0..<8 { ivCounter0[8 + index] = sequence[index] } - - var mainKey = InlineArray<32, UInt8>(repeating: 0) - for index in 0..<32 { mainKey[index] = key[index] } - var context = ChaCha20Context(key: mainKey) - context.ivSetup(ivInline: ivCounter0) - - // Derive poly key - let polyKeystream = context.generateKeystream() - var polyKey = InlineArray<32, UInt8>(repeating: 0) - for index in 0..<32 { polyKey[index] = polyKeystream[index] } - - let expected = Poly1305.auth(message: Data(ciphertext), key: polyKey.toData()) - guard constantTimeEqual(expected, tag) else { throw SSHKeyError.invalidKeyData } - - // Decrypt (counter=1) - var ivCounter1 = ivCounter0 - ivCounter1[0] = 1 - context.ivSetup(ivInline: ivCounter1) - var decryptedData = Data(count: ciphertextLength) - context.encrypt(src: Data(ciphertext), dst: &decryptedData) - return decryptedData - } - - /// Constant-time comparison (16-byte tags here) - private static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool { - if a.count != b.count { return false } - let spanA = a.span - let spanB = b.span - var diff: UInt8 = 0 - for index in 0.. - private var state: ChaChaState - - init(key: InlineArray<32, UInt8>) { - self.state = ChaChaState(repeating: 0) - keySetup(key: key) - } - - mutating func keySetup(key: InlineArray<32, UInt8>) { - // Constants "expand 32-byte k" - state[0] = 0x61707865 - state[1] = 0x3320646e - state[2] = 0x79622d32 - state[3] = 0x6b206574 - for index in 0..<8 { - let offset = index * 4 - state[4 + index] = UInt32(key[offset]) | - UInt32(key[offset + 1]) << 8 | - UInt32(key[offset + 2]) << 16 | - UInt32(key[offset + 3]) << 24 - } - } - - mutating func ivSetup(ivInline: InlineArray<16, UInt8>) { - // Counter (first 8 bytes little-endian as two words) - state[12] = UInt32(ivInline[0]) | UInt32(ivInline[1]) << 8 | UInt32(ivInline[2]) << 16 | UInt32(ivInline[3]) << 24 - state[13] = UInt32(ivInline[4]) | UInt32(ivInline[5]) << 8 | UInt32(ivInline[6]) << 16 | UInt32(ivInline[7]) << 24 - // Nonce (next 8 bytes) - state[14] = UInt32(ivInline[8]) | UInt32(ivInline[9]) << 8 | UInt32(ivInline[10]) << 16 | UInt32(ivInline[11]) << 24 - state[15] = UInt32(ivInline[12]) | UInt32(ivInline[13]) << 8 | UInt32(ivInline[14]) << 16 | UInt32(ivInline[15]) << 24 - } - - mutating func encrypt(src: Data, dst: inout Data) { - var srcOffset = 0 - var dstOffset = 0 - while srcOffset < src.count { - let blockSize = min(64, src.count - srcOffset) - let keystream = generateKeystream() - for index in 0.. InlineArray<64, UInt8> { - var working = state // copy - for _ in 0..<10 { // 20 rounds (10 double rounds) - quarterRound(&working, 0, 4, 8, 12) - quarterRound(&working, 1, 5, 9, 13) - quarterRound(&working, 2, 6, 10, 14) - quarterRound(&working, 3, 7, 11, 15) - quarterRound(&working, 0, 5, 10, 15) - quarterRound(&working, 1, 6, 11, 12) - quarterRound(&working, 2, 7, 8, 13) - quarterRound(&working, 3, 4, 9, 14) - } - for index in 0..<16 { working[index] = working[index] &+ state[index] } - var output = InlineArray<64, UInt8>(repeating: 0) - for index in 0..<16 { - let offset = index * 4 - let word = working[index] - output[offset] = UInt8(word & 0xff) - output[offset + 1] = UInt8((word >> 8) & 0xff) - output[offset + 2] = UInt8((word >> 16) & 0xff) - output[offset + 3] = UInt8((word >> 24) & 0xff) - } - return output - } - - private func quarterRound(_ state: inout ChaChaState, _ a: Int, _ b: Int, _ c: Int, _ d: Int) { - state[a] = state[a] &+ state[b]; state[d] = rotateLeft32(state[d] ^ state[a], 16) - state[c] = state[c] &+ state[d]; state[b] = rotateLeft32(state[b] ^ state[c], 12) - state[a] = state[a] &+ state[b]; state[d] = rotateLeft32(state[d] ^ state[a], 8) - state[c] = state[c] &+ state[d]; state[b] = rotateLeft32(state[b] ^ state[c], 7) - } - private func rotateLeft32(_ value: UInt32, _ shift: Int) -> UInt32 { (value << shift) | (value >> (32 - shift)) } -} - -/// Poly1305 MAC implementation -private struct Poly1305 { - /// Generate Poly1305 authentication tag - static func auth(message: Data, key: Data) -> Data { - guard key.count == 32 else { return Data(count: 16) } - - // Initialize r (first 16 bytes of key) - var t0 = UInt32(key[0]) | UInt32(key[1]) << 8 | UInt32(key[2]) << 16 | UInt32(key[3]) << 24 - var t1 = UInt32(key[4]) | UInt32(key[5]) << 8 | UInt32(key[6]) << 16 | UInt32(key[7]) << 24 - var t2 = UInt32(key[8]) | UInt32(key[9]) << 8 | UInt32(key[10]) << 16 | UInt32(key[11]) << 24 - var t3 = UInt32(key[12]) | UInt32(key[13]) << 8 | UInt32(key[14]) << 16 | UInt32(key[15]) << 24 - - // Clamp and compute r values (matching OpenSSH) - let r0 = t0 & 0x3ffffff - t0 >>= 26 - t0 |= t1 << 6 - let r1 = t0 & 0x3ffff03 - t1 >>= 20 - t1 |= t2 << 12 - let r2 = t1 & 0x3ffc0ff - t2 >>= 14 - t2 |= t3 << 18 - let r3 = t2 & 0x3f03fff - t3 >>= 8 - let r4 = t3 & 0x00fffff - - // Precompute multipliers - let s1 = r1 &* 5 - let s2 = r2 &* 5 - let s3 = r3 &* 5 - let s4 = r4 &* 5 - - // Initialize h - var h0: UInt32 = 0 - var h1: UInt32 = 0 - var h2: UInt32 = 0 - var h3: UInt32 = 0 - var h4: UInt32 = 0 - - // Process message in 16-byte blocks - var offset = 0 - - // Process full blocks - while offset + 16 <= message.count { - let t0 = UInt32(message[offset]) | UInt32(message[offset+1]) << 8 | UInt32(message[offset+2]) << 16 | UInt32(message[offset+3]) << 24 - let t1 = UInt32(message[offset+4]) | UInt32(message[offset+5]) << 8 | UInt32(message[offset+6]) << 16 | UInt32(message[offset+7]) << 24 - let t2 = UInt32(message[offset+8]) | UInt32(message[offset+9]) << 8 | UInt32(message[offset+10]) << 16 | UInt32(message[offset+11]) << 24 - let t3 = UInt32(message[offset+12]) | UInt32(message[offset+13]) << 8 | UInt32(message[offset+14]) << 16 | UInt32(message[offset+15]) << 24 - - h0 += t0 & 0x3ffffff - let temp1 = (UInt64(t1) << 32) | UInt64(t0) - h1 += UInt32(truncatingIfNeeded: (temp1 >> 26) & 0x3ffffff) - let temp2 = (UInt64(t2) << 32) | UInt64(t1) - h2 += UInt32(truncatingIfNeeded: (temp2 >> 20) & 0x3ffffff) - let temp3 = (UInt64(t3) << 32) | UInt64(t2) - h3 += UInt32(truncatingIfNeeded: (temp3 >> 14) & 0x3ffffff) - h4 += (t3 >> 8) | (1 << 24) - - // Multiply by r - let d0 = UInt64(h0) * UInt64(r0) + UInt64(h1) * UInt64(s4) + UInt64(h2) * UInt64(s3) + UInt64(h3) * UInt64(s2) + UInt64(h4) * UInt64(s1) - let d1 = UInt64(h0) * UInt64(r1) + UInt64(h1) * UInt64(r0) + UInt64(h2) * UInt64(s4) + UInt64(h3) * UInt64(s3) + UInt64(h4) * UInt64(s2) - let d2 = UInt64(h0) * UInt64(r2) + UInt64(h1) * UInt64(r1) + UInt64(h2) * UInt64(r0) + UInt64(h3) * UInt64(s4) + UInt64(h4) * UInt64(s3) - let d3 = UInt64(h0) * UInt64(r3) + UInt64(h1) * UInt64(r2) + UInt64(h2) * UInt64(r1) + UInt64(h3) * UInt64(r0) + UInt64(h4) * UInt64(s4) - let d4 = UInt64(h0) * UInt64(r4) + UInt64(h1) * UInt64(r3) + UInt64(h2) * UInt64(r2) + UInt64(h3) * UInt64(r1) + UInt64(h4) * UInt64(r0) - - // Carry propagation - var c: UInt64 - h0 = UInt32(truncatingIfNeeded: d0) & 0x3ffffff; c = d0 >> 26 - let t1c = d1 + c; h1 = UInt32(truncatingIfNeeded: t1c) & 0x3ffffff; c = t1c >> 26 - let t2c = d2 + c; h2 = UInt32(truncatingIfNeeded: t2c) & 0x3ffffff; c = t2c >> 26 - let t3c = d3 + c; h3 = UInt32(truncatingIfNeeded: t3c) & 0x3ffffff; c = t3c >> 26 - let t4c = d4 + c; h4 = UInt32(truncatingIfNeeded: t4c) & 0x3ffffff; c = t4c >> 26 - h0 = h0 &+ UInt32(truncatingIfNeeded: c * 5) - - offset += 16 - } - - // Process final partial block if any - if offset < message.count { - var paddedBlock = InlineArray<16, UInt8>(repeating: 0) - let remaining = message.count - offset - for index in 0..> 26) & 0x3ffffff) - let ptemp2 = (UInt64(t2) << 32) | UInt64(t1) - h2 += UInt32(truncatingIfNeeded: (ptemp2 >> 20) & 0x3ffffff) - let ptemp3 = (UInt64(t3) << 32) | UInt64(t2) - h3 += UInt32(truncatingIfNeeded: (ptemp3 >> 14) & 0x3ffffff) - h4 += (t3 >> 8) - - // Final multiply - let d0 = UInt64(h0) * UInt64(r0) + UInt64(h1) * UInt64(s4) + UInt64(h2) * UInt64(s3) + UInt64(h3) * UInt64(s2) + UInt64(h4) * UInt64(s1) - let d1 = UInt64(h0) * UInt64(r1) + UInt64(h1) * UInt64(r0) + UInt64(h2) * UInt64(s4) + UInt64(h3) * UInt64(s3) + UInt64(h4) * UInt64(s2) - let d2 = UInt64(h0) * UInt64(r2) + UInt64(h1) * UInt64(r1) + UInt64(h2) * UInt64(r0) + UInt64(h3) * UInt64(s4) + UInt64(h4) * UInt64(s3) - let d3 = UInt64(h0) * UInt64(r3) + UInt64(h1) * UInt64(r2) + UInt64(h2) * UInt64(r1) + UInt64(h3) * UInt64(r0) + UInt64(h4) * UInt64(s4) - let d4 = UInt64(h0) * UInt64(r4) + UInt64(h1) * UInt64(r3) + UInt64(h2) * UInt64(r2) + UInt64(h3) * UInt64(r1) + UInt64(h4) * UInt64(r0) - - // Carry propagation - var c: UInt64 - h0 = UInt32(truncatingIfNeeded: d0) & 0x3ffffff; c = d0 >> 26 - let t1c = d1 + c; h1 = UInt32(truncatingIfNeeded: t1c) & 0x3ffffff; c = t1c >> 26 - let t2c = d2 + c; h2 = UInt32(truncatingIfNeeded: t2c) & 0x3ffffff; c = t2c >> 26 - let t3c = d3 + c; h3 = UInt32(truncatingIfNeeded: t3c) & 0x3ffffff; c = t3c >> 26 - let t4c = d4 + c; h4 = UInt32(truncatingIfNeeded: t4c) & 0x3ffffff; c = t4c >> 26 - h0 = h0 &+ UInt32(truncatingIfNeeded: c * 5) - } - - // Final reduction - var b = h0 >> 26; h0 = h0 & 0x3ffffff - h1 += b; b = h1 >> 26; h1 = h1 & 0x3ffffff - h2 += b; b = h2 >> 26; h2 = h2 & 0x3ffffff - h3 += b; b = h3 >> 26; h3 = h3 & 0x3ffffff - h4 += b; b = h4 >> 26; h4 = h4 & 0x3ffffff - h0 += b * 5; b = h0 >> 26; h0 = h0 & 0x3ffffff - h1 += b - - // Compute g = h + 5 - var g0 = h0 + 5; b = g0 >> 26; g0 &= 0x3ffffff - var g1 = h1 + b; b = g1 >> 26; g1 &= 0x3ffffff - var g2 = h2 + b; b = g2 >> 26; g2 &= 0x3ffffff - var g3 = h3 + b; b = g3 >> 26; g3 &= 0x3ffffff - let g4 = h4 &+ b &- (1 << 26) - - // b = (g4 >> 31) - 1, nb = ~b - // In C: if g4 has high bit set (would be negative), (g4 >> 31) = 1, so b = 0 - // if g4 has high bit clear, (g4 >> 31) = 0, so b = -1 (all bits set) - let selectMask = (g4 >> 31) == 0 ? UInt32.max : 0 - let invertedMask = ~selectMask - - h0 = (h0 & invertedMask) | (g0 & selectMask) - h1 = (h1 & invertedMask) | (g1 & selectMask) - h2 = (h2 & invertedMask) | (g2 & selectMask) - h3 = (h3 & invertedMask) | (g3 & selectMask) - h4 = (h4 & invertedMask) | (g4 & selectMask) - - // Load s (last 16 bytes of key) - let subKey0 = UInt32(key[16]) | UInt32(key[17]) << 8 | UInt32(key[18]) << 16 | UInt32(key[19]) << 24 - let subKey1 = UInt32(key[20]) | UInt32(key[21]) << 8 | UInt32(key[22]) << 16 | UInt32(key[23]) << 24 - let subKey2 = UInt32(key[24]) | UInt32(key[25]) << 8 | UInt32(key[26]) << 16 | UInt32(key[27]) << 24 - let subKey3 = UInt32(key[28]) | UInt32(key[29]) << 8 | UInt32(key[30]) << 16 | UInt32(key[31]) << 24 - - // Add s - var f0 = UInt64(h0) | (UInt64(h1) << 26) - f0 += UInt64(subKey0) - var f1 = UInt64(h1 >> 6) | (UInt64(h2) << 20) - f1 += UInt64(subKey1) - var f2 = UInt64(h2 >> 12) | (UInt64(h3) << 14) - f2 += UInt64(subKey2) - var f3 = UInt64(h3 >> 18) | (UInt64(h4) << 8) - f3 += UInt64(subKey3) - - // Create tag with carry propagation - var tagInline = InlineArray<16, UInt8>(repeating: 0) - tagInline[0] = UInt8(f0 & 0xff) - tagInline[1] = UInt8((f0 >> 8) & 0xff) - tagInline[2] = UInt8((f0 >> 16) & 0xff) - tagInline[3] = UInt8((f0 >> 24) & 0xff) - f1 += (f0 >> 32) - tagInline[4] = UInt8(f1 & 0xff) - tagInline[5] = UInt8((f1 >> 8) & 0xff) - tagInline[6] = UInt8((f1 >> 16) & 0xff) - tagInline[7] = UInt8((f1 >> 24) & 0xff) - f2 += (f1 >> 32) - tagInline[8] = UInt8(f2 & 0xff) - tagInline[9] = UInt8((f2 >> 8) & 0xff) - tagInline[10] = UInt8((f2 >> 16) & 0xff) - tagInline[11] = UInt8((f2 >> 24) & 0xff) - f3 += (f2 >> 32) - tagInline[12] = UInt8(f3 & 0xff) - tagInline[13] = UInt8((f3 >> 8) & 0xff) - tagInline[14] = UInt8((f3 >> 16) & 0xff) - tagInline[15] = UInt8((f3 >> 24) & 0xff) - return tagInline.toData() - } -} diff --git a/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305OpenSSH.swift b/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305OpenSSH.swift new file mode 100644 index 0000000..ffe01a0 --- /dev/null +++ b/Sources/SwiftKeyGen/Cryptography/Ciphers/ChaCha20Poly1305OpenSSH.swift @@ -0,0 +1,350 @@ +import Foundation +import BigInt + +/// ChaCha20-Poly1305 implementation mirroring OpenSSH's chachapoly cipher. +/// This variant uses a 64-byte key split into a main encryption key (first +/// 32 bytes) and a header key (second 32 bytes). The nonce is an 8-byte packet +/// sequence encoded in little-endian form. Authentication tags are 16 bytes. +enum ChaCha20Poly1305OpenSSH { + private static let keyLength = 64 + private static let polyKeyLength = 32 + private static let tagLength = 16 + + static func encrypt( + data: Data, + key: Data, + iv: Data, + aadLength: Int = 0 + ) throws -> Data { + guard key.count == keyLength else { + throw SSHKeyError.invalidKeySize(key.count, "ChaCha20-Poly1305 requires a 64-byte key") + } + let nonce = try prepareNonce(from: iv) + let clampedAAD = clampAADLength(aadLength, dataLength: data.count) + let messageLength = data.count - clampedAAD + + var mainCtx = ChaCha20Core(keyBytes: Array(key.prefix(32))) + var headerCtx = ChaCha20Core(keyBytes: Array(key.suffix(32))) + + var keystream = [UInt8](repeating: 0, count: 64) + mainCtx.setNonce(nonce, counter: 0) + mainCtx.generateKeystream(into: &keystream) + var polyKey = Array(keystream.prefix(polyKeyLength)) + + var output = Data(count: data.count + tagLength) + output.withUnsafeMutableBytes { destPtr in + guard let destBase = destPtr.baseAddress else { return } + data.withUnsafeBytes { srcPtr in + guard let srcBase = srcPtr.baseAddress else { return } + + if clampedAAD > 0 { + headerCtx.setNonce(nonce, counter: 0) + let aadInput = UnsafeRawBufferPointer(start: srcBase, count: clampedAAD) + var aadOutput = UnsafeMutableRawBufferPointer(start: destBase, count: clampedAAD) + headerCtx.xor(input: aadInput, output: &aadOutput) + } + + if messageLength > 0 { + mainCtx.setNonce(nonce, counter: 1) + let msgInput = UnsafeRawBufferPointer( + start: srcBase.advanced(by: clampedAAD), + count: messageLength + ) + var msgOutput = UnsafeMutableRawBufferPointer( + start: destBase.advanced(by: clampedAAD), + count: messageLength + ) + mainCtx.xor(input: msgInput, output: &msgOutput) + } + } + } + + let ciphertextSlice = output.prefix(data.count) + let tag = Poly1305.tag(for: Array(ciphertextSlice), key: polyKey) + output.replaceSubrange(data.count.. Data { + guard data.count >= tagLength else { + throw SSHKeyError.invalidFormat + } + guard key.count == keyLength else { + throw SSHKeyError.invalidKeySize(key.count, "ChaCha20-Poly1305 requires a 64-byte key") + } + + let nonce = try prepareNonce(from: iv) + let ciphertext = data.prefix(data.count - tagLength) + let receivedTag = Array(data.suffix(tagLength)) + let clampedAAD = clampAADLength(aadLength, dataLength: ciphertext.count) + let messageLength = ciphertext.count - clampedAAD + + var mainCtx = ChaCha20Core(keyBytes: Array(key.prefix(32))) + var headerCtx = ChaCha20Core(keyBytes: Array(key.suffix(32))) + + var keystream = [UInt8](repeating: 0, count: 64) + mainCtx.setNonce(nonce, counter: 0) + mainCtx.generateKeystream(into: &keystream) + var polyKey = Array(keystream.prefix(polyKeyLength)) + + let expectedTag = Poly1305.tag(for: Array(ciphertext), key: polyKey) + let tagsMatch = constantTimeEquals(expectedTag, receivedTag) + polyKey.resetBytes() + keystream.resetBytes() + + guard tagsMatch else { + throw SSHKeyError.decryptionFailed + } + + var plaintext = Data(count: ciphertext.count) + plaintext.withUnsafeMutableBytes { destPtr in + guard let destBase = destPtr.baseAddress else { return } + ciphertext.withUnsafeBytes { srcPtr in + guard let srcBase = srcPtr.baseAddress else { return } + + if clampedAAD > 0 { + headerCtx.setNonce(nonce, counter: 0) + let aadInput = UnsafeRawBufferPointer(start: srcBase, count: clampedAAD) + var aadOutput = UnsafeMutableRawBufferPointer(start: destBase, count: clampedAAD) + headerCtx.xor(input: aadInput, output: &aadOutput) + } + + if messageLength > 0 { + mainCtx.setNonce(nonce, counter: 1) + let msgInput = UnsafeRawBufferPointer( + start: srcBase.advanced(by: clampedAAD), + count: messageLength + ) + var msgOutput = UnsafeMutableRawBufferPointer( + start: destBase.advanced(by: clampedAAD), + count: messageLength + ) + mainCtx.xor(input: msgInput, output: &msgOutput) + } + } + } + + return plaintext + } + + private static func clampAADLength(_ requested: Int, dataLength: Int) -> Int { + if requested <= 0 { + return 0 + } + return min(requested, dataLength) + } + + private static func prepareNonce(from iv: Data) throws -> [UInt8] { + switch iv.count { + case 0: + return [UInt8](repeating: 0, count: 8) + case 8: + return Array(iv) + default: + throw SSHKeyError.invalidKeySize(iv.count, "ChaCha20-Poly1305 nonce must be 8 bytes") + } + } + + private static func constantTimeEquals(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + guard lhs.count == rhs.count else { + return false + } + var diff: UInt8 = 0 + for index in 0..> 32) + state[14] = Self.loadUInt32(from: nonce, offset: 0) + state[15] = Self.loadUInt32(from: nonce, offset: 4) + } + + mutating func xor( + input: UnsafeRawBufferPointer, + output: inout UnsafeMutableRawBufferPointer + ) { + guard let inBase = input.baseAddress, let outBase = output.baseAddress else { + return + } + + var remaining = input.count + var inputPointer = inBase.assumingMemoryBound(to: UInt8.self) + var outputPointer = outBase.assumingMemoryBound(to: UInt8.self) + var keystream = [UInt8](repeating: 0, count: 64) + + while remaining > 0 { + let blockSize = min(remaining, 64) + generateKeystream(into: &keystream) + for index in 0..= 64) + + var workingState = state + + for _ in 0..<10 { + quarterRound(&workingState, 0, 4, 8, 12) + quarterRound(&workingState, 1, 5, 9, 13) + quarterRound(&workingState, 2, 6, 10, 14) + quarterRound(&workingState, 3, 7, 11, 15) + quarterRound(&workingState, 0, 5, 10, 15) + quarterRound(&workingState, 1, 6, 11, 12) + quarterRound(&workingState, 2, 7, 8, 13) + quarterRound(&workingState, 3, 4, 9, 14) + } + + for index in 0..> 8) & 0xff) + buffer[index * 4 + 2] = UInt8(truncatingIfNeeded: (word >> 16) & 0xff) + buffer[index * 4 + 3] = UInt8(truncatingIfNeeded: (word >> 24) & 0xff) + } + + state[12] = state[12] &+ 1 + if state[12] == 0 { + state[13] = state[13] &+ 1 + } + } + + private func rotateLeft(_ value: UInt32, by amount: UInt32) -> UInt32 { + return (value << amount) | (value >> (32 - amount)) + } + + private mutating func quarterRound( + _ state: inout [UInt32], + _ a: Int, + _ b: Int, + _ c: Int, + _ d: Int + ) { + state[a] = state[a] &+ state[b] + state[d] = rotateLeft(state[d] ^ state[a], by: 16) + state[c] = state[c] &+ state[d] + state[b] = rotateLeft(state[b] ^ state[c], by: 12) + state[a] = state[a] &+ state[b] + state[d] = rotateLeft(state[d] ^ state[a], by: 8) + state[c] = state[c] &+ state[d] + state[b] = rotateLeft(state[b] ^ state[c], by: 7) + } + + private static func loadUInt32(from bytes: [UInt8], offset: Int) -> UInt32 { + let b0 = UInt32(bytes[offset]) + let b1 = UInt32(bytes[offset + 1]) << 8 + let b2 = UInt32(bytes[offset + 2]) << 16 + let b3 = UInt32(bytes[offset + 3]) << 24 + return b0 | b1 | b2 | b3 + } + } + + private struct Poly1305 { + static func tag(for message: [UInt8], key: [UInt8]) -> [UInt8] { + precondition(key.count == ChaCha20Poly1305OpenSSH.polyKeyLength) + + var rBytes = Array(key.prefix(16)) + clampR(&rBytes) + let sBytes = Array(key.suffix(16)) + + let r = bigUInt(fromLittleEndian: rBytes) + let s = bigUInt(fromLittleEndian: sBytes) + let modulus = (BigUInt(1) << 130) - 5 + var accumulator = BigUInt(0) + + var index = 0 + while index < message.count { + let blockLength = min(16, message.count - index) + let blockBytes = Array(message[index.. BigUInt { + var value = BigUInt(0) + for (index, byte) in bytes.enumerated() where byte != 0 { + value += BigUInt(byte) << (index * 8) + } + return value + } + + private static func littleEndianBytes(of value: BigUInt, count: Int) -> [UInt8] { + var result = [UInt8](repeating: 0, count: count) + var remaining = value + for index in 0..>= 8 + } + return result + } + } +} + +private extension Array where Element == UInt8 { + mutating func resetBytes() { + for index in indices { + self[index] = 0 + } + } +} diff --git a/Sources/SwiftKeyGen/Cryptography/Ciphers/Cipher.swift b/Sources/SwiftKeyGen/Cryptography/Ciphers/Cipher.swift index 55aaf63..9f2bace 100644 --- a/Sources/SwiftKeyGen/Cryptography/Ciphers/Cipher.swift +++ b/Sources/SwiftKeyGen/Cryptography/Ciphers/Cipher.swift @@ -151,4 +151,4 @@ enum Cipher { // MARK: - Triple DES CBC Implementation is in TripleDES.swift -// MARK: - ChaCha20-Poly1305 Implementation is in ChaCha20Poly1305.swift +// MARK: - ChaCha20-Poly1305 Implementation is in ChaCha20Poly1305OpenSSH.swift diff --git a/Sources/SwiftKeyGenCLI/main.swift b/Sources/SwiftKeyGenCLI/main.swift index 8529054..bface0a 100644 --- a/Sources/SwiftKeyGenCLI/main.swift +++ b/Sources/SwiftKeyGenCLI/main.swift @@ -3,7 +3,7 @@ import SwiftKeyGen struct SwiftKeyGenCLI { // Update this value when publishing a new release (match the git tag) - private static let version = "0.1.9" + private static let version = "0.1.10" static func main() { let arguments = CommandLine.arguments diff --git a/Tests/SwiftKeyGenTests/Cryptography/Ciphers/ChaCha20-Poly1305UnitTests.swift b/Tests/SwiftKeyGenTests/Cryptography/Ciphers/ChaCha20-Poly1305UnitTests.swift index 8163aa3..dcd8be6 100644 --- a/Tests/SwiftKeyGenTests/Cryptography/Ciphers/ChaCha20-Poly1305UnitTests.swift +++ b/Tests/SwiftKeyGenTests/Cryptography/Ciphers/ChaCha20-Poly1305UnitTests.swift @@ -43,4 +43,142 @@ struct ChaCha20Poly1305UnitTests { #expect(testData == decrypted) } -} \ No newline at end of file + + @Test("ChaCha20-Poly1305 worked example encryption") + func testChaCha20Poly1305WorkedExampleEncryption() throws { + guard let vectors = ChaCha20Poly1305Fixtures.workedExample else { + Issue.record("Failed to load ChaCha20-Poly1305 worked example vectors") + return + } + + let encrypted = try ChaCha20Poly1305OpenSSH.encrypt( + data: vectors.plaintext, + key: vectors.key, + iv: vectors.iv, + aadLength: ChaCha20Poly1305Fixtures.aadLength + ) + + #expect(encrypted.count == vectors.fullCiphertext.count) + #expect(encrypted == vectors.fullCiphertext) + } + + @Test("ChaCha20-Poly1305 worked example decryption") + func testChaCha20Poly1305WorkedExampleDecryption() throws { + guard let vectors = ChaCha20Poly1305Fixtures.workedExample else { + Issue.record("Failed to load ChaCha20-Poly1305 worked example vectors") + return + } + + let decrypted = try ChaCha20Poly1305OpenSSH.decrypt( + data: vectors.fullCiphertext, + key: vectors.key, + iv: vectors.iv, + aadLength: ChaCha20Poly1305Fixtures.aadLength + ) + + #expect(decrypted == vectors.plaintext) + } +} + +private enum ChaCha20Poly1305Fixtures { + static let aadLength = 4 + static let workedExample: Vectors? = { + let keyParts = [ + "8b bf f6 85", + "5f c1 02 33", + "8c 37 3e 73", + "aa c0 c9 14", + "f0 76 a9 05", + "b2 44 4a 32", + "ee ca ff ea", + "e2 2b ec c5", + "e9 b7 a7 a5", + "82 5a 82 49", + "34 6e c1 c2", + "83 01 cf 39", + "45 43 fc 75", + "69 88 7d 76", + "e1 68 f3 75", + "62 ac 07 40" + ] + let plaintextParts = [ + "00 00 00 48", + "06 5e 00 00", + "00 00 00 00", + "00 38 4c 6f", + "72 65 6d 20", + "69 70 73 75", + "6d 20 64 6f", + "6c 6f 72 20", + "73 69 74 20", + "61 6d 65 74", + "2c 20 63 6f", + "6e 73 65 63", + "74 65 74 75", + "72 20 61 64", + "69 70 69 73", + "69 63 69 6e", + "67 20 65 6c", + "69 74 4e 43", + "e8 04 dc 6c" + ] + let ciphertextParts = [ + "2c 3e cc e4", + "a5 bc 05 89", + "5b f0 7a 7b", + "a9 56 b6 c6", + "88 29 ac 7c", + "83 b7 80 b7", + "00 0e cd e7", + "45 af c7 05", + "bb c3 78 ce", + "03 a2 80 23", + "6b 87 b5 3b", + "ed 58 39 66", + "23 02 b1 64", + "b6 28 6a 48", + "cd 1e 09 71", + "38 e3 cb 90", + "9b 8b 2b 82", + "9d d1 8d 2a", + "35 ff 82 d9" + ] + let tagParts = [ + "95 34 9e 85", + "5b f0 2c 29", + "8e f7 75 f2", + "d1 a7 e8 b8" + ] + + guard + let key = Data(hexString: keyParts.joined(separator: " ")), + let iv = Data(hexString: "00 00 00 00 00 00 00 07"), + let plaintext = Data(hexString: plaintextParts.joined(separator: " ")), + let ciphertext = Data(hexString: ciphertextParts.joined(separator: " ")), + let tag = Data(hexString: tagParts.joined(separator: " ")) + else { + return nil + } + + var fullCiphertext = ciphertext + fullCiphertext.append(tag) + + return Vectors( + key: key, + iv: iv, + plaintext: plaintext, + ciphertext: ciphertext, + tag: tag, + fullCiphertext: fullCiphertext + ) + }() + + struct Vectors { + let key: Data + let iv: Data + let plaintext: Data + let ciphertext: Data + let tag: Data + let fullCiphertext: Data + } +} diff --git a/Tests/SwiftKeyGenTests/Integration/OpenSSHFormatIntegrationTests.swift b/Tests/SwiftKeyGenTests/Integration/OpenSSHFormatIntegrationTests.swift index 7d29e4c..63d51b6 100644 --- a/Tests/SwiftKeyGenTests/Integration/OpenSSHFormatIntegrationTests.swift +++ b/Tests/SwiftKeyGenTests/Integration/OpenSSHFormatIntegrationTests.swift @@ -224,6 +224,336 @@ struct OpenSSHFormatIntegrationTests { #expect(ourNormalized == theirNormalized, "Public keys should match") } } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes128-ctr)") + func testParseSSHKeygenEd25519EncryptedAES128CTR() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes128ctr") + let passphrase = "test-passphrase-ed25519-aes128-ctr" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes128-ctr@example.com", + "-o", + "-Z", "aes128-ctr" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes128-ctr") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes128ctr.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes192-ctr)") + func testParseSSHKeygenEd25519EncryptedAES192CTR() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes192ctr") + let passphrase = "test-passphrase-ed25519-aes192-ctr" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes192-ctr@example.com", + "-o", + "-Z", "aes192-ctr" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes192-ctr") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes192ctr.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes256-ctr)") + func testParseSSHKeygenEd25519EncryptedAES256CTR() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes256ctr") + let passphrase = "test-passphrase-ed25519-aes256-ctr" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes256-ctr@example.com", + "-o", + "-Z", "aes256-ctr" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes256-ctr") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes256ctr.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes128-cbc)") + func testParseSSHKeygenEd25519EncryptedAES128CBC() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes128cbc") + let passphrase = "test-passphrase-ed25519-aes128-cbc" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes128-cbc@example.com", + "-o", + "-Z", "aes128-cbc" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes128-cbc") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes128cbc.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes192-cbc)") + func testParseSSHKeygenEd25519EncryptedAES192CBC() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes192cbc") + let passphrase = "test-passphrase-ed25519-aes192-cbc" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes192-cbc@example.com", + "-o", + "-Z", "aes192-cbc" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes192-cbc") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes192cbc.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes256-cbc)") + func testParseSSHKeygenEd25519EncryptedAES256CBC() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes256cbc") + let passphrase = "test-passphrase-ed25519-aes256-cbc" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes256-cbc@example.com", + "-o", + "-Z", "aes256-cbc" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes256-cbc") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes256cbc.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes128-gcm)") + func testParseSSHKeygenEd25519EncryptedAES128GCM() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes128gcm") + let passphrase = "test-passphrase-ed25519-aes128-gcm" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes128-gcm@example.com", + "-o", + "-Z", "aes128-gcm@openssh.com" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes128-gcm") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes128gcm.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted aes256-gcm)") + func testParseSSHKeygenEd25519EncryptedAES256GCM() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_aes256gcm") + let passphrase = "test-passphrase-ed25519-aes256-gcm" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-aes256-gcm@example.com", + "-o", + "-Z", "aes256-gcm@openssh.com" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with aes256-gcm") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_aes256gcm.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted 3des-cbc)") + func testParseSSHKeygenEd25519Encrypted3DESCBC() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_3descbc") + let passphrase = "test-passphrase-ed25519-3des-cbc" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-3des-cbc@example.com", + "-o", + "-Z", "3des-cbc" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with 3des-cbc") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_3descbc.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } + + @Test("Parse ssh-keygen Ed25519 key (encrypted chacha20-poly1305)") + func testParseSSHKeygenEd25519EncryptedChaCha20Poly1305() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let keyPath = tempDir.appendingPathComponent("id_ed25519_chacha20poly1305") + let passphrase = "test-passphrase-ed25519-chacha20" + + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "ed25519", + "-f", keyPath.path, + "-N", passphrase, + "-C", "encrypted-ed25519-chacha20@example.com", + "-o", + "-Z", "chacha20-poly1305@openssh.com" + ]) + + #expect(genResult.succeeded, "ssh-keygen should generate encrypted Ed25519 key with chacha20-poly1305") + + #expect(throws: Error.self) { + try KeyManager.readPrivateKey(from: keyPath.path, passphrase: nil) + } + + let key = try KeyManager.readPrivateKey(from: keyPath.path, passphrase: passphrase) + #expect(key is Ed25519Key, "Parsed key should be Ed25519Key") + + let sshPubPath = tempDir.appendingPathComponent("id_ed25519_chacha20poly1305.pub") + let sshPublicKey = try String(contentsOf: sshPubPath, encoding: .utf8) + + let ourNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(key.publicKeyString()) + let theirNormalized = IntegrationTestSupporter.normalizeOpenSSHPublicKey(sshPublicKey) + #expect(ourNormalized == theirNormalized, "Public keys should match") + } + } @Test("Parse ssh-keygen RSA key (encrypted)", .tags(.rsa)) func testParseSSHKeygenRSAEncrypted() throws { @@ -498,4 +828,43 @@ struct OpenSSHFormatIntegrationTests { #expect(ourNorm == extractedNorm, "ssh-keygen extracted public key should match ours") } } + + @Test("Round-trip RSA 2048: ssh-keygen → us → export → ssh-keygen", .tags(.rsa)) + func testRoundTripRSA() throws { + try IntegrationTestSupporter.withTemporaryDirectory { tempDir in + let originalPath = tempDir.appendingPathComponent("original") + let genResult = try IntegrationTestSupporter.runSSHKeygen([ + "-t", "rsa", + "-b", "2048", + "-f", originalPath.path, + "-N", "", + "-C", "roundtrip-rsa@example.com" + ]) + #expect(genResult.succeeded, "ssh-keygen should generate RSA key") + + let key = try KeyManager.readPrivateKey(from: originalPath.path, passphrase: nil) + #expect(key is RSAKey, "Parsed key should be RSAKey") + + let exportPath = tempDir.appendingPathComponent("exported") + let exportData = try OpenSSHPrivateKey.serialize(key: key, passphrase: nil) + try IntegrationTestSupporter.write(exportData, to: exportPath) + + let extractResult = try IntegrationTestSupporter.runSSHKeygen([ + "-y", "-f", exportPath.path + ]) + #expect(extractResult.succeeded, "ssh-keygen should read our exported RSA key") + + let originalPubPath = tempDir.appendingPathComponent("original.pub") + let originalPub = try String(contentsOf: originalPubPath, encoding: .utf8) + let ourPub = key.publicKeyString() + let extractedPub = extractResult.stdout + + let originalNorm = IntegrationTestSupporter.normalizeOpenSSHPublicKey(originalPub) + let ourNorm = IntegrationTestSupporter.normalizeOpenSSHPublicKey(ourPub) + let extractedNorm = IntegrationTestSupporter.normalizeOpenSSHPublicKey(extractedPub) + + #expect(originalNorm == ourNorm, "Our parsed public key should match original") + #expect(ourNorm == extractedNorm, "ssh-keygen extracted public key should match ours") + } + } }