// 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: "mlkem", dependencies: [ .product(name: "Crypto", package: "swift-crypto"), .product(name: "SwiftASN1", package: "swift-asn1"), ] ), ]) import Crypto // CryptoKit's MLKEM768 API, also on Linux via swift-cryptoimport Foundation func agree() throws { // Receiver: a key pair; only publicKey.rawRepresentation is handed out let privateKey = try MLKEM768.PrivateKey() let encapsulationKey = try MLKEM768.PublicKey(rawRepresentation: privateKey.publicKey.rawRepresentation) // Sender: a fresh shared secret and the ciphertext ("encapsulated") that carries it let result = try encapsulationKey.encapsulate() // Receiver: the same secret from the ciphertext let received = try privateKey.decapsulate(result.encapsulated) print("encapsulation key \(encapsulationKey.rawRepresentation.count) bytes, ciphertext \(result.encapsulated.count) bytes") print("shared secret agreed: \(received == result.sharedSecret)") // SymmetricKey == compares in constant time} import Cryptoimport SwiftASN1 /// An ML-KEM-768 encapsulation key from an SPKI ("BEGIN PUBLIC KEY") PEM, e.g. written by OpenSSL 3.5./// CryptoKit takes the raw 1184-byte key, so unwrap the SubjectPublicKeyInfo first.func encapsulationKey(fromSPKIPEM pem: String) throws -> MLKEM768.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, 4, 2] else { // id-alg-ml-kem-768 (RFC 9935) throw ASN1Error.invalidASN1Object(reason: "not an ML-KEM-768 key: \(oid)") } let bits = try ASN1BitString(derEncoded: key) return try MLKEM768.PublicKey(rawRepresentation: bits.bytes)} How it works
MLKEM768.PrivateKey()creates the key;publicKey.rawRepresentation(1184 bytes) is what the sender needs, andMLKEM768.PublicKey(rawRepresentation:)reads it back.encapsulate()returns aKEM.EncapsulationResult:sharedSecretis aSymmetricKey,encapsulatedthe 1088-byte ciphertext.decapsulate(_:)returns the sameSymmetricKey;==onSymmetricKeycompares in constant time (safeComparein swift-crypto).MLKEM768.PrivateKey(seedRepresentation:publicKey:)builds the key from the 64-byte seed and checks it against OpenSSL's encapsulation key; it then decapsulates OpenSSL's ciphertext todee35810…30f9.
Gotchas
- There is no SPKI initializer for
MLKEM768.PublicKey, onlyrawRepresentation:: the 1206-byte DER of OpenSSL'sBEGIN PUBLIC KEYfile has to be unwrapped, asencapsulationKey(fromSPKIPEM:)does. - A tampered ciphertext decapsulates without an error to a different secret (implicit rejection); a 1000-byte ciphertext throws
incorrectParameterSize. - The secret is a
SymmetricKey, notData: use it directly withAES.GCM,ChaChaPolyorHKDF, and reach forwithUnsafeBytesonly when you must export it.
Related recipes
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Kotlin
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Rust
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 with OpenSSL
- How to sign and verify with ML-DSA-65 in Swift
- How to derive a shared key with ECDH and HKDF in Swift