Research preview LatticeLink v0.1.0 runs ML-KEM-768 (NIST FIPS 203) entirely in your browser. ML-KEM-768, in your browser. Read the specificationRead the spec →

LatticeLink envelope format.

The complete, normative description of the public-key file and the sealed envelope: fields, encodings, key derivation, sealing, opening, and what the format does not provide.

Status: prototype, not reviewed Version 1 ML-KEM-768 · HKDF-SHA-256 · AES-256-GCM Download test vector (JSON)

v0.1.0Updated 2026-10-10

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:

  1. rejects unknown fields and any other format, v or kem;
  2. decodes public_key (canonical unpadded base64url only) and requires exactly 1,184 bytes;
  3. runs the FIPS 203 encapsulation-key checks (length and modulus) by performing a throwaway encapsulation;
  4. computes the fingerprint itself and rejects the file if a fingerprint field 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)

  1. Encode the message as UTF-8. Refuse text containing unpaired UTF-16 surrogates. The maximum is 65,536 bytes.
  2. Fingerprint the recipient public key.
  3. Encapsulate: this gives kem_ct (1,088 bytes) and ss (32 bytes).
  4. Draw a fresh 12-byte nonce from crypto.getRandomValues.
  5. Build the header, compute the AAD, derive key, and wipe ss.
  6. 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)

  1. 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/aead other than the values above, a recipient that is not 64 lowercase hex characters, non-canonical base64url, kem_ct other than 1,088 bytes, nonce other than 12 bytes, and ct outside 16..65,552 bytes.
  2. Note whether recipient equals the fingerprint of the recipient's own key. Continue either way.
  3. Decapsulate kem_ct with the secret key. ML-KEM uses implicit rejection: a foreign or altered ciphertext yields an unrelated secret, not an error.
  4. Derive key using the envelope's own recipient field, then wipe ss.
  5. 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.
  6. If the tag verified but step 2 found a mismatch, refuse with recipient mismatch. This can only happen with a deliberately crafted envelope.
  7. 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 recipient field names the key the envelope was sealed for.