Phoenix

KEM and sealing

Textbook NTRU encrypts one small polynomial and offers no protection against an attacker who modifies ciphertexts. Phoenix turns it into something usable in two steps:

phoenix.ntru   textbook trapdoor            e = r*h + m
      │
phoenix.kem    key encapsulation            (ciphertext, 32-byte secret)
      │
phoenix.seal   hybrid encryption of bytes   header | KEM ciphertext | AEAD ciphertext

What is wrong with the textbook scheme

  1. It is malleable. Add $x^k$ to a ciphertext and the plaintext gains $x^k$. An attacker can alter messages they cannot read.
  2. Decryption can be turned into an oracle. Feed the receiver carefully malformed ciphertexts, watch how they react, and the private key leaks piece by piece. Practical attacks of this kind against unpadded NTRU have been known since 2000.
  3. It leaks $m(1)$. See the algorithm page.
  4. Messages are polynomials. Real data is bytes of arbitrary length.

The standard cure is to never encrypt a chosen message at all.

The KEM

A key encapsulation mechanism has two operations:

The sender does not choose what gets encrypted; the mechanism generates a random secret and transports it. Phoenix builds its KEM with a Fujisaki–Okamoto-style transform.

Encapsulation

  1. Sample a random message $m \in T(d_m, d_m)$ with $d_m = \lfloor N/3 \rfloor$.
  2. Derive the blinding polynomial from the message and the public key: $r = \text{Sample}\bigl(H(\texttt{blinding}, H(\text{pk}), m)\bigr)$.
  3. Compute $e = r \star h + m \pmod q$ and pack it into bytes: the ciphertext $c$.
  4. Output the shared secret $K = H(\texttt{key}, m, c)$.

Step 2 is the heart of it. Because $r$ is a deterministic function of $m$, anyone who knows $m$ can recompute the entire ciphertext.

Decapsulation

  1. Unpack $c$ and decrypt it to get a candidate $m’$.
  2. Check that $m’$ really is in $T(d_m, d_m)$.
  3. Re-derive $r’$ from $m’$, re-encrypt, and compare with $c$ byte for byte.
  4. If everything matches, output $K = H(\texttt{key}, m’, c)$.
  5. Otherwise output $K’ = H(\texttt{reject}, s, c)$, where $s$ is a random 32-byte value stored in the private key.

Why this fixes the problems

>>> ciphertext, secret = phoenix.encapsulate(public_key)
>>> phoenix.decapsulate(private_key, ciphertext) == secret
True
>>> tampered = bytes([ciphertext[0] ^ 1]) + ciphertext[1:]
>>> phoenix.decapsulate(private_key, tampered) == secret
False                      # no exception, just an unrelated key

A ciphertext that is not even well-formed (wrong length, coefficient $\ge q$) raises FormatError. That is safe: anyone can perform those checks without the key, so the error reveals nothing.

Sealing

phoenix.seal is hybrid encryption: the KEM transports a key, and a standard symmetric cipher encrypts the data.

seal(public_key, plaintext, associated_data):
    kem_ciphertext, key = encapsulate(public_key)
    body = ChaCha20-Poly1305(key, nonce = 0).encrypt(
               plaintext,
               aad = header | kem_ciphertext | associated_data)
    return header | kem_ciphertext | body

Notes on the design:

The overhead is constant: 966 bytes for phoenix677, whatever the message size.

What sealing does not do

Caveats on the construction

The transform follows the standard recipe, but this exact combination (this ring, these distributions, this encoding) has no published security proof and has not been reviewed. Details that a production design would also get right, such as constant-time decapsulation, are out of scope here. See Security.

The byte-level details of every hash input are in File formats.