Skip to content
Open
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
2 changes: 1 addition & 1 deletion Benchmarks/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let package = Package(
dependencies: [
.package(path: "../"),
.package(url: "https://github.com/ordo-one/benchmark.git", from: "1.11.1"),
.package(url: "https://github.com/apple/swift-crypto.git", "3.12.3"..<"5.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was discussing using a package trait to avoid needing this, let us know what works.

.package(url: "https://github.com/apple/swift-asn1.git", from: "1.0.0"),
],
targets: [
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
// that have a direct dependency on swift-crypto accept beta releases as well.
if ProcessInfo.processInfo.environment["SWIFT_CERTIFICATES_ALLOW_SWIFT_CRYPTO_BETA"] == nil {
package.dependencies += [
.package(url: "https://github.com/apple/swift-crypto.git", "3.12.3"..<"5.0.0")
.package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0")
]
} else {
print("Accepting beta versions of swift-crypto!")
package.dependencies += [
.package(url: "https://github.com/apple/swift-crypto.git", "3.12.3"..<"5.0.0-beta.max")
.package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0-beta.max")
]
}

Expand Down
1 change: 1 addition & 0 deletions Sources/X509/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ add_library(X509
"GeneralName.swift"
"Lock.swift"
"LockedValueBox.swift"
"MLDSA.swift"
"OCSP/BasicOCSPResponse.swift"
"OCSP/DirectoryString.swift"
"OCSP/OCSPCertID.swift"
Expand Down
24 changes: 24 additions & 0 deletions Sources/X509/CertificatePublicKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ extension Certificate {
case .ed25519:
let key = try Curve25519.Signing.PublicKey(rawRepresentation: spki.key.bytes)
self.backing = .ed25519(key)
case .mldsa65:
self.backing = .mldsa(try MLDSAPublicKeyBytes(spkiBytes: spki.key.bytes, variant: .mldsa65))
case .mldsa87:
self.backing = .mldsa(try MLDSAPublicKeyBytes(spkiBytes: spki.key.bytes, variant: .mldsa87))
default:
throw CertificateError.unsupportedPublicKeyAlgorithm(reason: "\(spki.algorithmIdentifier)")
}
Expand Down Expand Up @@ -154,6 +158,8 @@ extension Certificate.PublicKey {
return rsa.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
case .ed25519(let ed25519):
return ed25519.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
case .mldsa(let mldsa):
return mldsa.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
}
}

Expand Down Expand Up @@ -185,6 +191,8 @@ extension Certificate.PublicKey {
return rsa.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
case .ed25519(let ed25519):
return ed25519.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
case .mldsa(let mldsa):
return mldsa.isValidSignature(signature, for: bytes, signatureAlgorithm: signatureAlgorithm)
}
}
}
Expand All @@ -209,6 +217,13 @@ extension Certificate.PublicKey: CustomStringConvertible {
return "RSA\(publicKey.keySizeInBits).PublicKey"
case .ed25519:
return "Ed25519.PublicKey"
case .mldsa(let backing):
switch backing.variant {
case .mldsa65:
return "MLDSA65.PublicKey"
case .mldsa87:
return "MLDSA87.PublicKey"
}
}
}
}
Expand All @@ -222,6 +237,7 @@ extension Certificate.PublicKey {
case p521(Crypto.P521.Signing.PublicKey)
case rsa(_CryptoExtras._RSA.Signing.PublicKey)
case ed25519(Curve25519.Signing.PublicKey)
case mldsa(MLDSAPublicKeyBytes)

@inlinable
static func == (lhs: BackingPublicKey, rhs: BackingPublicKey) -> Bool {
Expand All @@ -236,6 +252,8 @@ extension Certificate.PublicKey {
return l.derRepresentation == r.derRepresentation
case (.ed25519(let l), .ed25519(let r)):
return l.rawRepresentation == r.rawRepresentation
case (.mldsa(let l), .mldsa(let r)):
return l == r
default:
return false
}
Expand All @@ -259,6 +277,9 @@ extension Certificate.PublicKey {
case .ed25519(let digest):
hasher.combine(4)
hasher.combine(digest.rawRepresentation)
case .mldsa(let backing):
hasher.combine(5)
hasher.combine(backing)
}
}
}
Expand Down Expand Up @@ -287,6 +308,9 @@ extension SubjectPublicKeyInfo {
case .ed25519(let ed25519):
algorithmIdentifier = .ed25519
key = .init(bytes: ArraySlice(ed25519.rawRepresentation))
case .mldsa(let mldsa):
algorithmIdentifier = (mldsa.variant == .mldsa65) ? .mldsa65 : .mldsa87
key = .init(bytes: ArraySlice(mldsa.bytes))
}

