Last updated: 2026-06-16
These are the reusable recipes that every project in PROJECT_CATALOG.md is composed from. Learn the ten blocks once; then a "project" is just a named combination of blocks for a domain, with that domain's caveats.
How to read the code. Lines that call pqcrypto use the real, current
API and are correct as written. Lines marked // you supply are illustrative
placeholders for code you bring from your own crypto/storage stack — pqcrypto
does not provide them (see the gives-vs-supply table in
README.md).
Treat every snippet as a sketch to adapt, not a drop-in library.
All snippets assume:
import 'dart:convert';
import 'dart:typed_data';
import 'package:pqcrypto/pqcrypto.dart';Block index:
- BB1 Detached signatures
- BB2 Encrypt to a public key
- BB3 Hybrid authenticated handshake
- BB4 Identity enrollment and key directory
- BB5 Deterministic keys from a seed
- BB6 Tamper-evident signed log
- BB7 Signed software and firmware updates
- BB8 Encrypted data at rest
- BB9 Hybrid and dual signatures
- BB10 Offloading heavy work
What it does. Proves a message came from the holder of a secret key and was not modified. The output is a detached signature you store or send alongside the message.
Use it for. Signed tokens, signed webhooks, document/contract e-signing, signed records, signed releases — anywhere you need integrity and non-repudiation.
final params = DilithiumParams.mlDsa65;
// A domain-separation context. Use a distinct, versioned string PER USE so a
// signature minted for one purpose can never be replayed as another.
final ctx = Uint8List.fromList(utf8.encode('myapp/invoice-signature/v1'));
// Key generation (do this once; persist the keys securely — see BB5/BB8).
final (pk, sk) = MlDsa.generateKeyPair(params); // hedged, uses Random.secure()
// Sign. Signing is hedged by default (recommended). For a small message:
final message = Uint8List.fromList(utf8.encode('canonical invoice bytes'));
final sig = MlDsa.sign(sk, message, params, ctx: ctx);
// Verify. Returns false (never throws) on any malformed/forged input.
final ok = MlDsa.verify(pk, message, sig, params, ctx: ctx);For large payloads (files, multi-MB documents) use the pre-hash variant so you are not buffering the whole message through the lattice signer:
final sig = MlDsa.hashSign(sk, bigPayload, params, ctx: ctx);
final ok = MlDsa.hashVerify(pk, bigPayload, sig, params, ctx: ctx);You supply. A canonical byte encoding of the message (both signer and verifier must serialize it identically — see BB6's framing helper), and a way to distribute and trust the public key (see BB4).
Caveats.
- A signature is only as meaningful as your trust in the public key. A self-provided key proves nothing about identity (BB4).
signDeterministicexists but is discouraged: deterministic ML-DSA is harder to protect against fault/side-channel attacks. Prefer the hedged default.- The context string is at most 255 bytes.
What it does. Lets anyone encrypt data to a recipient's public key such that only the holder of the matching secret key can read it. This is the recipe people think "ML-KEM" means — but ML-KEM only gives you a shared secret; you must combine it with a KDF and an AEAD (the "DEM") to actually encrypt data. This pattern is KEM-DEM hybrid public-key encryption.
final kem = PqcKem.kyber768;
// --- Sender: has the recipient's ML-KEM public key ---
(Uint8List ct, Uint8List sealed) encryptTo(
Uint8List recipientPk,
Uint8List plaintext,
) {
final (ct, ss) = kem.encapsulate(recipientPk); // pqcrypto: ct + 32-byte secret
// Derive a fresh data key from the shared secret. NEVER use `ss` directly as
// an AEAD key — always run it through a KDF with a domain/label.
final key = hkdf(ss, salt: ct, info: 'myapp/kem-dem/v1', length: 32); // you supply
final nonce = randomBytes(12); // you supply
final box = aeadSeal(key, nonce, plaintext, aad: ct); // you supply
return (ct, concatBytes([nonce, box])); // send both ct and sealed payload
}
// --- Recipient: holds the ML-KEM secret key ---
Uint8List decrypt(Uint8List sk, Uint8List ct, Uint8List sealed) {
final ss = kem.decapsulate(sk, ct); // pqcrypto: recovers the same 32-byte secret
final key = hkdf(ss, salt: ct, info: 'myapp/kem-dem/v1', length: 32); // you supply
final nonce = sealed.sublist(0, 12);
final box = sealed.sublist(12);
return aeadOpen(key, nonce, box, aad: ct); // you supply
}You supply. HKDF (or another KDF), an AEAD (AES-GCM or ChaCha20-Poly1305),
and a CSPRNG for the nonce. All are in standard Dart crypto packages; none are in
pqcrypto.
Caveats.
- This gives confidentiality to the key holder, not sender authentication. Anyone can encrypt to a public key. If you need to know who sent it, also sign the ciphertext with BB1, or use the authenticated handshake (BB3).
- Bind
ctinto the KDF and/or the AEAD's associated data (as above) so the ciphertext cannot be transplanted onto a different encapsulation.
What it does. Establishes a mutually understood session key that is secure if either the classical algorithm (X25519) or the lattice algorithm (ML-KEM) holds — the conservative "hybrid" posture recommended during the PQC transition — and authenticates the exchange with ML-DSA so it is not a man-in-the-middle's session.
This is the full pattern documented end-to-end (with Serverpod models, endpoint guards, replay windows, and a Flutter client) in ../SERVERPOD_FLUTTER_GUIDE.md. The essence:
final kem = PqcKem.kyber768;
final dsa = DilithiumParams.mlDsa65;
final ctx = Uint8List.fromList(utf8.encode('myapp/handshake/v1'));
// Client encapsulates to the server's authenticated ML-KEM public key,
// and also runs an app-supplied X25519 exchange.
final (ct, ssLattice) = kem.encapsulate(serverKemPk); // pqcrypto
final ssClassical = x25519(clientEphSk, serverX25519Pk); // you supply
// Bind everything that matters into one canonical transcript, then sign it.
final transcript = lengthPrefixed([
utf8Bytes('myapp/handshake/v1'),
utf8Bytes('ML-KEM-768'), utf8Bytes('ML-DSA-65'),
serverKemPk, clientEphX25519Pk, ct, clientNonce, uint64(timestampMs),
]); // framing helper is in BB6
final sig = MlDsa.sign(clientIdentitySk, transcript, dsa, ctx: ctx); // pqcrypto
// Both sides derive the same session key from BOTH secrets.
final sessionKey = hkdf(
concatBytes([ssClassical, ssLattice]), // ikm = classical || lattice
salt: hash(transcript), info: 'myapp/session/v1', length: 32,
); // you supply (HKDF + hash)You supply. X25519 (or another classical KEX), HKDF, a transcript hash, an AEAD for traffic after the handshake, plus the server-side replay window, timestamp checks, and session storage.
Caveats.
- The server's public-key bundle must be authenticated before encapsulation (pinning, a certificate chain, or a signed-metadata channel). ML-KEM to an unauthenticated key is an exchange with an attacker.
- A client ML-DSA key is only an identity after enrollment/attestation (BB4) — on its own it is just a key.
- Never use the raw ML-KEM shared secret as a traffic key; always derive through the KDF that also mixes the classical secret.
What it does. Turns "a public key" into "a trusted identity." Devices/users generate ML-DSA identity keys; a directory publishes them; clients trust them because an enrollment authority vouches for them — not because the key signs itself.
final dsa = DilithiumParams.mlDsa65;
// On the device/user: create a long-lived identity key.
final (identityPk, identitySk) = MlDsa.generateKeyPair(dsa);
// Enrollment: the AUTHORITY (a key clients already trust) signs a binding of
// {subject, identityPk, validity} after verifying the subject out of band.
final binding = lengthPrefixed([
utf8Bytes('myapp/enrollment/v1'),
utf8Bytes(subjectId), identityPk, uint64(notBeforeMs), uint64(expiresAtMs),
]); // framing helper in BB6
final authoritySig = MlDsa.sign(authoritySk, binding, dsa,
ctx: utf8Bytes('myapp/enrollment/v1'));
// A client trusts identityPk only if the authority's signature verifies
// against the authority's PINNED public key.
final trusted = MlDsa.verify(authorityPk, binding, authoritySig, dsa,
ctx: utf8Bytes('myapp/enrollment/v1'));You supply. The out-of-band proof that the subject really controls the key (account login, attestation, in-person check), directory storage, and a revocation mechanism (revocation lists, short expiry + re-enrollment).
Caveats.
- The authority key is your root of trust — protect it (KMS/HSM, offline) far more carefully than any device key.
- Enrollment is the hard part, not the signing. A signed key with no real vetting is theater.
- Revocation is not provided by signatures; design it explicitly.
What it does. Reproducibly derives a keypair from a stored high-entropy seed. Useful for backup/restore, KMS-held seeds, and cross-implementation interop.
final kem = PqcKem.kyber768;
final dsa = DilithiumParams.mlDsa65;
// ML-KEM from a 64-byte seed (d || z). With the SAME 64-byte seed, OpenSSL
// derives the byte-identical public key — see OPENSSL_INTEROP.md.
final seed64 = randomBytes(64); // you supply secure entropy/storage
final (kemPk, kemSk) = kem.generateKeyPair(seed64);
// ML-DSA from a 32-byte seed (xi).
final seed32 = randomBytes(32); // you supply secure entropy/storage
final (dsaPk, dsaSk) = MlDsa.generateKeyPairSeeded(dsa, seed32);You supply. Secure generation, storage, and backup of the seed (a platform keystore, KMS/HSM, or a user-held recovery phrase).
Caveats.
- The seed is equivalent to the secret key. Anyone with the seed can reproduce the keys. Protect it exactly as you would the secret key.
- Deterministic keygen is for backup/interop, not a substitute for a CSPRNG when
you just need a fresh key — use
generateKeyPair()for that.
What it does. Builds an append-only log where any modification or reordering of past entries is detectable: each entry signs over the previous entry's hash, forming a chain.
final dsa = DilithiumParams.mlDsa44; // verify/sign small records; 44 is light
final ctx = Uint8List.fromList(utf8.encode('myapp/audit-log/v1'));
Uint8List appendEntry(Uint8List signerSk, Uint8List prevHash, Uint8List payload) {
final framed = lengthPrefixed([prevHash, payload, uint64(nowMs())]);
final sig = MlDsa.sign(signerSk, framed, dsa, ctx: ctx); // pqcrypto
// store {framed, sig}; next entry's prevHash = hash(framed || sig)
return hash(concatBytes([framed, sig])); // you supply the hash
}The framing helpers used across these blocks (keep ONE copy shared by all writers and readers):
Uint8List lengthPrefixed(List<Uint8List> fields) {
final out = <Uint8List>[];
for (final f in fields) {
out.add(uint32(f.length));
out.add(f);
}
return concatBytes(out);
}
Uint8List concatBytes(List<Uint8List> chunks) {
final total = chunks.fold<int>(0, (s, c) => s + c.length);
final out = Uint8List(total);
var off = 0;
for (final c in chunks) {
out.setRange(off, off + c.length, c);
off += c.length;
}
return out;
}
Uint8List uint32(int v) =>
Uint8List(4)..buffer.asByteData().setUint32(0, v, Endian.big);
Uint8List uint64(int v) =>
Uint8List(8)..buffer.asByteData().setUint64(0, v, Endian.big);
Uint8List utf8Bytes(String s) => Uint8List.fromList(utf8.encode(s));You supply. A public hash function (e.g. SHA-256 from your stack), durable storage, and — for efficient inclusion proofs — a Merkle tree if you need one.
Caveats.
- This is tamper-evident, not tamper-proof. It detects edits; it does not prevent them, and it does not provide confidentiality.
- If the signer's key is compromised, the attacker can rewrite history from that point. Consider co-signing to an external transparency log or anchor.
- Integrity logging of people's activity is a surveillance capability. Log the minimum, and read the ethics note in PROJECT_CATALOG.md.
What it does. A vendor signs an artifact; a device verifies the signature against an embedded vendor key before installing. Verification is cheap, so this suits embedded-Linux devices well.
final dsa = DilithiumParams.mlDsa65;
final ctx = Uint8List.fromList(utf8.encode('myapp/firmware/v1'));
// --- Build server (vendor) ---
Uint8List signRelease(Uint8List vendorSk, Uint8List artifact, int version) {
final framed = lengthPrefixed([uint64(version), artifact]);
return MlDsa.hashSign(vendorSk, framed, dsa, ctx: ctx); // pre-hash: large file
}
// --- Device ---
bool acceptUpdate(Uint8List vendorPk, Uint8List artifact, int version, Uint8List sig) {
if (version <= installedVersion) return false; // you supply: rollback guard
final framed = lengthPrefixed([uint64(version), artifact]);
return MlDsa.hashVerify(vendorPk, framed, sig, dsa, ctx: ctx); // pqcrypto
}You supply. Secure embedding/pinning of the vendor public key in the device image, monotonic version/rollback protection, and the delivery channel.
Caveats.
- Sign the version (and ideally a hardware/model id) alongside the artifact, or an attacker can replay an old, validly-signed but vulnerable image.
- Protect the vendor signing key in an HSM; its compromise is catastrophic.
- Consider BB9 (dual signatures) for long-lived devices that must survive a single algorithm being broken.
What it does. BB2 applied to storage: encrypt records/files to a vault public key whose secret key lives offline or in a KMS/HSM, so the running app can write secrets it cannot itself read back.
final kem = PqcKem.kyber1024; // long-term data at rest → highest margin
Uint8List sealRecord(Uint8List vaultPk, Uint8List record) {
final (ct, ss) = kem.encapsulate(vaultPk); // pqcrypto
final key = hkdf(ss, salt: ct, info: 'myapp/at-rest/v1', length: 32); // you supply
final nonce = randomBytes(12); // you supply
final box = aeadSeal(key, nonce, record, aad: ct); // you supply
return concatBytes([ct, nonce, box]); // store this blob
}You supply. HKDF, AEAD, CSPRNG, and — critically — secure custody of the vault secret key (KMS/HSM, offline media). Optionally per-record keys.
Caveats.
- Choose ML-KEM-1024 for data with a long confidentiality lifetime ("harvest now, decrypt later" is the whole reason to use PQC at rest).
- Best-effort zeroization in Dart is not a guarantee that plaintext leaves no trace in memory; do not rely on it for a hard memory-erasure requirement.
What it does. Signs with two independent schemes so a forgery requires breaking both. During the PQC transition this is the conservative posture for high-value, long-lived signatures, and it smooths migration.
final dsa = DilithiumParams.mlDsa65;
final ctx = Uint8List.fromList(utf8.encode('myapp/dual-sign/v1'));
({Uint8List pqc, Uint8List classical}) dualSign(
Uint8List mlDsaSk, Object classicalSk, Uint8List message) {
final pqcSig = MlDsa.sign(mlDsaSk, message, dsa, ctx: ctx); // pqcrypto
final classicalSig = ed25519Sign(classicalSk, message); // you supply
return (pqc: pqcSig, classical: classicalSig);
}
// Accept ONLY if both verify.
bool dualVerify(Uint8List mlDsaPk, Object classicalPk, Uint8List message,
Uint8List pqcSig, Uint8List classicalSig) {
final a = MlDsa.verify(mlDsaPk, message, pqcSig, dsa, ctx: ctx); // pqcrypto
final b = ed25519Verify(classicalPk, message, classicalSig); // you supply
return a && b;
}You supply. A classical signature scheme (Ed25519/ECDSA) for a
classical × post-quantum hybrid. For a post-quantum × post-quantum dual — the
strongest posture, because a forgery must break two different hardness families
(lattice and hash) — swap the classical leg for SLH-DSA, which ships in
0.4.0 via SlhDsa:
// PQC x PQC: ML-DSA (lattice) + SLH-DSA (hash-based), both from pqcrypto.
final slh = SlhDsaParams.shake128f;
final mlDsaSig = MlDsa.sign(mlDsaSk, message, dsa, ctx: ctx);
final slhSig = SlhDsa.sign(slhSk, message, slh, context: ctx);
// Accept ONLY if both verify.
final ok = MlDsa.verify(mlDsaPk, message, mlDsaSig, dsa, ctx: ctx) &&
SlhDsa.verify(slhPk, message, slhSig, slh, context: ctx);Caveats.
- Decide your combiner policy explicitly: require-both (max safety) vs. accept-either (max availability during migration). They have opposite failure modes.
- Bind the same canonical message into both signatures.
What it does. Keeps UIs responsive by running key generation, signing, and encapsulation off the main thread.
import 'dart:isolate';
// Dart VM / Flutter mobile & desktop: real background thread.
final (pk, sk) = await Isolate.run(() =>
MlDsa.generateKeyPair(DilithiumParams.mlDsa65));
// Flutter equivalent: compute(buildHandshakeRequest, input);You supply. Isolate-sendable input/output types, and a policy for handling secret-key material crossing the isolate boundary.
Caveats.
- Flutter web does not run isolates on a separate thread.
compute()keeps the same API but does not move CPU off the main thread; heavy keygen will jank the page. Preferdart2wasm, do keygen rarely, or generate long-term keys server-side. - Passing a secret key into a worker isolate copies sensitive bytes. For hardened apps, prefer a dedicated long-lived crypto isolate or a platform-backed signing service over copying keys per call.