Skip to main content

Post-quantum Note Encryption

When an Action creates an output note, the chain stores only its commitment cm. The note's plaintext travels in two ciphertexts published alongside it:

  • C_enc: for the recipient, openable with the incoming viewing key ivk and the recipient's ML-KEM-768 private key.
  • C_out: for the sender (or an auditor with ovk), so the sender can recover what they sent from chain data alone, with no local state.

Encryption is always hybrid (classical ECDH + post-quantum ML-KEM-768). There is no classical-only mode: a sender who cannot resolve the recipient's ML-KEM public key (via the PqPubkeyRegistry or the recipient's published address) cannot send them a private note.

Setup

esk ← random BJJ scalar (fresh per output)
epk = [esk] · gd_new # ephemeral pubkey, published on-chain
ssEcdh = [esk] · pkd_new = [ivk] · epk # classical DH leg (commutative)
(ssPq, mlkemCT) ← ML-KEM-768.Encapsulate(recipient.mlkemPubkey) # PQ leg
K_enc = HKDF-SHA256(ssEcdh ‖ ssPq ‖ epk ‖ mlkemCT, info="platus.action.kenc.v1")

The DH identity [esk]·pkd_new = [esk·ivk]·gd_new = [ivk]·epk is why sender and recipient derive the same ssEcdh. The circuit constrains epk = esk·gd_new (and esk ≠ 0), so a sender cannot publish a valid commitment with an epk that doesn't match their ciphertext — that would brick the note for the recipient.

Breaking confidentiality requires breaking both legs. A future quantum adversary that defeats the BabyJubJub ECDH still faces ML-KEM-768; an adversary who defeats ML-KEM still faces ECDH. This is the harvest-now-decrypt-later defense for the publicly stored ciphertexts.

View tags: fast scanning

Each output publishes a 1-byte view tag:

viewTag=low_byte(Poseidon2(VT_DOMAIN,ssEcdhx,ssEcdhy))\text{viewTag} = \text{low\_byte}\big(\text{Poseidon2}(\text{VT\_DOMAIN}, ssEcdh_x, ssEcdh_y)\big)

Only the recipient (holding ivk) or the sender can recompute ssEcdh, so the tag is uniform-random to anyone else and leaks nothing.

C_enc — recipient ciphertext

The plaintext is constant length regardless of memo size, so the on-chain ciphertext length never leaks how long the memo is:

P_enc = d (11) ‖ value (16, LE) ‖ rho (32, LE) ‖ rcm (32, LE) ‖ psi (32, LE)
‖ memoLen (2, LE) ‖ memo (zero-padded to 512) # 637 bytes total
C_enc = mlkemCT (1568) ‖ ChaCha20-Poly1305(K_enc, nonce = 0…0).encrypt(P_enc)

C_out — sender recovery

C_out lets the sender (or ovk holder) recover the note from chain data alone. Its key is per-output unique:

ock = HKDF-SHA256(ovk ‖ cvNet ‖ cm_new ‖ epk, info="platus.action.ock.v1")
P_out = pkd_x (32) ‖ pkd_y (32) ‖ esk (32) ‖ ssPq (32) # 128 bytes
C_out = ChaCha20-Poly1305(ock, nonce = 0…0).encrypt(P_out)

The stashed ssPq is the key trick: with pkd and esk the sender recomputes ssEcdh = [esk]·pkd, and with ssPq they re-derive K_enc and open C_enc without the recipient's ML-KEM private key — recovering the full (value, rho, rcm, psi, memo).

Post-quantum hybrid encryption

The ML-KEM-768 keypair is derived deterministically from the spending key:

pqSeed = PRF^expand(sk, 0x0c) # SHA512(sk ‖ 0x0c), 64 bytes
(mlkemPubkey, mlkemPrivkey) ← ML-KEM-768.KeyGen(pqSeed)

PaymentAddress carries mlkemPubkey so any sender can encrypt, recipients publish it to the on-chain PqPubkeyRegistry (write-once, keyed by their BJJ transmission key, authorized by a BJJ Schnorr signature) so even an unknown sender can resolve it.