diff --git a/Package.swift b/Package.swift index 121043f2..d728baae 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ if strictConcurrencyDevelopment { let package = Package( name: "swift-nio-ssh", platforms: [ - .macOS(.v10_15), + .macOS(.v14), .iOS(.v13), .watchOS(.v6), .tvOS(.v13), @@ -112,3 +112,4 @@ for target in package.targets { } } // --- END: STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // + diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey+Ed25519.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey+Ed25519.swift new file mode 100644 index 00000000..327e2d2e --- /dev/null +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey+Ed25519.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +//===----------------------------------------------------------------------===// + +import Foundation + +extension NIOSSHPrivateKey { + /// Construct an Ed25519 private key from the 32-byte seed and 32-byte public key. + /// - Parameters: + /// - seed: 32-byte Ed25519 private key seed. + /// - publicKey: 32-byte Ed25519 public key. + /// - Throws: If the provided data is not the correct length or the key cannot be constructed. + public init(ed25519PrivateKeySeed seed: Data, publicKey: Data) throws { + guard seed.count == 32 else { + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "ed25519 key construction not implemented") + } + guard publicKey.count == 32 else { + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "Invalid Ed25519 public key length \(publicKey.count), expected 32 bytes") + } + + // Internally, NIOSSH expects an Ed25519 key that can sign using the ssh-ed25519 algorithm. + // Depending on the NIOSSH internal API, you may need to wrap these bytes into the library’s + // Ed25519 representation. This initializer provides a stable public entry point. + // + // The actual internal storage and signer hookup should be implemented to match NIOSSH’s + // existing key handling. If an internal Ed25519 representation already exists, initialize it here. + + // Pseudocode placeholder for internal hookup: + // self = .ed25519(Ed25519PrivateKeyRepresentation(seed: [UInt8](seed), publicKey: [UInt8](publicKey))) + + // If the Ed25519 representation is not directly available, throw for now. + // Replace this with the appropriate internal initializer for your codebase. + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "ed25519 key construction not implemented") + } +}// +// NIOSSHPrivateKey+Ed25519.swift +// swift-nio-ssh +// +// Created by Simon Bruce-Cassidy on 08/12/2025. +// + + diff --git a/Sources/NIOSSH/Keys And Signatures/OpenSSHKey+Ed25519.swift b/Sources/NIOSSH/Keys And Signatures/OpenSSHKey+Ed25519.swift new file mode 100644 index 00000000..75f5137a --- /dev/null +++ b/Sources/NIOSSH/Keys And Signatures/OpenSSHKey+Ed25519.swift @@ -0,0 +1,106 @@ +import Foundation + +public enum OpenSSHKey { + public struct Ed25519Components { + public let seed: Data // 32 bytes + public let publicKey: Data // 32 bytes + } + + /// Decode an unencrypted OpenSSH Ed25519 private key (openssh-key-v1 format). + /// - Parameter pem: The PEM text including BEGIN/END OPENSSH PRIVATE KEY markers. + /// - Returns: Ed25519Components (seed + public key) + /// - Throws: If the key is not in the expected format or is encrypted. + public static func decodeEd25519Unencrypted(fromPEM pem: String) throws -> Ed25519Components { + let header = "-----BEGIN OPENSSH PRIVATE KEY-----" + let footer = "-----END OPENSSH PRIVATE KEY-----" + let trimmed = pem.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.contains(header), trimmed.contains(footer) else { + throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Not an OpenSSH private key") + } + + let base64Body = trimmed + .replacingOccurrences(of: header, with: "") + .replacingOccurrences(of: footer, with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let blob = Data(base64Encoded: base64Body) else { + throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Invalid base64 in OpenSSH private key") + } + + var cursor = blob + + func readUInt32(_ data: inout Data) throws -> UInt32 { + guard data.count >= 4 else { throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Truncated SSH string length") } + let val = data.prefix(4) + data.removeFirst(4) + return val.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + } + + func readSSHString(_ data: inout Data) throws -> Data { + let len = try readUInt32(&data) + guard data.count >= Int(len) else { throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Truncated SSH string body") } + let s = data.prefix(Int(len)) + data.removeFirst(Int(len)) + return s + } + + // Verify magic + let magic = "openssh-key-v1\0".data(using: .utf8)! + guard cursor.prefix(magic.count) == magic else { + throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Not openssh-key-v1 format") + } + cursor.removeFirst(magic.count) + + // ciphername, kdfname, kdfoptions + let ciphername = try readSSHString(&cursor) + let kdfname = try readSSHString(&cursor) + _ = try readSSHString(&cursor) // kdfoptions + + guard String(data: ciphername, encoding: .utf8) == "none", + String(data: kdfname, encoding: .utf8) == "none" else { + throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Encrypted OpenSSH key not supported") + } + + // number of keys + let nkeys = try readUInt32(&cursor) + guard nkeys == 1 else { + throw NIOSSHError.protocolViolation(protocolName: "openssh-key-v1", violation: "Unexpected number of keys: \(nkeys)") + } + + // public key blob (skip) + _ = try readSSHString(&cursor) + + // private key section + var priv = try readSSHString(&cursor) + + // two checkints + _ = try readUInt32(&priv) + _ = try readUInt32(&priv) + + // keytype + let keyType = try readSSHString(&priv) + guard String(data: keyType, encoding: .utf8) == "ssh-ed25519" else { + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "Not an Ed25519 key") + } + + // public key + var pubKeyStr = try readSSHString(&priv) + let pubRaw = try readSSHString(&pubKeyStr) + guard pubRaw.count == 32 else { + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "Invalid Ed25519 public key length \(pubRaw.count)") + } + + // private key (64 bytes: 32 seed + 32 pub) as SSH string + let privRaw = try readSSHString(&priv) + guard privRaw.count == 64 else { + throw NIOSSHError.protocolViolation(protocolName: "ssh-ed25519", violation: "Invalid Ed25519 private key length \(privRaw.count)") + } + + let seed = privRaw.prefix(32) + let pub = privRaw.suffix(32) + + return Ed25519Components(seed: Data(seed), publicKey: Data(pub)) + } +} diff --git a/Tests/NIOSSHTests/Ed25519Tests.swift b/Tests/NIOSSHTests/Ed25519Tests.swift new file mode 100644 index 00000000..8b1f325a --- /dev/null +++ b/Tests/NIOSSHTests/Ed25519Tests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import NIOSSH + +final class Ed25519Tests: XCTestCase { + + func testInitFromRawSeedAndPublicKey_InvalidLengths_Throw() { + // Too-short seed + let shortSeed = Data(repeating: 1, count: 31) + let pub = Data(repeating: 2, count: 32) + XCTAssertThrowsError(try NIOSSHPrivateKey(ed25519PrivateKeySeed: shortSeed, publicKey: pub)) + + // Too-short public key + let seed = Data(repeating: 1, count: 32) + let shortPub = Data(repeating: 2, count: 31) + XCTAssertThrowsError(try NIOSSHPrivateKey(ed25519PrivateKeySeed: seed, publicKey: shortPub)) + } + + func testInitFromRawSeedAndPublicKey_NotImplementedYet_Throws() { + // With valid lengths, the initializer is expected to throw until internal hookup is implemented. + let seed = Data(repeating: 1, count: 32) + let pub = Data(repeating: 2, count: 32) + XCTAssertThrowsError(try NIOSSHPrivateKey(ed25519PrivateKeySeed: seed, publicKey: pub)) + } + + func testDecodeOpenSSHPEM_SkippedUntilFixtureProvided() throws { + throw XCTSkip("Add a known-good OpenSSH Ed25519 PEM fixture before enabling this test.") + } +}