// swift-tools-version:6.2import 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"), ] ), ]) import Crypto // CryptoKit's MLDSA65 API (iOS 26 / macOS 26), also on Linux via swift-cryptoimport 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))")} import Cryptoimport 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
MLDSA65.PrivateKey()creates a key;publicKey.rawRepresentationis the 1952-byte public key andsignature(for:)returns 3309 bytes.isValidSignature(_:for:)returns aBool; both signing and verification have a variant withcontext:.- CryptoKit imports only the raw public key, so
publicKey(fromSPKIPEM:)parses the SubjectPublicKeyInfo with swift-asn1, checks the OID2.16.840.1.101.3.4.3.18(id-ml-dsa-65) and passes the BIT STRING toMLDSA65.PublicKey(rawRepresentation:). 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.PublicKeyin swift-crypto 5.0.3, onlyrawRepresentation:. The DER of aBEGIN PUBLIC KEYfile 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 (
falsewithout it in the run).