Skip to content
ecdsa.com

ML-KEM-768 encapsulation · Swift

How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Swift

To encapsulate and decapsulate a shared secret with ML-KEM-768 in Swift, use the MLKEM768 types of CryptoKit's API, available on Linux through Apple's swift-crypto package: publicKey.encapsulate() returns the shared secret and the ciphertext, and privateKey.decapsulate(_:) recovers the secret. This recipe also unwraps OpenSSL's SPKI encapsulation key and decapsulates a ciphertext OpenSSL made.

Tested with swift-crypto 5.0.3 (Swift 6.4.1 on Ubuntu 24.04, BoringSSL ML-KEM) 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: "mlkem",
dependencies: [
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "SwiftASN1", package: "swift-asn1"),
]
),
]
)
Kem.swift — key pair, encapsulation, decapsulation
import Crypto // CryptoKit's MLKEM768 API, also on Linux via swift-crypto
import 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
}
SPKI.swift — an MLKEM768 public key from a BEGIN PUBLIC KEY file
import Crypto
import 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

  1. MLKEM768.PrivateKey() creates the key; publicKey.rawRepresentation (1184 bytes) is what the sender needs, and MLKEM768.PublicKey(rawRepresentation:) reads it back.
  2. encapsulate() returns a KEM.EncapsulationResult: sharedSecret is a SymmetricKey, encapsulated the 1088-byte ciphertext.
  3. decapsulate(_:) returns the same SymmetricKey; == on SymmetricKey compares in constant time (safeCompare in swift-crypto).
  4. 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 to dee35810…30f9.

Gotchas

  • There is no SPKI initializer for MLKEM768.PublicKey, only rawRepresentation:: the 1206-byte DER of OpenSSL's BEGIN PUBLIC KEY file has to be unwrapped, as encapsulationKey(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, not Data: use it directly with AES.GCM, ChaChaPoly or HKDF, and reach for withUnsafeBytes only when you must export it.

Related recipes