Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions Sources/Citadel/Algorithms/ECDSA.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import Foundation
import Crypto
import _CryptoExtras
import NIOCore
import BigInt

// MARK: - Constants

/// ECDSA point format identifier for uncompressed points
/// In the x963 representation, uncompressed points start with 0x04
private let uncompressedPointPrefix: UInt8 = 0x04

// MARK: - Helper Functions

/// Writes ECDSA public key data to a buffer in SSH format
/// - Parameters:
/// - buffer: The buffer to write to
/// - curveName: The curve name (e.g., "nistp256", "nistp384", "nistp521"), if provided
/// - publicKeyData: The public key data in x963 representation
/// - Returns: The number of bytes written
@discardableResult
private func writeECDSAPublicKey(to buffer: inout ByteBuffer, curveName: String? = nil, publicKeyData: Data) -> Int {
let start = buffer.writerIndex
if let curveName = curveName {
buffer.writeSSHString(curveName)
}
buffer.writeSSHString(publicKeyData)
return buffer.writerIndex - start
}

/// Processes ECDSA private key data by validating its size and removing the leading zero byte if present.
///
/// SSH bignum format may include a leading zero byte to ensure the number is interpreted as unsigned.
/// This function removes that zero byte if present and validates that the resulting data matches
/// the expected key size for the curve.
///
/// - Parameters:
/// - privateKeyData: The raw private key data from SSH format
/// - expectedKeySize: The expected size in bytes for the specific curve (32 for P-256, 48 for P-384, 66 for P-521)
/// - Returns: The processed private key data with the correct size
/// - Throws: `InvalidOpenSSHKey.invalidLayout` if the data size is invalid
private func processECDSAPrivateKeyData(_ privateKeyData: Data, expectedKeySize: Int) throws -> Data {
// Check if we have the expected size with a leading zero byte
if privateKeyData.count == expectedKeySize + 1 && privateKeyData[0] == 0 {
// Remove the leading zero byte
return privateKeyData.dropFirst()
} else if privateKeyData.count == expectedKeySize {
// Already the correct size
return privateKeyData
} else {
// Invalid size
throw InvalidOpenSSHKey.invalidLayout
}
}

extension P256.Signing.PrivateKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
guard
let curveName = buffer.readSSHString(),
let _ = buffer.readSSHBuffer(), // public key - we don't need it for reconstruction
let privateKeyData = buffer.readSSHBignum()
else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp256" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Process the private key data to validate size and remove leading zero if present
let keyData = try processECDSAPrivateKeyData(privateKeyData, expectedKeySize: 32)

return try P256.Signing.PrivateKey(rawRepresentation: keyData)
}

public func write(to buffer: inout ByteBuffer) -> Int {
let start = buffer.writerIndex

// Write curve name and public key
writeECDSAPublicKey(to: &buffer, curveName: "nistp256", publicKeyData: publicKey.x963Representation)

// Write private key as bignum (matching OpenSSH format)
let privateKeyData = self.rawRepresentation
let bignum = BigInt(privateKeyData)
buffer.writeSSHBignum(bignum)

return buffer.writerIndex - start
}
}

extension P384.Signing.PrivateKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
guard
let curveName = buffer.readSSHString(),
let _ = buffer.readSSHBuffer(), // public key - we don't need it for reconstruction
let privateKeyData = buffer.readSSHBignum()
else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp384" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Process the private key data to validate size and remove leading zero if present
let keyData = try processECDSAPrivateKeyData(privateKeyData, expectedKeySize: 48)

return try P384.Signing.PrivateKey(rawRepresentation: keyData)
}

public func write(to buffer: inout ByteBuffer) -> Int {
let start = buffer.writerIndex

// Write curve name and public key
writeECDSAPublicKey(to: &buffer, curveName: "nistp384", publicKeyData: publicKey.x963Representation)

// Write private key as bignum (matching OpenSSH format)
let privateKeyData = self.rawRepresentation
let bignum = BigInt(privateKeyData)
buffer.writeSSHBignum(bignum)

return buffer.writerIndex - start
}
}

