Skip to main content

src/nquic_crypto.erl

-module(nquic_crypto).

-moduledoc """
QUIC payload protection using AEAD (RFC 9001 Section 5).

Handles AES-128-GCM and ChaCha20-Poly1305 encryption and decryption
of QUIC packet payloads. Nonces are constructed by XORing the IV with
the packet number.
""".

-export([constant_time_equal/2, decrypt/6, encrypt/6]).

-doc """
Constant-time equality for binaries.

Use for any security-sensitive comparison (tokens, MACs, reset tokens)
to avoid revealing length or content through timing.

Returns `false` immediately on length mismatch, which leaks length but
not content. Callers that must also hide length should pad the inputs
to a fixed size before comparing.
""".
-spec constant_time_equal(binary(), binary()) -> boolean().
constant_time_equal(A, B) when byte_size(A) =:= byte_size(B) ->
    crypto:hash_equals(A, B);
constant_time_equal(_, _) ->
    false.

-doc "Decrypt a QUIC packet payload with AEAD. Returns plaintext or `{error, decrypt_failed}`.".
-spec decrypt(
    aes_128_gcm | chacha20_poly1305, binary(), binary(), nquic_packet_number:t(), binary(), binary()
) ->
    binary() | {error, term()}.
decrypt(Cipher, Key, IV, PN, AAD, CiphertextAndTag) ->
    Nonce = make_nonce(IV, PN),
    TagLen = 16,
    Size = byte_size(CiphertextAndTag) - TagLen,
    <<Ciphertext:Size/binary, Tag:TagLen/binary>> = CiphertextAndTag,
    case crypto:crypto_one_time_aead(Cipher, Key, Nonce, Ciphertext, AAD, Tag, false) of
        error -> {error, decrypt_failed};
        Plaintext -> Plaintext
    end.

-doc "Encrypt a QUIC packet payload with AEAD.".
-spec encrypt(
    aes_128_gcm | chacha20_poly1305, binary(), binary(), nquic_packet_number:t(), iodata(), iodata()
) ->
    {binary(), binary()}.
encrypt(Cipher, Key, IV, PN, AAD, Plaintext) ->
    Nonce = make_nonce(IV, PN),
    crypto:crypto_one_time_aead(Cipher, Key, Nonce, Plaintext, AAD, true).

-spec make_nonce(<<_:96>>, nquic_packet_number:t()) -> <<_:96>>.
make_nonce(<<IV0:32, IV1:64>>, PN) ->
    <<IV0:32, (IV1 bxor PN):64>>.