Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Swift

How to sign and verify with ML-DSA-65 in Swift

To sign and verify with ML-DSA-65 in Swift, use the MLDSA65 types of CryptoKit's API, available on Linux through Apple's swift-crypto package: MLDSA65.PrivateKey(), signature(for:) and isValidSignature(_:for:), with an optional context. This recipe also unwraps an OpenSSL SPKI public key and verifies a signature OpenSSL made.

Tested with swift-crypto 5.0.3 (Swift 6.4.1 on Ubuntu 24.04, BoringSSL ML-DSA) and swift-asn1 1.7.3 (SubjectPublicKeyInfo parsing).

Package.swift — swift-crypto, and swift-asn1 for the SPKI
// swift-tools-version:6.2
import PackageDescription
let package = Package(
name: "howto",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/apple/swift-crypto.git", exact: "5.0.3"),
.package(url: "https://github.com/apple/swift-asn1.git", exact: "1.7.3"),
],
targets: [
.executableTarget(
name: "mldsa",
dependencies: [
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "SwiftASN1", package: "swift-asn1"),
]
),
]
)
Sign.swift — key pair, signature, verification, context string
import Crypto // CryptoKit's MLDSA65 API (iOS 26 / macOS 26), also on Linux via swift-crypto
import Foundation
func signAndVerify() throws {
let privateKey = try MLDSA65.PrivateKey()
let publicKey = privateKey.publicKey
let message = Data("hello, post-quantum world".utf8)
let signature = try privateKey.signature(for: message) // randomized (hedged) signing
print("public key \(publicKey.rawRepresentation.count) bytes, signature \(signature.count) bytes")
print("verified: \(publicKey.isValidSignature(signature, for: message))")
print("tampered message: \(publicKey.isValidSignature(signature, for: Data("hello, post-quantum world!".utf8)))")
let context = Data("invoice-v1".utf8)
let invoiceSignature = try privateKey.signature(for: message, context: context)
print("with the context: \(publicKey.isValidSignature(invoiceSignature, for: message, context: context)), without: \(publicKey.isValidSignature(invoiceSignature, for: message))")
}
SPKI.swift — an MLDSA65 public key from a BEGIN PUBLIC KEY file
import Crypto
import SwiftASN1
/// An ML-DSA-65 public key from an SPKI ("BEGIN PUBLIC KEY") PEM, e.g. written by OpenSSL 3.5.
/// CryptoKit takes the raw 1952-byte key, so unwrap the SubjectPublicKeyInfo first.
func publicKey(fromSPKIPEM pem: String) throws -> MLDSA65.PublicKey {
let document = try PEMDocument(pemString: pem)
guard document.discriminator == "PUBLIC KEY" else { throw ASN1Error.invalidPEMDocument(reason: "not a PUBLIC KEY") }
let root = try DER.parse(document.derBytes)
guard case .constructed(let nodes) = root.content else { throw ASN1Error.invalidASN1Object(reason: "SPKI is not a SEQUENCE") }
var children = nodes.makeIterator()
guard let algorithm = children.next(), let key = children.next(),
case .constructed(let algorithmNodes) = algorithm.content,
let oidNode = algorithmNodes.first(where: { _ in true }) else {
throw ASN1Error.invalidASN1Object(reason: "malformed SubjectPublicKeyInfo")
}
let oid = try ASN1ObjectIdentifier(derEncoded: oidNode)
guard oid == [2, 16, 840, 1, 101, 3, 4, 3, 18] else { // id-ml-dsa-65 (RFC 9881)
throw ASN1Error.invalidASN1Object(reason: "not an ML-DSA-65 key: \(oid)")
}
let bits = try ASN1BitString(derEncoded: key)
return try MLDSA65.PublicKey(rawRepresentation: bits.bytes)
}

How it works

  1. MLDSA65.PrivateKey() creates a key; publicKey.rawRepresentation is the 1952-byte public key and signature(for:) returns 3309 bytes.
  2. isValidSignature(_:for:) returns a Bool; both signing and verification have a variant with context:.
  3. CryptoKit imports only the raw public key, so publicKey(fromSPKIPEM:) parses the SubjectPublicKeyInfo with swift-asn1, checks the OID 2.16.840.1.101.3.4.3.18 (id-ml-dsa-65) and passes the BIT STRING to MLDSA65.PublicKey(rawRepresentation:).
  4. MLDSA65.PrivateKey(seedRepresentation:publicKey:) builds the key from the 32-byte seed and checks it against the given public key — the shared seed matches OpenSSL's public key in the run.

Gotchas

  • Signing is randomized: two signatures of the same message differ.
  • There is no PEM or SPKI initializer for MLDSA65.PublicKey in swift-crypto 5.0.3, only rawRepresentation:. The DER of a BEGIN PUBLIC KEY file is 1974 bytes — 22 bytes of SubjectPublicKeyInfo around the 1952-byte key — so it has to be unwrapped first.
  • A signature made with a context verifies only with the same context (false without it in the run).

Related recipes