Packages
Pure Erlang QUIC implementation (RFC 9000)
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
src/quic_aead.erl
%%% -*- erlang -*-
%%%
%%% QUIC AEAD Packet Protection
%%% RFC 9001 Section 5 - Packet Protection
%%%
%%% Copyright (c) 2024-2026 Benoit Chesneau
%%% Apache License 2.0
%%%
%%% @doc AEAD encryption/decryption for QUIC packet protection.
%%%
%%% QUIC uses AEAD algorithms (AES-GCM, ChaCha20-Poly1305) to protect
%%% packet payloads. Header protection is applied to hide the packet
%%% number and certain header flags.
%%%
%%% == Packet Protection ==
%%%
%%% The nonce for AEAD is computed by XORing the IV with the packet
%%% number (left-padded to 12 bytes).
%%%
%%% == Header Protection ==
%%%
%%% A sample from the encrypted payload is used to generate a mask
%%% that protects the first header byte and packet number bytes.
%%%
-module(quic_aead).
%% Suppress dialyzer warnings for cipher patterns not yet exercised
-dialyzer([no_match]).
-export([
encrypt/5,
encrypt/6,
decrypt/5,
decrypt/6,
protect_header/4,
unprotect_header/4,
compute_nonce/2,
compute_hp_mask/3,
%% Consolidated packet protection API
protect_short_packet/8,
protect_long_packet/7,
unprotect_short_packet/7,
unprotect_long_packet/7,
%% 2-stage short header receive API (for key-phase handling)
unprotect_short_header/4,
decrypt_short_payload/8
]).
-export_type([cipher/0]).
-type cipher() :: aes_128_gcm | aes_256_gcm | chacha20_poly1305.
%% Tag length for AEAD algorithms (16 bytes)
-define(TAG_LEN, 16).
%% Header protection sample offset from start of encrypted payload
-define(HP_SAMPLE_OFFSET, 4).
-define(HP_SAMPLE_LEN, 16).
%% Minimum payload size for header protection sample
-define(MIN_PAYLOAD_FOR_SAMPLE, ?HP_SAMPLE_OFFSET + ?HP_SAMPLE_LEN).
%%====================================================================
%% API
%%====================================================================
%% @doc Encrypt a QUIC packet payload using AEAD.
%%
%% Key: AEAD key
%% IV: AEAD initialization vector
%% PN: Packet number (used with IV to create nonce)
%% AAD: Additional authenticated data (unprotected header)
%% Plaintext: Payload to encrypt
%%
%% Returns: Ciphertext with authentication tag appended
-spec encrypt(binary(), binary(), non_neg_integer(), binary(), binary()) ->
binary().
encrypt(Key, IV, PN, AAD, Plaintext) ->
Cipher = cipher_for_key(Key),
encrypt(Key, IV, PN, AAD, Plaintext, Cipher).
%% @doc Encrypt with explicit cipher type.
%% Useful for ChaCha20-Poly1305 which also uses 32-byte keys.
-spec encrypt(binary(), binary(), non_neg_integer(), binary(), binary(), cipher()) ->
binary().
encrypt(Key, IV, PN, AAD, Plaintext, Cipher) ->
Nonce = compute_nonce(IV, PN),
{Ciphertext, Tag} = crypto:crypto_one_time_aead(
Cipher, Key, Nonce, Plaintext, AAD, ?TAG_LEN, true
),
<<Ciphertext/binary, Tag/binary>>.
%% @doc Decrypt a QUIC packet payload using AEAD.
%%
%% Returns: {ok, Plaintext} | {error, bad_tag}
-spec decrypt(binary(), binary(), non_neg_integer(), binary(), binary()) ->
{ok, binary()} | {error, bad_tag}.
decrypt(Key, IV, PN, AAD, CiphertextWithTag) ->
Cipher = cipher_for_key(Key),
decrypt(Key, IV, PN, AAD, CiphertextWithTag, Cipher).
%% @doc Decrypt with explicit cipher type.
%% Useful for ChaCha20-Poly1305 which also uses 32-byte keys.
-spec decrypt(binary(), binary(), non_neg_integer(), binary(), binary(), cipher()) ->
{ok, binary()} | {error, bad_tag}.
decrypt(Key, IV, PN, AAD, CiphertextWithTag, Cipher) ->
Nonce = compute_nonce(IV, PN),
CipherLen = byte_size(CiphertextWithTag) - ?TAG_LEN,
<<Ciphertext:CipherLen/binary, Tag:?TAG_LEN/binary>> = CiphertextWithTag,
case
crypto:crypto_one_time_aead(
Cipher, Key, Nonce, Ciphertext, AAD, Tag, false
)
of
Plaintext when is_binary(Plaintext) ->
{ok, Plaintext};
error ->
{error, bad_tag}
end.
%% @doc Apply header protection to a QUIC packet.
%%
%% HP: Header protection key
%% Header: The packet header (first byte + rest + PN)
%% EncryptedPayload: The AEAD-encrypted payload (ciphertext + tag)
%% PNOffset: Offset of packet number in the header
%%
%% The sample is taken starting 4 bytes after the start of the Packet Number.
%% Since PN is at the end of Header, and ciphertext comes after PN:
%% sample_offset = 4 - PNLen (where PNLen is encoded in the first byte)
%%
%% Returns: Protected header (first byte and PN bytes masked), or
%% {error, payload_too_short} if payload is too small for sampling.
-spec protect_header(binary(), binary(), binary(), non_neg_integer()) ->
binary() | {error, payload_too_short}.
protect_header(HP, Header, EncryptedPayload, PNOffset) ->
Cipher = cipher_for_key(HP),
<<FirstByte, _/binary>> = Header,
PNLen = (FirstByte band 16#03) + 1,
%% Sample starts (4 - PNLen) bytes into ciphertext
%% This is because sample_offset = pn_offset + 4 in the full packet
%% And ciphertext starts at pn_offset + PNLen
SampleOffset = max(0, 4 - PNLen),
RequiredLen = SampleOffset + ?HP_SAMPLE_LEN,
case byte_size(EncryptedPayload) >= RequiredLen of
true ->
Sample = binary:part(EncryptedPayload, SampleOffset, ?HP_SAMPLE_LEN),
Mask = compute_hp_mask(Cipher, HP, Sample),
apply_header_mask(Header, Mask, PNOffset);
false ->
{error, payload_too_short}
end.
%% @doc Remove header protection from a QUIC packet.
%%
%% HP: Header protection key
%% ProtectedHeader: The protected header bytes (up to but not including PN)
%% EncryptedPayload: PN bytes + ciphertext + tag
%% PNOffset: Offset of packet number in the full header (= byte_size(ProtectedHeader))
%%
%% The sample is taken at position 4 from the start of PN.
%% Since EncryptedPayload starts with PN, sample is at position 4.
%%
%% Returns: {UnprotectedHeader, PNLength} or {error, payload_too_short}
-spec unprotect_header(binary(), binary(), binary(), non_neg_integer()) ->
{binary(), pos_integer()} | {error, payload_too_short}.
unprotect_header(HP, ProtectedHeader, EncryptedPayload, _PNOffset) ->
case byte_size(EncryptedPayload) >= ?MIN_PAYLOAD_FOR_SAMPLE of
false ->
{error, payload_too_short};
true ->
Cipher = cipher_for_key(HP),
%% Sample is at position 4 from start of PN
%% PN is at position 0 of EncryptedPayload
Sample = binary:part(EncryptedPayload, ?HP_SAMPLE_OFFSET, ?HP_SAMPLE_LEN),
Mask = compute_hp_mask(Cipher, HP, Sample),
<<ProtectedFirstByte, HeaderRest/binary>> = ProtectedHeader,
<<MaskByte0, MaskByte1, MaskByte2, MaskByte3, MaskByte4, _/binary>> = Mask,
%% Unmask first byte to get PN length
IsLongHeader = (ProtectedFirstByte band 16#80) =:= 16#80,
FirstByteMask =
case IsLongHeader of
true -> MaskByte0 band 16#0f;
false -> MaskByte0 band 16#1f
end,
FirstByte = ProtectedFirstByte bxor FirstByteMask,
%% Get PN length from unmasked first byte
PNLen = (FirstByte band 16#03) + 1,
%% PN is at the start of EncryptedPayload. Unmask inline
%% to avoid a crypto:exor/2 NIF call per packet — the PN
%% is only 1-4 bytes so pure-Erlang XOR is cheaper.
PN = xor_pn_bytes(
EncryptedPayload, PNLen, MaskByte1, MaskByte2, MaskByte3, MaskByte4
),
%% Return unprotected header (first byte + rest) with PN appended
UnprotectedHeader = <<FirstByte, HeaderRest/binary, PN/binary>>,
{UnprotectedHeader, PNLen}
end.
%% Pure-Erlang XOR for the 1-4 byte packet-number mask. Replaces
%% crypto:exor/2 on the hot send/receive paths; only the first PNLen
%% bytes of the input are consumed.
xor_pn_bytes(<<B1, _/binary>>, 1, M1, _M2, _M3, _M4) ->
<<(B1 bxor M1)>>;
xor_pn_bytes(<<B1, B2, _/binary>>, 2, M1, M2, _M3, _M4) ->
<<(B1 bxor M1), (B2 bxor M2)>>;
xor_pn_bytes(<<B1, B2, B3, _/binary>>, 3, M1, M2, M3, _M4) ->
<<(B1 bxor M1), (B2 bxor M2), (B3 bxor M3)>>;
xor_pn_bytes(<<B1, B2, B3, B4, _/binary>>, 4, M1, M2, M3, M4) ->
<<(B1 bxor M1), (B2 bxor M2), (B3 bxor M3), (B4 bxor M4)>>.
%% @doc Compute the nonce for AEAD by XORing IV with packet number.
%% RFC 9001 Section 5.3: The 64 bits of the reconstructed QUIC packet number
%% in network byte order are left-padded with zeros to the size of the IV.
-spec compute_nonce(binary(), non_neg_integer()) -> binary().
compute_nonce(<<IV0:32, IV1:64>>, PN) ->
<<IV0:32, (IV1 bxor PN):64>>.
%%====================================================================
%% Internal Functions
%%====================================================================
%% Determine cipher type from key length
cipher_for_key(Key) when byte_size(Key) =:= 16 -> aes_128_gcm;
cipher_for_key(Key) when byte_size(Key) =:= 32 -> aes_256_gcm.
%% Note: ChaCha20-Poly1305 also uses 32-byte keys, but we'd need
%% additional context to distinguish it from AES-256-GCM.
%% Compute header protection mask
compute_hp_mask(aes_128_gcm, HP, Sample) ->
%% AES-ECB encryption of sample
crypto:crypto_one_time(aes_128_ecb, HP, Sample, true);
compute_hp_mask(aes_256_gcm, HP, Sample) ->
%% AES-ECB encryption of sample (use first 16 bytes of 32-byte key)
%% Actually, HP for AES-256 is 32 bytes, use aes_256_ecb
crypto:crypto_one_time(aes_256_ecb, HP, Sample, true);
compute_hp_mask(chacha20_poly1305, HP, Sample) ->
%% ChaCha20 with counter=0 and the sample as nonce
%% Sample is 16 bytes: first 4 = counter, last 12 = nonce
<<Counter:32/little, Nonce:12/binary>> = Sample,
%% Generate 5 bytes of mask using ChaCha20
Zeros = <<0, 0, 0, 0, 0>>,
crypto:crypto_one_time(chacha20, HP, <<Counter:32/little, Nonce/binary>>, Zeros, true).
%% Apply mask to header (for protection)
apply_header_mask(Header, Mask, PNOffset) ->
<<FirstByte, Rest/binary>> = Header,
<<MaskByte0, MaskByte1, MaskByte2, MaskByte3, MaskByte4, _/binary>> = Mask,
%% Determine PN length from first byte (bits 0-1 for short, bits 0-1 for long)
%% The PN length is encoded in the two least significant bits + 1
PNLen = (FirstByte band 16#03) + 1,
%% Mask first byte: for long header, mask lower 4 bits; for short, mask lower 5 bits
IsLongHeader = (FirstByte band 16#80) =:= 16#80,
FirstByteMask =
case IsLongHeader of
% Long header: mask bits 0-3
true -> MaskByte0 band 16#0f;
% Short header: mask bits 0-4
false -> MaskByte0 band 16#1f
end,
ProtectedFirstByte = FirstByte bxor FirstByteMask,
%% Split header at PN offset
% -1 because we already split off first byte
BeforePNLen = PNOffset - 1,
<<BeforePN:BeforePNLen/binary, PN:PNLen/binary, AfterPN/binary>> = Rest,
%% Inline pure-Erlang XOR for the PN bytes (see xor_pn_bytes/6).
ProtectedPN = xor_pn_bytes(
PN, PNLen, MaskByte1, MaskByte2, MaskByte3, MaskByte4
),
<<ProtectedFirstByte, BeforePN/binary, ProtectedPN/binary, AfterPN/binary>>.
%%====================================================================
%% Consolidated Packet Protection API
%%====================================================================
%% @doc Protect a short header (1-RTT) packet.
%% Performs encryption and header protection in a single call.
%%
%% Cipher: AEAD cipher type
%% Key: AEAD key
%% IV: AEAD initialization vector
%% HP: Header protection key
%% PN: Packet number
%% FirstByte: First byte of header (includes spin bit, key phase, etc.)
%% DCID: Destination Connection ID
%% Plaintext: Payload to encrypt
%%
%% Returns: Complete protected packet binary
-spec protect_short_packet(
cipher(),
binary(),
binary(),
binary(),
non_neg_integer(),
byte(),
binary(),
iodata()
) -> binary().
protect_short_packet(Cipher, Key, IV, HP, PN, FirstByte, DCID, Plaintext) ->
PNLen = pn_length(PN),
PNBin = encode_pn(PN, PNLen),
HeaderPrefix = <<FirstByte, DCID/binary>>,
protect_packet_common(Cipher, Key, IV, HP, PN, HeaderPrefix, PNBin, Plaintext).
%% @doc Protect a long header (Initial/Handshake/0-RTT) packet.
%% Performs encryption and header protection in a single call.
%%
%% Cipher: AEAD cipher type
%% Key: AEAD key
%% IV: AEAD initialization vector
%% HP: Header protection key
%% PN: Packet number
%% HeaderPrefix: Long header up to (but not including) the packet number
%% Plaintext: Payload to encrypt
%%
%% Returns: Complete protected packet binary
-spec protect_long_packet(
cipher(),
binary(),
binary(),
binary(),
non_neg_integer(),
binary(),
iodata()
) -> binary().
protect_long_packet(Cipher, Key, IV, HP, PN, HeaderPrefix, Plaintext) ->
PNLen = pn_length(PN),
PNBin = encode_pn(PN, PNLen),
protect_packet_common(Cipher, Key, IV, HP, PN, HeaderPrefix, PNBin, Plaintext).
%% @doc Shared core for packet protection.
%% Encrypts payload with AEAD, then applies header protection.
protect_packet_common(Cipher, Key, IV, HP, PN, HeaderPrefix, PNBin, Plaintext) ->
AAD = <<HeaderPrefix/binary, PNBin/binary>>,
Nonce = compute_nonce(IV, PN),
{Ciphertext, Tag} = crypto:crypto_one_time_aead(
Cipher, Key, Nonce, Plaintext, AAD, ?TAG_LEN, true
),
EncryptedPayload = <<Ciphertext/binary, Tag/binary>>,
PNOffset = byte_size(HeaderPrefix),
ProtectedHeader = protect_header(HP, AAD, EncryptedPayload, PNOffset),
<<ProtectedHeader/binary, EncryptedPayload/binary>>.
%% @doc Unprotect and decrypt a short header (1-RTT) packet.
%%
%% Cipher: AEAD cipher type
%% Key: AEAD key
%% IV: AEAD initialization vector
%% HP: Header protection key
%% Header: Protected header (first byte + DCID, without PN)
%% EncryptedPayload: PN bytes + ciphertext + tag
%% LargestRecv: Largest received packet number for PN reconstruction
%%
%% Returns: {ok, PN, UnprotectedHeader, Plaintext} | {error, term()}
-spec unprotect_short_packet(
cipher(),
binary(),
binary(),
binary(),
binary(),
binary(),
non_neg_integer() | undefined
) ->
{ok, non_neg_integer(), binary(), binary()} | {error, term()}.
unprotect_short_packet(Cipher, Key, IV, HP, Header, EncryptedPayload, LargestRecv) ->
unprotect_packet_common(Cipher, Key, IV, HP, Header, EncryptedPayload, LargestRecv).
%% @doc Unprotect and decrypt a long header (Initial/Handshake/0-RTT) packet.
%%
%% Cipher: AEAD cipher type
%% Key: AEAD key
%% IV: AEAD initialization vector
%% HP: Header protection key
%% Header: Protected header up to (but not including) the PN
%% EncryptedPayload: PN bytes + ciphertext + tag
%% LargestRecv: Largest received packet number for PN reconstruction
%%
%% Returns: {ok, PN, UnprotectedHeader, Plaintext} | {error, term()}
-spec unprotect_long_packet(
cipher(),
binary(),
binary(),
binary(),
binary(),
binary(),
non_neg_integer() | undefined
) ->
{ok, non_neg_integer(), binary(), binary()} | {error, term()}.
unprotect_long_packet(Cipher, Key, IV, HP, Header, EncryptedPayload, LargestRecv) ->
unprotect_packet_common(Cipher, Key, IV, HP, Header, EncryptedPayload, LargestRecv).
%% @doc Shared core for packet unprotection.
%% Removes header protection, reconstructs PN, and decrypts payload.
unprotect_packet_common(Cipher, Key, IV, HP, Header, EncryptedPayload, LargestRecv) ->
PNOffset = byte_size(Header),
case unprotect_header(HP, Header, EncryptedPayload, PNOffset) of
{error, Reason} ->
{error, {header_unprotect_failed, Reason}};
{UnprotectedHeader, PNLen} ->
%% Extract truncated PN
UnprotHeaderLen = byte_size(UnprotectedHeader),
<<_:((UnprotHeaderLen - PNLen) * 8), TruncatedPN:PNLen/unit:8>> = UnprotectedHeader,
%% Reconstruct full PN
PN = reconstruct_pn(LargestRecv, TruncatedPN, PNLen),
%% AAD is the full unprotected header
AAD = UnprotectedHeader,
%% Ciphertext starts after PN bytes
<<_:PNLen/binary, Ciphertext/binary>> = EncryptedPayload,
%% Decrypt
Nonce = compute_nonce(IV, PN),
CipherLen = byte_size(Ciphertext) - ?TAG_LEN,
<<CiphertextOnly:CipherLen/binary, Tag:?TAG_LEN/binary>> = Ciphertext,
case
crypto:crypto_one_time_aead(
Cipher, Key, Nonce, CiphertextOnly, AAD, Tag, false
)
of
Plaintext when is_binary(Plaintext) ->
{ok, PN, UnprotectedHeader, Plaintext};
error ->
{error, decryption_failed}
end
end.
%%====================================================================
%% 2-Stage Short Header Receive API
%%====================================================================
%% For 1-RTT packets, key selection depends on the key_phase bit which
%% is header-protected. This 2-stage API allows:
%% 1. Unprotect header to recover key_phase, then select correct keys
%% 2. Decrypt payload with selected keys
%% @doc Stage 1: Unprotect short header to recover key_phase and PN info.
%% Uses HP key (same regardless of key phase) to unprotect header.
%%
%% HP: Header protection key
%% Header: Protected header (first byte + DCID)
%% EncryptedPayload: PN bytes + ciphertext + tag
%% PNOffset: Offset to packet number in header (= byte_size(Header))
%%
%% Returns: {ok, KeyPhase, PNLen, TruncatedPN, UnprotectedHeader} | {error, term()}
-spec unprotect_short_header(binary(), binary(), binary(), non_neg_integer()) ->
{ok, 0 | 1, 1..4, non_neg_integer(), binary()} | {error, term()}.
unprotect_short_header(HP, Header, EncryptedPayload, PNOffset) ->
case unprotect_header(HP, Header, EncryptedPayload, PNOffset) of
{error, Reason} ->
{error, {header_unprotect_failed, Reason}};
{UnprotectedHeader, PNLen} ->
%% Extract key_phase from unprotected first byte (bit 2)
<<FirstByte, _/binary>> = UnprotectedHeader,
KeyPhase = (FirstByte bsr 2) band 1,
%% Extract truncated PN
UnprotHeaderLen = byte_size(UnprotectedHeader),
<<_:((UnprotHeaderLen - PNLen) * 8), TruncatedPN:PNLen/unit:8>> = UnprotectedHeader,
{ok, KeyPhase, PNLen, TruncatedPN, UnprotectedHeader}
end.
%% @doc Stage 2: Decrypt short packet payload after key selection.
%% Called after unprotect_short_header with the correct keys based on key_phase.
%%
%% Cipher: AEAD cipher type
%% Key: AEAD key (selected based on key_phase from stage 1)
%% IV: AEAD IV (selected based on key_phase from stage 1)
%% UnprotectedHeader: From stage 1 (used as AAD)
%% PNLen: From stage 1
%% TruncatedPN: From stage 1
%% EncryptedPayload: PN bytes + ciphertext + tag
%% LargestRecv: Largest received packet number for PN reconstruction
%%
%% Returns: {ok, PN, Plaintext} | {error, term()}
-spec decrypt_short_payload(
cipher(),
binary(),
binary(),
binary(),
1..4,
non_neg_integer(),
binary(),
non_neg_integer() | undefined
) ->
{ok, non_neg_integer(), binary()} | {error, term()}.
decrypt_short_payload(
Cipher, Key, IV, UnprotectedHeader, PNLen, TruncatedPN, EncryptedPayload, LargestRecv
) ->
%% Reconstruct full PN
PN = reconstruct_pn(LargestRecv, TruncatedPN, PNLen),
%% AAD is the full unprotected header
AAD = UnprotectedHeader,
%% Ciphertext starts after PN bytes
<<_:PNLen/binary, Ciphertext/binary>> = EncryptedPayload,
%% Decrypt
Nonce = compute_nonce(IV, PN),
CipherLen = byte_size(Ciphertext) - ?TAG_LEN,
<<CiphertextOnly:CipherLen/binary, Tag:?TAG_LEN/binary>> = Ciphertext,
case crypto:crypto_one_time_aead(Cipher, Key, Nonce, CiphertextOnly, AAD, Tag, false) of
Plaintext when is_binary(Plaintext) ->
{ok, PN, Plaintext};
error ->
{error, decryption_failed}
end.
%% Reconstruct full packet number from truncated PN (RFC 9000 Appendix A)
reconstruct_pn(undefined, TruncatedPN, _PNLen) ->
%% No previous packets, use truncated PN directly
TruncatedPN;
reconstruct_pn(LargestRecv, TruncatedPN, PNLen) ->
%% RFC 9000 Appendix A: Packet Number Decoding
PNWin = 1 bsl (PNLen * 8),
PNHalfWin = PNWin div 2,
%% Expected PN is one more than largest received
ExpectedPN = LargestRecv + 1,
%% Candidate PN in the expected range
CandidatePN = (ExpectedPN band (bnot (PNWin - 1))) bor TruncatedPN,
%% Adjust candidate based on window
adjust_candidate_pn(CandidatePN, ExpectedPN, PNWin, PNHalfWin).
%% Check if candidate is in the valid window and adjust
adjust_candidate_pn(CandidatePN, ExpectedPN, PNWin, PNHalfWin) when
CandidatePN =< ExpectedPN - PNHalfWin, CandidatePN < (1 bsl 62) - PNWin
->
CandidatePN + PNWin;
adjust_candidate_pn(CandidatePN, ExpectedPN, PNWin, PNHalfWin) when
CandidatePN > ExpectedPN + PNHalfWin, CandidatePN >= PNWin
->
CandidatePN - PNWin;
adjust_candidate_pn(CandidatePN, _ExpectedPN, _PNWin, _PNHalfWin) ->
CandidatePN.
%% Local packet number helpers (avoid circular dependency with quic_packet)
pn_length(PN) when PN < 256 -> 1;
pn_length(PN) when PN < 65536 -> 2;
pn_length(PN) when PN < 16777216 -> 3;
pn_length(_) -> 4.
encode_pn(PN, 1) -> <<PN:8>>;
encode_pn(PN, 2) -> <<PN:16>>;
encode_pn(PN, 3) -> <<PN:24>>;
encode_pn(PN, 4) -> <<PN:32>>.