extension P521.Signing.PrivateKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
guard
let curveName = buffer.readSSHString(),
let _ = buffer.readSSHBuffer(), // public key - we don't need it for reconstruction
let privateKeyData = buffer.readSSHBignum()
else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp521" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Process the private key data to validate size and remove leading zero if present
let keyData = try processECDSAPrivateKeyData(privateKeyData, expectedKeySize: 66)

return try P521.Signing.PrivateKey(rawRepresentation: keyData)
}

public func write(to buffer: inout ByteBuffer) -> Int {
let start = buffer.writerIndex

// Write curve name and public key
writeECDSAPublicKey(to: &buffer, curveName: "nistp521", publicKeyData: publicKey.x963Representation)

// Write private key as bignum (matching OpenSSH format)
let privateKeyData = self.rawRepresentation
let bignum = BigInt(privateKeyData)
buffer.writeSSHBignum(bignum)

return buffer.writerIndex - start
}
}

// Public key types for ECDSA
extension P256.Signing.PublicKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
// First read the curve name
guard let curveName = buffer.readSSHString() else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp256" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Then read the EC point data
guard let pointData = buffer.readSSHBuffer() else {
throw InvalidOpenSSHKey.invalidLayout
}

let pointBytes = pointData.getBytes(at: 0, length: pointData.readableBytes) ?? []
guard pointBytes.first == uncompressedPointPrefix else { // Uncompressed point
throw InvalidOpenSSHKey.invalidLayout
}

return try P256.Signing.PublicKey(x963Representation: pointBytes)
}

public func write(to buffer: inout ByteBuffer) -> Int {
return writeECDSAPublicKey(to: &buffer, publicKeyData: self.x963Representation)
}
}

extension P384.Signing.PublicKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
// First read the curve name
guard let curveName = buffer.readSSHString() else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp384" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Then read the EC point data
guard let pointData = buffer.readSSHBuffer() else {
throw InvalidOpenSSHKey.invalidLayout
}

let pointBytes = pointData.getBytes(at: 0, length: pointData.readableBytes) ?? []
guard pointBytes.first == uncompressedPointPrefix else { // Uncompressed point
throw InvalidOpenSSHKey.invalidLayout
}

return try P384.Signing.PublicKey(x963Representation: pointBytes)
}

public func write(to buffer: inout ByteBuffer) -> Int {
return writeECDSAPublicKey(to: &buffer, publicKeyData: self.x963Representation)
}
}

extension P521.Signing.PublicKey: ByteBufferConvertible {
public static func read(consuming buffer: inout ByteBuffer) throws -> Self {
// First read the curve name
guard let curveName = buffer.readSSHString() else {
throw InvalidOpenSSHKey.invalidLayout
}

guard curveName == "nistp521" else {
throw InvalidOpenSSHKey.invalidLayout
}

// Then read the EC point data
guard let pointData = buffer.readSSHBuffer() else {
throw InvalidOpenSSHKey.invalidLayout
}

let pointBytes = pointData.getBytes(at: 0, length: pointData.readableBytes) ?? []
guard pointBytes.first == uncompressedPointPrefix else { // Uncompressed point
throw InvalidOpenSSHKey.invalidLayout
}

return try P521.Signing.PublicKey(x963Representation: pointBytes)
}

public func write(to buffer: inout ByteBuffer) -> Int {
return writeECDSAPublicKey(to: &buffer, publicKeyData: self.x963Representation)
}
}

// OpenSSHPrivateKey conformances
extension P256.Signing.PrivateKey: OpenSSHPrivateKey {
public typealias PublicKey = P256.Signing.PublicKey

public static var publicKeyPrefix: String { "ecdsa-sha2-nistp256" }
public static var privateKeyPrefix: String { "ecdsa-sha2-nistp256" }
public static var keyType: OpenSSH.KeyType { .ecdsaP256 }
}

extension P384.Signing.PrivateKey: OpenSSHPrivateKey {
public typealias PublicKey = P384.Signing.PublicKey

public static var publicKeyPrefix: String { "ecdsa-sha2-nistp384" }
public static var privateKeyPrefix: String { "ecdsa-sha2-nistp384" }
public static var keyType: OpenSSH.KeyType { .ecdsaP384 }
}