self.algorithmIdentifier = algorithmIdentifier
Expand Down
187 changes: 187 additions & 0 deletions Sources/X509/MLDSA.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftCertificates open source project
//
// Copyright (c) 2026 Apple Inc. and the SwiftCertificates project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftCertificates project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import SwiftASN1
@preconcurrency import Crypto
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

/// The ML-DSA parameter sets this library can name.
///
/// This type deliberately stores no key material and names no swift-crypto ML-DSA type,
/// so it can be declared without an availability annotation: enum cases can't carry
/// `@available`, so keeping this payload free of macOS-26-only types is what lets
/// certificates *signed* with ML-DSA be parsed and re-serialized unconditionally.
@usableFromInline
enum MLDSAVariant: Hashable, Sendable {
case mldsa65
case mldsa87
}

/// The raw bytes of an ML-DSA public key, plus its parameter set.
///
/// Reconstructing a swift-crypto ML-DSA public key from its raw representation costs ~0.4%
/// of a verification (measured in the design doc), so the key is stored as validated bytes
/// and rebuilt on each use. The payload is plain data so the enum case that carries it can
/// be declared without an availability annotation (macOS-26-only types can't appear in an
/// unannotated enum payload); only the *initializers* need swift-crypto's ML-DSA API.
@usableFromInline
struct MLDSAPublicKeyBytes: Hashable, Sendable {
@usableFromInline
var variant: MLDSAVariant

@usableFromInline
var bytes: Data

/// Validates and stores SPKI subjectPublicKey bytes for the given parameter set.
///
/// Throws ``CertificateError/unsupportedPublicKeyAlgorithm(reason:)`` at runtime on
/// Darwin platforms older than macOS 26 (where CryptoKit has no ML-DSA).
@usableFromInline
init(spkiBytes: ArraySlice<UInt8>, variant: MLDSAVariant) throws {
guard #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
else {
throw CertificateError.unsupportedPublicKeyAlgorithm(
reason: "ML-DSA requires macOS 26, iOS 26, watchOS 26, tvOS 26, or visionOS 26"
)
}
// Let swift-crypto validate the encoding; we store the validated raw bytes.
switch variant {
case .mldsa65:
self.bytes = try MLDSA65.PublicKey(rawRepresentation: spkiBytes).rawRepresentation
case .mldsa87:
self.bytes = try MLDSA87.PublicKey(rawRepresentation: spkiBytes).rawRepresentation
}
self.variant = variant
}
}

