-
Notifications
You must be signed in to change notification settings - Fork 3
Serverpod Integration
This document is the canonical integration guide for coupling pqcrypto with a Dart backend (Serverpod) and a Flutter client. It is authored with a zero-trust, enterprise-first mindset.
Lattice-based cryptography is relatively new compared to elliptic-curve cryptography (ECC). To mitigate the risk of an undiscovered mathematical flaw in FIPS 203 (ML-KEM), you must use a hybrid key exchange.
The protocol flow:
- Bootstrap: The Serverpod backend loads an immutable Key Bundle (containing an ML-KEM-768 public/secret key, an ML-DSA-65 identity key, and a rotation epoch) from a hardware security module (HSM) or KMS.
- Client Init: The Flutter client generates an ephemeral classical key (e.g., X25519) and an ephemeral nonce.
- Encapsulation: The client uses the server's ML-KEM public key to encapsulate a lattice shared secret.
- Transcript Binding: The client computes a deterministic hash of the entire communication transcript to prevent man-in-the-middle downgrade attacks.
- Authentication: The client signs this transcript hash using its ML-DSA-65 identity key.
-
Key Derivation: The server verifies the transcript and signature, decapsulates the ML-KEM secret, and merges it with the X25519 secret via HKDF (
ss_classical || ss_lattice).
Your API boundaries must be strictly typed. Create the following Serverpod models. The ByteData fields represent the raw binary structures.
class: PqcHandshakeRequest
fields:
keyId: String
clientNonce: ByteData
clientTimestampMs: int
clientX25519PublicKey: ByteData
mlKem768Ciphertext: ByteData
clientMlDsa65PublicKey: ByteData
clientSignature: ByteData
transcriptHash: ByteDataBefore allocating memory for cryptographic operations, the Serverpod Endpoint must enforce strict length validations. Failure to do this exposes the server to buffer exhaustion and denial-of-service (DoS).
void _requireLength(Uint8List bytes, int expected, String field) {
if (bytes.length != expected) {
throw ArgumentError('Malformed $field: Expected $expected bytes, got ${bytes.length}');
}
}
// In your Endpoint:
_requireLength(ciphertext, 1088, 'mlKem768Ciphertext');
_requireLength(clientDsaPublicKey, 1952, 'clientMlDsa65PublicKey');
_requireLength(clientSignature, 3309, 'clientSignature');A stolen, intercepted ML-KEM ciphertext can be submitted repeatedly.
-
Timestamp Window: The
clientTimestampMsmust be validated against the server's UTC clock. A2000mswindow is standard. Anything outside this window is immediately rejected. -
Nonce Caching: Store the 32-byte
clientNoncein Serverpod's Redis instance with a TTL of 2000ms. If a nonce is seen twice, terminate the handshake.
The transcriptHash must unambiguously bind the session parameters. Use length-prefixed encoding to prevent collision vulnerabilities where an attacker shifts bytes between adjacent fields.
Uint8List buildHandshakeTranscript({
required String keyId,
required int clientTimestampMs,
required Uint8List clientNonce,
required Uint8List clientX25519PublicKey,
required Uint8List mlKem768PublicKey,
required Uint8List mlDsa65PublicKey,
required Uint8List mlKem768Ciphertext,
required Uint8List clientMlDsa65PublicKey,
}) {
return lengthPrefixFields([
utf8Bytes('pqcrypto/serverpod/flutter/v1'),
utf8Bytes('ML-KEM-768'),
utf8Bytes('ML-DSA-65'),
utf8Bytes(keyId),
uint64(clientTimestampMs),
clientNonce,
clientX25519PublicKey,
mlKem768PublicKey,
mlDsa65PublicKey,
mlKem768Ciphertext,
clientMlDsa65PublicKey,
]);
}Lattice cryptography operates on polynomial rings using the Number Theoretic Transform (NTT). This is CPU-intensive. On mobile devices, executing ML-KEM encapsulation and ML-DSA signing on the main thread will cause UI stutter (jank).
You must offload this to a worker isolate using compute():
Future<void> establishSession() async {
// 1. Fetch public materials...
// 2. Offload the heavy cryptography to a background isolate
final output = await compute(
_buildPqcHandshakeRequest,
ClientHandshakeInput(
keyId: serverBundle.keyId,
timestampMs: DateTime.now().toUtc().millisecondsSinceEpoch,
clientNonce: secureRandomBytes(32),
// ... pass required keys
),
);
// 3. Send over the network
final response = await client.pqc.establishSession(output.request);
}Note: On Flutter Web (dart2js / dart2wasm), compute() executes sequentially because web workers do not share memory natively. Design your web UI with loading states to account for this synchronous execution.
Once the sessionKey is derived via HKDF, the underlying ss_classical and ss_lattice buffers must be zeroized. In Dart, you cannot force the Garbage Collector to wipe memory, but you can explicitly zero out the Uint8List indices before the variables fall out of scope.
try {
sessionKey = await hkdfSessionKey(...);
} finally {
secureZero(ss_lattice);
secureZero(ss_classical);
}pqcrypto — pure Dart, zero-dependency post-quantum cryptography (ML-KEM FIPS 203 · ML-DSA FIPS 204) for Dart, Flutter, and the web · MIT License · pub.dev · Repository · Documentation Index
Algorithm/KAT-conformance and interoperability evidence — not a CMVP/FIPS 140 module validation.
pqcrypto Wiki
Getting started
Algorithms
Design & internals
Assurance
Integration
Project
Links