From 5940d3e8069138fb43700fbbc84d3745c4c378fa Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 4 Jun 2025 11:04:11 +0200 Subject: [PATCH 1/4] Add ML-DSA-87 --- .../_CryptoExtras/MLDSA/MLDSA_boring.swift | 306 ++++++ .../MLDSA/MLDSA_boring.swift.gyb | 2 +- .../{MLDSA65Tests.swift => MLDSATests.swift} | 124 ++- ...fy_test.json => mldsa_65_verify_test.json} | 4 +- .../mldsa_87_verify_test.json | 947 ++++++++++++++++++ ...s.json => mldsa_nist_keygen_65_tests.json} | 0 .../mldsa_nist_keygen_87_tests.json | 129 +++ 7 files changed, 1503 insertions(+), 9 deletions(-) rename Tests/_CryptoExtrasTests/{MLDSA65Tests.swift => MLDSATests.swift} (59%) rename Tests/_CryptoExtrasVectors/{mldsa_65_standard_verify_test.json => mldsa_65_verify_test.json} (99%) create mode 100644 Tests/_CryptoExtrasVectors/mldsa_87_verify_test.json rename Tests/_CryptoExtrasVectors/{mldsa_65_nist_keygen_tests.json => mldsa_nist_keygen_65_tests.json} (100%) create mode 100644 Tests/_CryptoExtrasVectors/mldsa_nist_keygen_87_tests.json diff --git a/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift b/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift index 975243bbf..51941f563 100644 --- a/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift +++ b/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift @@ -326,6 +326,312 @@ extension MLDSA65 { private static let signatureByteCount = Int(MLDSA65_SIGNATURE_BYTES) } +/// A module-lattice-based digital signature algorithm that provides security against quantum computing attacks. +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, visionOS 1.0, *) +public enum MLDSA87 {} + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, visionOS 1.0, *) +extension MLDSA87 { + /// A ML-DSA-87 private key. + public struct PrivateKey: Sendable { + private var backing: Backing + + /// Initialize a ML-DSA-87 private key from a random seed. + public init() throws { + self.backing = try Backing() + } + + /// Initialize a ML-DSA-87 private key from a seed. + /// + /// - Parameter seedRepresentation: The seed to use to generate the private key. + /// + /// - Throws: `CryptoKitError.incorrectKeySize` if the seed is not 32 bytes long. + public init(seedRepresentation: some DataProtocol) throws { + self.backing = try Backing(seedRepresentation: seedRepresentation) + } + + /// The seed from which this private key was generated. + public var seedRepresentation: Data { + self.backing.seed + } + + /// The public key associated with this private key. + public var publicKey: PublicKey { + self.backing.publicKey + } + + /// Generate a signature for the given data. + /// + /// - Parameter data: The message to sign. + /// + /// - Returns: The signature of the message. + public func signature(for data: D) throws -> Data { + let context: Data? = nil + return try self.backing.signature(for: data, context: context) + } + + /// Generate a signature for the given data. + /// + /// - Parameters: + /// - data: The message to sign. + /// - context: The context to use for the signature. + /// + /// - Returns: The signature of the message. + public func signature(for data: D, context: C) throws -> Data { + try self.backing.signature(for: data, context: context) + } + + /// The size of the private key in bytes. + static let byteCount = Backing.byteCount + + fileprivate final class Backing { + fileprivate var key: MLDSA87_private_key + var seed: Data + + /// Initialize a ML-DSA-87 private key from a random seed. + init() throws { + // We have to initialize all members before `self` is captured by the closure + self.key = .init() + self.seed = Data() + + self.seed = try withUnsafeTemporaryAllocation( + of: UInt8.self, + capacity: MLDSA.seedByteCount + ) { seedPtr in + try withUnsafeTemporaryAllocation( + of: UInt8.self, + capacity: MLDSA87.PublicKey.Backing.byteCount + ) { publicKeyPtr in + guard + CCryptoBoringSSL_MLDSA87_generate_key( + publicKeyPtr.baseAddress, + seedPtr.baseAddress, + &self.key + ) == 1 + else { + throw CryptoKitError.internalBoringSSLError() + } + + return Data(bytes: seedPtr.baseAddress!, count: MLDSA.seedByteCount) + } + } + } + + /// Initialize a ML-DSA-87 private key from a seed. + /// + /// - Parameter seedRepresentation: The seed to use to generate the private key. + /// + /// - Throws: `CryptoKitError.incorrectKeySize` if the seed is not 32 bytes long. + init(seedRepresentation: some DataProtocol) throws { + guard seedRepresentation.count == MLDSA.seedByteCount else { + throw CryptoKitError.incorrectKeySize + } + + self.key = .init() + self.seed = Data(seedRepresentation) + + guard + self.seed.withUnsafeBytes({ seedPtr in + CCryptoBoringSSL_MLDSA87_private_key_from_seed( + &self.key, + seedPtr.baseAddress, + MLDSA.seedByteCount + ) + }) == 1 + else { + throw CryptoKitError.internalBoringSSLError() + } + } + + /// The public key associated with this private key. + var publicKey: PublicKey { + PublicKey(privateKeyBacking: self) + } + + /// Generate a signature for the given data. + /// + /// - Parameters: + /// - data: The message to sign. + /// - context: The context to use for the signature. + /// + /// - Returns: The signature of the message. + func signature(for data: D, context: C?) throws -> Data { + var signature = Data(repeating: 0, count: MLDSA87.signatureByteCount) + + let rc: CInt = signature.withUnsafeMutableBytes { signaturePtr in + let bytes: ContiguousBytes = data.regions.count == 1 ? data.regions.first! : Array(data) + return bytes.withUnsafeBytes { dataPtr in + context.withUnsafeBytes { contextPtr in + CCryptoBoringSSL_MLDSA87_sign( + signaturePtr.baseAddress, + &self.key, + dataPtr.baseAddress, + dataPtr.count, + contextPtr.baseAddress, + contextPtr.count + ) + } + } + } + + guard rc == 1 else { + throw CryptoKitError.internalBoringSSLError() + } + + return signature + } + + /// The size of the private key in bytes. + static let byteCount = Int(MLDSA87_PRIVATE_KEY_BYTES) + } + } +} + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, visionOS 1.0, *) +extension MLDSA87 { + /// A ML-DSA-87 public key. + public struct PublicKey: Sendable { + private var backing: Backing + + fileprivate init(privateKeyBacking: PrivateKey.Backing) { + self.backing = Backing(privateKeyBacking: privateKeyBacking) + } + + /// Initialize a ML-DSA-87 public key from a raw representation. + /// + /// - Parameter rawRepresentation: The public key bytes. + /// + /// - Throws: `CryptoKitError.incorrectKeySize` if the raw representation is not the correct size. + public init(rawRepresentation: some DataProtocol) throws { + self.backing = try Backing(rawRepresentation: rawRepresentation) + } + + /// The raw binary representation of the public key. + public var rawRepresentation: Data { + self.backing.rawRepresentation + } + + /// Verify a signature for the given data. + /// + /// - Parameters: + /// - signature: The signature to verify. + /// - data: The message to verify the signature against. + /// + /// - Returns: `true` if the signature is valid, `false` otherwise. + public func isValidSignature(_ signature: S, for data: D) -> Bool { + let context: Data? = nil + return self.backing.isValidSignature(signature, for: data, context: context) + } + + /// Verify a signature for the given data. + /// + /// - Parameters: + /// - signature: The signature to verify. + /// - data: The message to verify the signature against. + /// - context: The context to use for the signature verification. + /// + /// - Returns: `true` if the signature is valid, `false` otherwise. + public func isValidSignature( + _ signature: S, + for data: D, + context: C + ) -> Bool { + self.backing.isValidSignature(signature, for: data, context: context) + } + + /// The size of the public key in bytes. + static let byteCount = Backing.byteCount + + fileprivate final class Backing { + private var key: MLDSA87_public_key + + init(privateKeyBacking: PrivateKey.Backing) { + self.key = .init() + CCryptoBoringSSL_MLDSA87_public_from_private(&self.key, &privateKeyBacking.key) + } + + /// Initialize a ML-DSA-87 public key from a raw representation. + /// + /// - Parameter rawRepresentation: The public key bytes. + /// + /// - Throws: `CryptoKitError.incorrectKeySize` if the raw representation is not the correct size. + init(rawRepresentation: some DataProtocol) throws { + guard rawRepresentation.count == MLDSA87.PublicKey.Backing.byteCount else { + throw CryptoKitError.incorrectKeySize + } + + self.key = .init() + + let bytes: ContiguousBytes = + rawRepresentation.regions.count == 1 + ? rawRepresentation.regions.first! + : Array(rawRepresentation) + try bytes.withUnsafeBytes { rawBuffer in + try rawBuffer.withMemoryRebound(to: UInt8.self) { buffer in + var cbs = CBS(data: buffer.baseAddress, len: buffer.count) + guard CCryptoBoringSSL_MLDSA87_parse_public_key(&self.key, &cbs) == 1 else { + throw CryptoKitError.internalBoringSSLError() + } + } + } + } + + /// The raw binary representation of the public key. + var rawRepresentation: Data { + var cbb = CBB() + // The following BoringSSL functions can only fail on allocation failure, which we define as impossible. + CCryptoBoringSSL_CBB_init(&cbb, MLDSA87.PublicKey.Backing.byteCount) + defer { CCryptoBoringSSL_CBB_cleanup(&cbb) } + CCryptoBoringSSL_MLDSA87_marshal_public_key(&cbb, &self.key) + return Data(bytes: CCryptoBoringSSL_CBB_data(&cbb), count: CCryptoBoringSSL_CBB_len(&cbb)) + } + + /// Verify a signature for the given data. + /// + /// - Parameters: + /// - signature: The signature to verify. + /// - data: The message to verify the signature against. + /// - context: The context to use for the signature verification. + /// + /// - Returns: `true` if the signature is valid, `false` otherwise. + func isValidSignature( + _ signature: S, + for data: D, + context: C? + ) -> Bool { + let signatureBytes: ContiguousBytes = + signature.regions.count == 1 ? signature.regions.first! : Array(signature) + return signatureBytes.withUnsafeBytes { signaturePtr in + let dataBytes: ContiguousBytes = data.regions.count == 1 ? data.regions.first! : Array(data) + let rc: CInt = dataBytes.withUnsafeBytes { dataPtr in + context.withUnsafeBytes { contextPtr in + CCryptoBoringSSL_MLDSA87_verify( + &self.key, + signaturePtr.baseAddress, + signaturePtr.count, + dataPtr.baseAddress, + dataPtr.count, + contextPtr.baseAddress, + contextPtr.count + ) + } + } + return rc == 1 + } + } + + /// The size of the public key in bytes. + static let byteCount = Int(MLDSA87_PUBLIC_KEY_BYTES) + } + } +} + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, visionOS 1.0, *) +extension MLDSA87 { + /// The size of the signature in bytes. + private static let signatureByteCount = Int(MLDSA87_SIGNATURE_BYTES) +} + private enum MLDSA { /// The size of the seed in bytes. fileprivate static let seedByteCount = 32 diff --git a/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift.gyb b/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift.gyb index 734dfffcf..91a86c18d 100644 --- a/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift.gyb +++ b/Sources/_CryptoExtras/MLDSA/MLDSA_boring.swift.gyb @@ -20,7 +20,7 @@ import Crypto import Foundation %{ - parameter_sets = ["65"] + parameter_sets = ["65", "87"] }% % for parameter_set in parameter_sets: diff --git a/Tests/_CryptoExtrasTests/MLDSA65Tests.swift b/Tests/_CryptoExtrasTests/MLDSATests.swift similarity index 59% rename from Tests/_CryptoExtrasTests/MLDSA65Tests.swift rename to Tests/_CryptoExtrasTests/MLDSATests.swift index a18a97ab0..83187f181 100644 --- a/Tests/_CryptoExtrasTests/MLDSA65Tests.swift +++ b/Tests/_CryptoExtrasTests/MLDSATests.swift @@ -16,7 +16,7 @@ import XCTest @testable import _CryptoExtras -final class MLDSA65Tests: XCTestCase { +final class MLDSATests: XCTestCase { func testMLDSA65Signing() throws { try testMLDSA65Signing(MLDSA65.PrivateKey()) let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } @@ -42,7 +42,32 @@ final class MLDSA65Tests: XCTestCase { ) } - func testSeedRoundTripping() throws { + func testMLDSA87Signing() throws { + try testMLDSA87Signing(MLDSA87.PrivateKey()) + let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } + try testMLDSA87Signing(MLDSA87.PrivateKey(seedRepresentation: seed)) + } + + private func testMLDSA87Signing(_ key: MLDSA87.PrivateKey) throws { + let test = "Hello, world!".data(using: .utf8)! + try XCTAssertTrue( + key.publicKey.isValidSignature( + key.signature(for: test), + for: test + ) + ) + + let context = "ctx".data(using: .utf8)! + try XCTAssertTrue( + key.publicKey.isValidSignature( + key.signature(for: test, context: context), + for: test, + context: context + ) + ) + } + + func testMLDSA65SeedRoundTripping() throws { let key = try MLDSA65.PrivateKey() let seed = key.seedRepresentation let roundTripped = try MLDSA65.PrivateKey(seedRepresentation: seed) @@ -50,7 +75,15 @@ final class MLDSA65Tests: XCTestCase { XCTAssertEqual(key.publicKey.rawRepresentation, roundTripped.publicKey.rawRepresentation) } - func testSignatureIsRandomized() throws { + func testMLDSA87SeedRoundTripping() throws { + let key = try MLDSA87.PrivateKey() + let seed = key.seedRepresentation + let roundTripped = try MLDSA87.PrivateKey(seedRepresentation: seed) + XCTAssertEqual(seed, roundTripped.seedRepresentation) + XCTAssertEqual(key.publicKey.rawRepresentation, roundTripped.publicKey.rawRepresentation) + } + + func testMLDSA65SignatureIsRandomized() throws { let message = "Hello, world!".data(using: .utf8)! let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } @@ -67,7 +100,24 @@ final class MLDSA65Tests: XCTestCase { XCTAssertTrue(publicKey.isValidSignature(signature2, for: message)) } - func testInvalidPublicKeyEncodingLength() throws { + func testMLDSA87SignatureIsRandomized() throws { + let message = "Hello, world!".data(using: .utf8)! + + let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } + let key = try MLDSA87.PrivateKey(seedRepresentation: seed) + let publicKey = key.publicKey + + let signature1 = try key.signature(for: message) + let signature2 = try key.signature(for: message) + + XCTAssertNotEqual(signature1, signature2) + + // Even though the signatures are different, they both verify. + XCTAssertTrue(publicKey.isValidSignature(signature1, for: message)) + XCTAssertTrue(publicKey.isValidSignature(signature2, for: message)) + } + + func testInvalidMLDSA65PublicKeyEncodingLength() throws { // Encode a public key with a trailing 0 at the end. var encodedPublicKey = [UInt8](repeating: 0, count: MLDSA65.PublicKey.byteCount + 1) let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } @@ -87,8 +137,28 @@ final class MLDSA65Tests: XCTestCase { XCTAssertThrowsError(try MLDSA65.PublicKey(rawRepresentation: encodedPublicKey)) } + func testInvalidMLDSA87PublicKeyEncodingLength() throws { + // Encode a public key with a trailing 0 at the end. + var encodedPublicKey = [UInt8](repeating: 0, count: MLDSA87.PublicKey.byteCount + 1) + let seed: [UInt8] = (0..<32).map { _ in UInt8.random(in: 0...255) } + let key = try MLDSA87.PrivateKey(seedRepresentation: seed) + let publicKey = key.publicKey + encodedPublicKey.replaceSubrange(0.. Date: Wed, 4 Jun 2025 11:06:23 +0200 Subject: [PATCH 2/4] Update DocC --- Sources/_CryptoExtras/Docs.docc/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/_CryptoExtras/Docs.docc/index.md b/Sources/_CryptoExtras/Docs.docc/index.md index 6e4424b90..fc5d2f696 100644 --- a/Sources/_CryptoExtras/Docs.docc/index.md +++ b/Sources/_CryptoExtras/Docs.docc/index.md @@ -16,6 +16,7 @@ Provides additional cryptographic APIs that are not available in CryptoKit (and - ``_RSA`` - ``MLDSA65`` +- ``MLDSA87`` ### Key derivation functions From 288b9959db7b29075b634658a676f264cc84dba6 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 4 Jun 2025 12:12:44 +0200 Subject: [PATCH 3/4] Add ML-KEM to DocC --- Sources/_CryptoExtras/Docs.docc/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/_CryptoExtras/Docs.docc/index.md b/Sources/_CryptoExtras/Docs.docc/index.md index fc5d2f696..b38a23dd7 100644 --- a/Sources/_CryptoExtras/Docs.docc/index.md +++ b/Sources/_CryptoExtras/Docs.docc/index.md @@ -15,6 +15,8 @@ Provides additional cryptographic APIs that are not available in CryptoKit (and ### Public key cryptography - ``_RSA`` +- ``MLKEM768`` +- ``MLKEM1024`` - ``MLDSA65`` - ``MLDSA87`` From a1d823a45df90dbed77b9f967491dd383d205ada Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino Date: Wed, 4 Jun 2025 12:49:37 +0200 Subject: [PATCH 4/4] Simplify ML-DSA NIST test --- Tests/_CryptoExtrasTests/MLDSATests.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Tests/_CryptoExtrasTests/MLDSATests.swift b/Tests/_CryptoExtrasTests/MLDSATests.swift index 83187f181..e19582dc5 100644 --- a/Tests/_CryptoExtrasTests/MLDSATests.swift +++ b/Tests/_CryptoExtrasTests/MLDSATests.swift @@ -197,18 +197,8 @@ final class MLDSATests: XCTestCase { for _ in 0..<2 { fileURL.deleteLastPathComponent() } - #if compiler(>=6.0) - if #available(macOS 13, iOS 16, watchOS 9, tvOS 16, visionOS 1, macCatalyst 16, *) { - fileURL.append(path: "_CryptoExtrasVectors", directoryHint: .isDirectory) - fileURL.append(path: "\(jsonName).json", directoryHint: .notDirectory) - } else { - fileURL = fileURL.appendingPathComponent("_CryptoExtrasVectors", isDirectory: true) - fileURL = fileURL.appendingPathComponent("\(jsonName).json", isDirectory: false) - } - #else fileURL = fileURL.appendingPathComponent("_CryptoExtrasVectors", isDirectory: true) fileURL = fileURL.appendingPathComponent("\(jsonName).json", isDirectory: false) - #endif let data = try Data(contentsOf: fileURL)