Status: prototype format. It uses post-quantum cryptography but has not been reviewed or audited.
This document is the reference for src/crypto/ and for the independent implementation in
tests/reference.ts, which is written from this text and checked against the app in the test suite.
Algorithms
| Role | Algorithm | Implementation |
|---|---|---|
| Key encapsulation | ML-KEM-768 (NIST FIPS 203) | @noble/post-quantum ml_kem768 |
| Key derivation | HKDF with SHA-256 (RFC 5869) | WebCrypto HKDF |
| Authenticated encryption | AES-256-GCM, 96-bit nonce, 128-bit tag (NIST SP 800-38D) | WebCrypto AES-GCM |
| Fingerprint | SHA-256 of the raw 1,184-byte public key | WebCrypto SHA-256 |
| Randomness | crypto.getRandomValues (key seeds, ML-KEM message randomness, nonces) |
browser / OS CSPRNG |
Public-key file
{
"format": "LatticeLink-public-key",
"v": 1,
"kem": "ML-KEM-768",
"fingerprint": "<64 lowercase hex: SHA-256 of the raw public key>",
"public_key": "<unpadded base64url of the 1,184-byte ML-KEM-768 encapsulation key>"
}
On import, the reader:
- rejects unknown fields and any other
format,vorkem; - decodes
public_key(canonical unpadded base64url only) and requires exactly 1,184 bytes; - runs the FIPS 203 encapsulation-key checks (length and modulus) by performing a throwaway encapsulation;
- computes the fingerprint itself and rejects the file if a
fingerprintfield is present and differs.
These checks show that the bytes are a well-formed key. They do not show whose key it is. Only comparing the fingerprint with the intended recipient over a channel you already trust does that. A bare base64url key, without the JSON wrapper, is also accepted.
Envelope
{
"format": "LatticeLink-envelope",
"v": 1,
"kem": "ML-KEM-768",
"kdf": "HKDF-SHA-256",
"aead": "AES-256-GCM",
"recipient": "<64 lowercase hex: fingerprint of the recipient public key>",
"kem_ct": "<base64url, 1,088 bytes>",
"nonce": "<base64url, 12 bytes>",
"ct": "<base64url, AES-GCM ciphertext followed by the 16-byte tag>"
}
All binary fields are canonical unpadded base64url (RFC 4648 §5). Writers emit the fields in the order shown.
Associated data
AAD = UTF-8( JSON.stringify([format, v, kem, kdf, aead, recipient, kem_ct, nonce]) )
This uses the exact field strings as they appear in the envelope (and the number 1 for v). Every field except ct is authenticated.
Key derivation
ss = ML-KEM-768.Encaps(recipient_public_key) // 32 bytes; sender side
info = UTF-8("LatticeLink/v1 message key") || 0x00 || recipient_fingerprint_bytes (32)
key = HKDF-SHA-256(IKM = ss, salt = empty, info = info, L = 32)
Sealing (sender)
- Encode the message as UTF-8. Refuse text containing unpaired UTF-16 surrogates. The maximum is 65,536 bytes.
- Fingerprint the recipient public key.
- Encapsulate: this gives
kem_ct(1,088 bytes) andss(32 bytes). - Draw a fresh 12-byte nonce from
crypto.getRandomValues. - Build the header, compute the AAD, derive
key, and wipess. - Set
ct = AES-256-GCM-Encrypt(key, nonce, plaintext, AAD), which includes the tag.
Each message gets a fresh encapsulation, so every message has its own key. The random nonce is a second safeguard.
Opening (recipient)
- Parse strictly, before any cryptography. Reject input that is over 131,072 characters, is not JSON, or is not an object. Also reject unknown or missing fields, wrong types, any
format/v/kem/kdf/aeadother than the values above, arecipientthat is not 64 lowercase hex characters, non-canonical base64url,kem_ctother than 1,088 bytes,nonceother than 12 bytes, andctoutside 16..65,552 bytes. - Note whether
recipientequals the fingerprint of the recipient's own key. Continue either way. - Decapsulate
kem_ctwith the secret key. ML-KEM uses implicit rejection: a foreign or altered ciphertext yields an unrelated secret, not an error. - Derive
keyusing the envelope's ownrecipientfield, then wipess. - AES-256-GCM-Decrypt with the AAD rebuilt from the parsed header. If the tag fails, return authentication failed. WebCrypto releases no bytes on tag failure, so no partial plaintext can exist.
- If the tag verified but step 2 found a mismatch, refuse with recipient mismatch. This can only happen with a deliberately crafted envelope.
- Decode the plaintext as strict UTF-8.
Test vector
tests/vectors/envelope-v1.json contains an envelope sealed to a key derived from a published
seed (so it protects nothing). It also lists the fixed ML-KEM message randomness and nonce, so the envelope
can be reproduced byte for byte. Regenerate it with npm run vector.
What the format does not provide
- Sender authentication. Anyone with the recipient's public key can produce a valid envelope.
- Replay protection, ordering, or forward secrecy beyond the per-message encapsulation. A compromised recipient secret key opens every envelope ever sealed to it.
- Hiding message length. Ciphertext length equals plaintext length plus 16 bytes.
- Hiding the recipient. The
recipientfield names the key the envelope was sealed for.