@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, macCatalyst 13, visionOS 1.0, *)
extension MLDSAPublicKeyBytes {
@usableFromInline
func isValidSignature<Bytes: DataProtocol>(
_ signature: Certificate.Signature,
for bytes: Bytes,
signatureAlgorithm: Certificate.SignatureAlgorithm
) -> Bool {
guard case .mldsa(let signatureVariant, let rawSignature) = signature.backing,
signatureVariant == self.variant
else {
// Signature mismatch, including an ML-DSA signature under the other parameter set.
return false
}
return self.isValidSignature(rawSignature, for: bytes, signatureAlgorithm: signatureAlgorithm)
}

@usableFromInline
func isValidSignature<SignatureBytes: DataProtocol, Bytes: DataProtocol>(
_ signature: SignatureBytes,
for bytes: Bytes,
signatureAlgorithm: Certificate.SignatureAlgorithm
) -> Bool {
switch (self.variant, signatureAlgorithm) {
case (.mldsa65, .mldsa65), (.mldsa87, .mldsa87):
break
default:
return false
}
guard #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
else {
return false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this code branch actually reachable? It seems as though it should not be possible to create a key on these platforms such that we'd be unable to get here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're correct, it's not reachable. But the compiler required it in the way the PR is made now. I'd be happy to change it up, but this seemed like the best way.

}
switch self.variant {
case .mldsa65:
guard let key = try? MLDSA65.PublicKey(rawRepresentation: self.bytes) else {
return false
}
return key.isValidSignature(signature, for: bytes)
case .mldsa87:
guard let key = try? MLDSA87.PublicKey(rawRepresentation: self.bytes) else {
return false
}
return key.isValidSignature(signature, for: bytes)
}
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
extension MLDSAPublicKeyBytes {
@usableFromInline
init(_ mldsa65: MLDSA65.PublicKey) {
self.variant = .mldsa65
self.bytes = mldsa65.rawRepresentation
}

@usableFromInline
init(_ mldsa87: MLDSA87.PublicKey) {
self.variant = .mldsa87
self.bytes = mldsa87.rawRepresentation
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
extension Certificate.PublicKey {
/// Construct a public key wrapping an ML-DSA-65 public key.
/// - Parameter mldsa65: The ML-DSA-65 public key to wrap.
@inlinable
public init(_ mldsa65: MLDSA65.PublicKey) {
self.init(backing: .mldsa(MLDSAPublicKeyBytes(mldsa65)))
}

/// Construct a public key wrapping an ML-DSA-87 public key.
/// - Parameter mldsa87: The ML-DSA-87 public key to wrap.
@inlinable
public init(_ mldsa87: MLDSA87.PublicKey) {
self.init(backing: .mldsa(MLDSAPublicKeyBytes(mldsa87)))
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
extension MLDSA65.PublicKey {
/// Create an ML-DSA-65 public key from a given ``Certificate/PublicKey-swift.struct``.
///
/// Fails if the key is not an ML-DSA-65 key.
///
/// - Parameters:
/// - key: The key to unwrap.
public init?(_ key: Certificate.PublicKey) {
guard case .mldsa(let backing) = key.backing, backing.variant == .mldsa65,
let key = try? MLDSA65.PublicKey(rawRepresentation: backing.bytes)
else {
return nil
}
self = key
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, macCatalyst 26.0, visionOS 26.0, *)
extension MLDSA87.PublicKey {
/// Create an ML-DSA-87 public key from a given ``Certificate/PublicKey-swift.struct``.
///
/// Fails if the key is not an ML-DSA-87 key.
///
/// - Parameters:
/// - key: The key to unwrap.
public init?(_ key: Certificate.PublicKey) {
guard case .mldsa(let backing) = key.backing, backing.variant == .mldsa87,
let key = try? MLDSA87.PublicKey(rawRepresentation: backing.bytes)
else {
return nil
}
self = key
}
}
23 changes: 23 additions & 0 deletions Sources/X509/Signature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ extension Certificate {
}
let signature = Data(signatureBytes.bytes)
self.backing = .ed25519(signature)
case .mldsa65, .mldsa87:
guard signatureBytes.paddingBits == 0 else {
throw CertificateError.invalidSignatureForCertificate(
reason: "No padding bits are allowed on ML-DSA signatures"
)
}
let variant: MLDSAVariant = (signatureAlgorithm == .mldsa65) ? .mldsa65 : .mldsa87
self.backing = .mldsa(variant, Data(signatureBytes.bytes))
default:
throw CertificateError.unsupportedSignatureAlgorithm(reason: "\(signatureAlgorithm)")
}
Expand All @@ -86,6 +94,10 @@ extension Certificate.Signature: CustomStringConvertible {
return "RSA"
case .ed25519:
return "Ed25519"
case .mldsa(.mldsa65, _):
return "MLDSA65"
case .mldsa(.mldsa87, _):
return "MLDSA87"
}
}
}
Expand All @@ -97,6 +109,7 @@ extension Certificate.Signature {
case ecdsa(ECDSASignature)
case rsa(_CryptoExtras._RSA.Signing.RSASignature)
case ed25519(Data)
case mldsa(MLDSAVariant, Data)

@inlinable
static func == (lhs: BackingSignature, rhs: BackingSignature) -> Bool {
Expand All @@ -107,6 +120,8 @@ extension Certificate.Signature {
return l.rawRepresentation == r.rawRepresentation
case (.ed25519(let l), .ed25519(let r)):
return l == r
case (.mldsa(let lv, let l), .mldsa(let rv, let r)):
return lv == rv && l == r
default:
return false
}
Expand All @@ -124,6 +139,10 @@ extension Certificate.Signature {
case .ed25519(let sig):
hasher.combine(2)
hasher.combine(sig)
case .mldsa(let variant, let sig):
hasher.combine(3)
hasher.combine(variant)
hasher.combine(sig)
}
}
}
Expand All @@ -143,6 +162,8 @@ extension Certificate.Signature {
return .init(data)
case let .rsa(signature):
return .init(signature.rawRepresentation)
case .mldsa(_, let sig):
return .init(sig)
}
}
}
Expand All @@ -168,6 +189,8 @@ extension ASN1OctetString {
self = ASN1OctetString(contentBytes: ArraySlice(sig.rawRepresentation))
case .ed25519(let sig):
self = ASN1OctetString(contentBytes: ArraySlice(sig))
case .mldsa(_, let sig):
self = ASN1OctetString(contentBytes: ArraySlice(sig))
}
}
}
Expand Down
Loading