extension P521.Signing.PrivateKey: OpenSSHPrivateKey {
public typealias PublicKey = P521.Signing.PublicKey

public static var publicKeyPrefix: String { "ecdsa-sha2-nistp521" }
public static var privateKeyPrefix: String { "ecdsa-sha2-nistp521" }
public static var keyType: OpenSSH.KeyType { .ecdsaP521 }
}
58 changes: 58 additions & 0 deletions Sources/Citadel/ByteBufferHelpers.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import NIO
import Foundation
import BigInt

extension ByteBuffer {
mutating func writeSFTPDate(_ date: Date) {
Expand Down Expand Up @@ -125,6 +126,20 @@ extension ByteBuffer {
setInteger(UInt32(writerIndex - oldWriterIndex - 4), at: oldWriterIndex)
}

@discardableResult
mutating func writeSSHString(_ data: Data) -> Int {
let oldWriterIndex = writerIndex
writeInteger(UInt32(data.count))
writeBytes(data)
return writerIndex - oldWriterIndex
}

@discardableResult
mutating func writeSSHString<S: Sequence>(_ bytes: S) -> Int where S.Element == UInt8 {
let data = Data(bytes)
return writeSSHString(data)
}

mutating func readSSHString() -> String? {
guard
let length = getInteger(at: self.readerIndex, as: UInt32.self),
Expand All @@ -148,4 +163,47 @@ extension ByteBuffer {
moveReaderIndex(forwardBy: 4 + Int(length))
return slice
}

/// Reads a BigInt from the buffer in SSH bignum format.
///
/// The SSH bignum format consists of:
/// 1. A 4-byte unsigned integer indicating the length of the bignum data
/// 2. The bignum data itself, as a big-endian byte array
///
/// The data may include a leading zero byte that was added during serialization
/// to ensure the number is interpreted as unsigned (when MSB was set).
///
/// - Returns: The raw bignum data as `Data`, or nil if reading fails
mutating func readSSHBignum() -> Data? {
guard let buffer = readSSHBuffer() else {
return nil
}

return buffer.getData(at: 0, length: buffer.readableBytes)
}

/// Writes a BigInt to the buffer in SSH bignum format.
///
/// The SSH bignum format consists of:
/// 1. A 4-byte unsigned integer indicating the length of the bignum data
/// 2. The bignum data itself, serialized as a big-endian byte array
///
/// SSH bignums must always be interpreted as unsigned. If the most significant bit (MSB)
/// of the first byte is set, the number could be misinterpreted as negative in two's
/// complement representation. To prevent this, a zero byte is prepended when necessary.
///
/// - Parameter bignum: The BigInt value to write in SSH format. The function handles
/// the SSH requirement of prepending zero bytes for unsigned interpretation when
/// necessary.
mutating func writeSSHBignum(_ bignum: BigInt) {
var data = bignum.serialize()

// Prepend zero byte if MSB is set to ensure unsigned interpretation
if !data.isEmpty && (data[0] & 0x80) != 0 {
data.insert(0, at: 0)
}

writeInteger(UInt32(data.count))
writeBytes(data)
}
}
7 changes: 5 additions & 2 deletions Sources/Citadel/OpenSSHKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ extension ByteBuffer {
}
}

enum OpenSSH {
public enum OpenSSH {
enum KeyError: Error {
case missingDecryptionKey, cryptoError
}
Expand Down Expand Up @@ -284,9 +284,12 @@ enum OpenSSH {
}
}

enum KeyType: String {
public enum KeyType: String {
case sshRSA = "ssh-rsa"
case sshED25519 = "ssh-ed25519"
case ecdsaP256 = "ecdsa-sha2-nistp256"
case ecdsaP384 = "ecdsa-sha2-nistp384"
case ecdsaP521 = "ecdsa-sha2-nistp521"
}

struct PrivateKey<SSHKey: OpenSSHPrivateKey> {
Expand Down
Loading