-
Notifications
You must be signed in to change notification settings - Fork 61
Ed25519 key and signature support #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the SwiftNIO open source project | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This header isn't correct: please provide the complete header block. |
||
|
|
||
| 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function doesn't actually work. |
||
| } | ||
| }// | ||
| // NIOSSHPrivateKey+Ed25519.swift | ||
| // swift-nio-ssh | ||
| // | ||
| // Created by Simon Bruce-Cassidy on 08/12/2025. | ||
| // | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't look like it belongs. |
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import Foundation | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. License header file is missing. |
||
|
|
||
| public enum OpenSSHKey { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we declaring a new namespace here? |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be useful to demonstrate that this does work. Can you wire up a unit test here? |
||
| 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)) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There doesn't appear to be any motivating reason for this change.