Current section
Files
Jump to
Current section
Files
src/quic_crypto.erl
%%% -*- erlang -*-
%%%
%%% QUIC TLS 1.3 Cryptographic Operations
%%% RFC 8446 - TLS 1.3
%%% RFC 9001 - Using TLS to Secure QUIC
%%%
%%% Copyright (c) 2024-2026 Benoit Chesneau
%%% Apache License 2.0
%%%
%%% @doc TLS 1.3 key schedule and cryptographic operations for QUIC.
%%%
%%% This module implements the TLS 1.3 key schedule used by QUIC for
%%% deriving encryption keys at each handshake stage.
%%%
%%% == Key Schedule ==
%%%
%%% TLS 1.3 uses a three-stage key schedule:
%%% 1. Early Secret (from PSK, or zeros for non-PSK)
%%% 2. Handshake Secret (from ECDHE shared secret)
%%% 3. Master Secret (for application data)
%%%
%%% == Traffic Secrets ==
%%%
%%% From each secret, traffic secrets are derived for both client and
%%% server directions.
%%%
-module(quic_crypto).
-export([
%% Key Schedule
derive_early_secret/0,
derive_early_secret/1,
derive_early_secret/2,
derive_handshake_secret/2,
derive_handshake_secret/3,
derive_handshake_secret_psk_only/1,
derive_handshake_secret_psk_only/2,
derive_master_secret/1,
derive_master_secret/2,
%% Traffic Secrets
derive_client_handshake_secret/2,
derive_client_handshake_secret/3,
derive_server_handshake_secret/2,
derive_server_handshake_secret/3,
derive_client_app_secret/2,
derive_client_app_secret/3,
derive_server_app_secret/2,
derive_server_app_secret/3,
%% 0-RTT (Early Data)
derive_client_early_traffic_secret/2,
derive_client_early_traffic_secret/3,
derive_early_exporter_master_secret/2,
compute_psk_binder/3,
compute_psk_binder/4,
%% Derive-Secret function
derive_secret/3,
derive_secret/4,
%% Finished key and verify data
derive_finished_key/1,
derive_finished_key/2,
compute_finished_verify/2,
compute_finished_verify/3,
%% Transcript hash
transcript_hash/1,
transcript_hash/2,
hrr_transcript_prefix/2,
%% Cipher to hash mapping
cipher_to_hash/1,
hash_len/1,
%% ECDHE
generate_key_pair/1,
compute_shared_secret/3,
%% Retry Packet Integrity (RFC 9001 Section 5.8)
verify_retry_integrity_tag/3,
compute_retry_integrity_tag/3
]).
%% Hash length for SHA-256
-define(HASH_LEN, 32).
-define(HASH_LEN_384, 48).
%%====================================================================
%% Key Schedule (RFC 8446 Section 7.1)
%%====================================================================
%% @doc Derive early secret without PSK (zeros).
%% early_secret = HKDF-Extract(0, 0)
-spec derive_early_secret() -> binary().
derive_early_secret() ->
derive_early_secret(<<0:?HASH_LEN/unit:8>>).
%% @doc Derive early secret with PSK.
%% early_secret = HKDF-Extract(0, PSK)
-spec derive_early_secret(binary()) -> binary().
derive_early_secret(PSK) ->
%% Salt is all zeros
Salt = <<0:?HASH_LEN/unit:8>>,
quic_hkdf:extract(Salt, PSK).
%% @doc Derive early secret with cipher-specific hash.
-spec derive_early_secret(atom(), binary()) -> binary().
derive_early_secret(Cipher, PSK) ->
Hash = cipher_to_hash(Cipher),
HashLen = hash_len(Hash),
Salt = <<0:HashLen/unit:8>>,
quic_hkdf:extract(Hash, Salt, PSK).
%% @doc Derive handshake secret from early secret and ECDHE shared secret.
%% handshake_secret = HKDF-Extract(
%% Derive-Secret(early_secret, "derived", ""),
%% shared_secret)
-spec derive_handshake_secret(binary(), binary()) -> binary().
derive_handshake_secret(EarlySecret, SharedSecret) ->
Salt = derive_secret(EarlySecret, <<"derived">>, <<>>),
quic_hkdf:extract(Salt, SharedSecret).
%% @doc Derive handshake secret with cipher-specific hash.
-spec derive_handshake_secret(atom(), binary(), binary()) -> binary().
derive_handshake_secret(Cipher, EarlySecret, SharedSecret) ->
Hash = cipher_to_hash(Cipher),
Salt = derive_secret(Hash, EarlySecret, <<"derived">>, <<>>),
quic_hkdf:extract(Hash, Salt, SharedSecret).
%% @doc Derive handshake secret for psk_ke (PSK-only, no DHE).
%% RFC 8446 ยง7.1: when (EC)DHE is not used, the IKM is a zero-vector
%% of the negotiated hash's length.
-spec derive_handshake_secret_psk_only(binary()) -> binary().
derive_handshake_secret_psk_only(EarlySecret) ->
Salt = derive_secret(EarlySecret, <<"derived">>, <<>>),
IKM = <<0:?HASH_LEN/unit:8>>,
quic_hkdf:extract(Salt, IKM).
%% @doc Derive handshake secret for psk_ke with cipher-specific hash.
-spec derive_handshake_secret_psk_only(atom(), binary()) -> binary().
derive_handshake_secret_psk_only(Cipher, EarlySecret) ->
Hash = cipher_to_hash(Cipher),
HashLen = hash_len(Hash),
Salt = derive_secret(Hash, EarlySecret, <<"derived">>, <<>>),
IKM = <<0:HashLen/unit:8>>,
quic_hkdf:extract(Hash, Salt, IKM).
%% @doc Derive master secret from handshake secret.
%% master_secret = HKDF-Extract(
%% Derive-Secret(handshake_secret, "derived", ""),
%% 0)
-spec derive_master_secret(binary()) -> binary().
derive_master_secret(HandshakeSecret) ->
Salt = derive_secret(HandshakeSecret, <<"derived">>, <<>>),
IKM = <<0:?HASH_LEN/unit:8>>,
quic_hkdf:extract(Salt, IKM).
%% @doc Derive master secret with cipher-specific hash.
-spec derive_master_secret(atom(), binary()) -> binary().
derive_master_secret(Cipher, HandshakeSecret) ->
Hash = cipher_to_hash(Cipher),
HashLen = hash_len(Hash),
Salt = derive_secret(Hash, HandshakeSecret, <<"derived">>, <<>>),
IKM = <<0:HashLen/unit:8>>,
quic_hkdf:extract(Hash, Salt, IKM).
%%====================================================================
%% Traffic Secrets (RFC 8446 Section 7.1)
%%====================================================================
%% @doc Derive client handshake traffic secret.
%% client_handshake_traffic_secret = Derive-Secret(
%% handshake_secret, "c hs traffic", ClientHello...ServerHello)
-spec derive_client_handshake_secret(binary(), binary()) -> binary().
derive_client_handshake_secret(HandshakeSecret, TranscriptHash) ->
derive_secret(HandshakeSecret, <<"c hs traffic">>, TranscriptHash).
%% @doc Derive client handshake traffic secret with cipher-specific hash.
-spec derive_client_handshake_secret(atom(), binary(), binary()) -> binary().
derive_client_handshake_secret(Cipher, HandshakeSecret, TranscriptHash) ->
Hash = cipher_to_hash(Cipher),
derive_secret(Hash, HandshakeSecret, <<"c hs traffic">>, TranscriptHash).
%% @doc Derive server handshake traffic secret.
%% server_handshake_traffic_secret = Derive-Secret(
%% handshake_secret, "s hs traffic", ClientHello...ServerHello)
-spec derive_server_handshake_secret(binary(), binary()) -> binary().
derive_server_handshake_secret(HandshakeSecret, TranscriptHash) ->
derive_secret(HandshakeSecret, <<"s hs traffic">>, TranscriptHash).
%% @doc Derive server handshake traffic secret with cipher-specific hash.
-spec derive_server_handshake_secret(atom(), binary(), binary()) -> binary().
derive_server_handshake_secret(Cipher, HandshakeSecret, TranscriptHash) ->
Hash = cipher_to_hash(Cipher),
derive_secret(Hash, HandshakeSecret, <<"s hs traffic">>, TranscriptHash).
%% @doc Derive client application traffic secret.
%% client_application_traffic_secret_0 = Derive-Secret(
%% master_secret, "c ap traffic", ClientHello...server Finished)
-spec derive_client_app_secret(binary(), binary()) -> binary().
derive_client_app_secret(MasterSecret, TranscriptHash) ->
derive_secret(MasterSecret, <<"c ap traffic">>, TranscriptHash).
%% @doc Derive client application traffic secret with cipher-specific hash.
-spec derive_client_app_secret(atom(), binary(), binary()) -> binary().
derive_client_app_secret(Cipher, MasterSecret, TranscriptHash) ->
Hash = cipher_to_hash(Cipher),
derive_secret(Hash, MasterSecret, <<"c ap traffic">>, TranscriptHash).
%% @doc Derive server application traffic secret.
%% server_application_traffic_secret_0 = Derive-Secret(
%% master_secret, "s ap traffic", ClientHello...server Finished)
-spec derive_server_app_secret(binary(), binary()) -> binary().
derive_server_app_secret(MasterSecret, TranscriptHash) ->
derive_secret(MasterSecret, <<"s ap traffic">>, TranscriptHash).
%% @doc Derive server application traffic secret with cipher-specific hash.
-spec derive_server_app_secret(atom(), binary(), binary()) -> binary().
derive_server_app_secret(Cipher, MasterSecret, TranscriptHash) ->
Hash = cipher_to_hash(Cipher),
derive_secret(Hash, MasterSecret, <<"s ap traffic">>, TranscriptHash).
%%====================================================================
%% 0-RTT / Early Data (RFC 8446 Section 7.1)
%%====================================================================
%% @doc Derive client early traffic secret.
%% client_early_traffic_secret = Derive-Secret(early_secret, "c e traffic", ClientHello)
%% This is used to encrypt 0-RTT data before the handshake completes.
-spec derive_client_early_traffic_secret(binary(), binary()) -> binary().
derive_client_early_traffic_secret(EarlySecret, ClientHelloHash) ->
derive_secret(EarlySecret, <<"c e traffic">>, ClientHelloHash).
%% @doc Derive client early traffic secret with cipher-specific hash.
-spec derive_client_early_traffic_secret(atom(), binary(), binary()) -> binary().
derive_client_early_traffic_secret(Cipher, EarlySecret, ClientHelloHash) ->
Hash = cipher_to_hash(Cipher),
derive_secret(Hash, EarlySecret, <<"c e traffic">>, ClientHelloHash).
%% @doc Derive early exporter master secret.
%% early_exporter_master_secret = Derive-Secret(early_secret, "e exp master", ClientHello)
-spec derive_early_exporter_master_secret(binary(), binary()) -> binary().
derive_early_exporter_master_secret(EarlySecret, ClientHelloHash) ->
derive_secret(EarlySecret, <<"e exp master">>, ClientHelloHash).
%% @doc Compute PSK binder value for a pre_shared_key extension.
%% RFC 8446 Section 4.2.11.2:
%% binder_key = Derive-Secret(early_secret, "res binder" | "ext binder", "")
%% binder = HMAC(binder_key, Transcript-Hash(Truncated ClientHello))
%% For resumption PSK, use "res binder". For external PSK, use "ext binder".
-spec compute_psk_binder(binary(), binary(), resumption | external) -> binary().
compute_psk_binder(EarlySecret, TruncatedClientHelloHash, Type) ->
compute_psk_binder(sha256, EarlySecret, TruncatedClientHelloHash, Type).
%% @doc Compute PSK binder with cipher-specific hash.
-spec compute_psk_binder(atom(), binary(), binary(), resumption | external) -> binary().
compute_psk_binder(Cipher, EarlySecret, TruncatedClientHelloHash, Type) ->
Hash = cipher_to_hash(Cipher),
Label =
case Type of
resumption -> <<"res binder">>;
external -> <<"ext binder">>
end,
%% binder_key = Derive-Secret(early_secret, label, "")
%% For empty context, derive_secret uses Hash("") as per RFC 8446.
BinderKey = derive_secret(Hash, EarlySecret, Label, <<>>),
%% The binder is a Finished MAC keyed by binder_key (RFC 8446 ยง4.2.11.2:
%% computed "in the same way as the Finished message", ยง4.4.4), so the
%% HMAC key is the finished_key derived from binder_key, not binder_key
%% itself:
%% finished_key = HKDF-Expand-Label(binder_key, "finished", "", Hash.len)
%% binder = HMAC(finished_key, Transcript-Hash(Truncated ClientHello))
FinishedKey = derive_finished_key(Cipher, BinderKey),
crypto:mac(hmac, Hash, FinishedKey, TruncatedClientHelloHash).
%%====================================================================
%% Derive-Secret Function
%%====================================================================
%% @doc Derive-Secret with raw messages (will be hashed).
%% Derive-Secret(Secret, Label, Messages) =
%% HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length)
-spec derive_secret(binary(), binary(), binary()) -> binary().
derive_secret(Secret, Label, Messages) ->
derive_secret(sha256, Secret, Label, Messages).
%% @doc Derive-Secret with specified hash algorithm.
-spec derive_secret(atom(), binary(), binary(), binary()) -> binary().
derive_secret(Hash, Secret, Label, Messages) ->
HashLen = hash_len(Hash),
%% If Messages is already hash-length, assume it's pre-hashed
%% Otherwise, compute Transcript-Hash(Messages) = Hash(Messages)
%% RFC 8446: Even for empty Messages, use Hash("") not empty binary
Context =
case byte_size(Messages) of
HashLen -> Messages;
% Includes empty case: Hash("")
_ -> transcript_hash(Hash, Messages)
end,
quic_hkdf:expand_label(Hash, Secret, Label, Context, HashLen).
%%====================================================================
%% Finished Key and Verify Data (RFC 8446 Section 4.4.4)
%%====================================================================
%% @doc Derive the finished key from a traffic secret.
%% finished_key = HKDF-Expand-Label(BaseKey, "finished", "", Hash.length)
-spec derive_finished_key(binary()) -> binary().
derive_finished_key(TrafficSecret) ->
quic_hkdf:expand_label(TrafficSecret, <<"finished">>, <<>>, ?HASH_LEN).
%% @doc Derive the finished key with cipher-specific hash.
-spec derive_finished_key(atom(), binary()) -> binary().
derive_finished_key(Cipher, TrafficSecret) ->
Hash = cipher_to_hash(Cipher),
HashLen = hash_len(Hash),
quic_hkdf:expand_label(Hash, TrafficSecret, <<"finished">>, <<>>, HashLen).
%% @doc Compute the Finished verify_data.
%% verify_data = HMAC(finished_key, Transcript-Hash(Handshake Context))
-spec compute_finished_verify(binary(), binary()) -> binary().
compute_finished_verify(FinishedKey, TranscriptHash) ->
crypto:mac(hmac, sha256, FinishedKey, TranscriptHash).
%% @doc Compute the Finished verify_data with cipher-specific hash.
-spec compute_finished_verify(atom(), binary(), binary()) -> binary().
compute_finished_verify(Cipher, FinishedKey, TranscriptHash) ->
Hash = cipher_to_hash(Cipher),
crypto:mac(hmac, Hash, FinishedKey, TranscriptHash).
%%====================================================================
%% Transcript Hash
%%====================================================================
%% @doc Compute transcript hash of handshake messages (default SHA-256).
-spec transcript_hash(binary()) -> binary().
transcript_hash(Messages) ->
crypto:hash(sha256, Messages).
%% @doc Compute transcript hash with specified hash algorithm or cipher.
%% Accepts both hash atoms (sha256, sha384) and cipher atoms (aes_128_gcm, aes_256_gcm).
-spec transcript_hash(atom(), binary()) -> binary().
transcript_hash(HashOrCipher, Messages) ->
%% Always go through cipher_to_hash which passes through sha256/sha384 unchanged
Hash = cipher_to_hash(HashOrCipher),
crypto:hash(Hash, Messages).
%% @doc Synthetic `message_hash' handshake message that replaces
%% ClientHello1 in the transcript after a HelloRetryRequest
%% (RFC 8446 ยง4.4.1). `HashClientHello1' is the digest over the
%% complete CH1 handshake message. Both peers prepend the result
%% to the post-HRR transcript.
-spec hrr_transcript_prefix(atom(), binary()) -> binary().
hrr_transcript_prefix(HashOrCipher, HashClientHello1) ->
HashLen = hash_len(cipher_to_hash(HashOrCipher)),
<<254:8, HashLen:24, HashClientHello1/binary>>.
%%====================================================================
%% Cipher to Hash Mapping
%%====================================================================
%% @doc Map cipher suite to corresponding hash algorithm.
-spec cipher_to_hash(atom()) -> atom().
cipher_to_hash(aes_128_gcm) -> sha256;
cipher_to_hash(aes_256_gcm) -> sha384;
cipher_to_hash(chacha20_poly1305) -> sha256;
% Pass-through for hash atoms
cipher_to_hash(sha256) -> sha256;
cipher_to_hash(sha384) -> sha384;
% Default to SHA-256
cipher_to_hash(_) -> sha256.
%%====================================================================
%% ECDHE Key Exchange
%%====================================================================
%% @doc Generate an ECDHE key pair for the specified curve.
%% Returns {PublicKey, PrivateKey}
-spec generate_key_pair(x25519 | x448 | secp256r1 | secp384r1) ->
{binary(), binary()}.
generate_key_pair(Curve) ->
{PubKey, PrivKey} = crypto:generate_key(ecdh, Curve),
{PubKey, PrivKey}.
%% @doc Compute ECDHE shared secret.
%% shared_secret = ECDH(our_private, their_public)
-spec compute_shared_secret(
x25519 | x448 | secp256r1 | secp384r1,
binary(),
binary()
) -> binary().
compute_shared_secret(Curve, OurPrivate, TheirPublic) ->
crypto:compute_key(ecdh, TheirPublic, OurPrivate, Curve).
%%====================================================================
%% Retry Packet Integrity (RFC 9001 Section 5.8)
%%====================================================================
%% RFC 9001 Section 5.8 - Retry Integrity Key and Nonce for QUIC v1
-define(RETRY_INTEGRITY_KEY_V1,
<<16#be, 16#0c, 16#69, 16#0b, 16#9f, 16#66, 16#57, 16#5a, 16#1d, 16#76, 16#6b, 16#54, 16#e3,
16#68, 16#c8, 16#4e>>
).
-define(RETRY_INTEGRITY_NONCE_V1,
<<16#46, 16#15, 16#99, 16#d3, 16#5d, 16#63, 16#2b, 16#f2, 16#23, 16#98, 16#25, 16#bb>>
).
%% RFC 9001 Section 5.8 - Retry Integrity Key and Nonce for QUIC v2
-define(RETRY_INTEGRITY_KEY_V2,
<<16#8f, 16#b4, 16#b0, 16#1b, 16#56, 16#ac, 16#48, 16#e2, 16#60, 16#fb, 16#cb, 16#ce, 16#ad,
16#7c, 16#ba, 16#00>>
).
-define(RETRY_INTEGRITY_NONCE_V2,
<<16#d8, 16#69, 16#69, 16#50, 16#c9, 16#06, 16#79, 16#a4, 16#da, 16#88, 16#7e, 16#ce>>
).
%% @doc Verify the integrity tag of a Retry packet.
%% RFC 9001 Section 5.8:
%% - Retry Pseudo-Packet = <ODCID length> <ODCID> <Retry packet without tag>
%% - Tag = AES-128-GCM(Key, Nonce, AAD=Pseudo-Packet, "")
%% Returns true if the tag is valid, false otherwise.
-spec verify_retry_integrity_tag(binary(), binary(), non_neg_integer()) -> boolean().
verify_retry_integrity_tag(OriginalDCID, RetryPacket, Version) ->
%% The Retry packet includes the 16-byte integrity tag at the end
case byte_size(RetryPacket) >= 16 of
true ->
PacketLen = byte_size(RetryPacket) - 16,
<<PacketWithoutTag:PacketLen/binary, IntegrityTag:16/binary>> = RetryPacket,
ExpectedTag = compute_retry_integrity_tag(OriginalDCID, PacketWithoutTag, Version),
IntegrityTag =:= ExpectedTag;
false ->
false
end.
%% @doc Compute the integrity tag for a Retry packet.
%% Used by servers to generate tags and by clients to verify.
-spec compute_retry_integrity_tag(binary(), binary(), non_neg_integer()) -> binary().
compute_retry_integrity_tag(OriginalDCID, RetryPacketWithoutTag, Version) ->
{Key, Nonce} = retry_integrity_secrets(Version),
ODCIDLen = byte_size(OriginalDCID),
%% Retry Pseudo-Packet per RFC 9001 Section 5.8
PseudoPacket = <<ODCIDLen, OriginalDCID/binary, RetryPacketWithoutTag/binary>>,
%% AEAD encrypt with empty plaintext (tag only)
%% crypto_one_time_aead returns {Ciphertext, Tag} for encryption
{<<>>, Tag} = crypto:crypto_one_time_aead(
aes_128_gcm, Key, Nonce, <<>>, PseudoPacket, 16, true
),
Tag.
%% Get the retry integrity key and nonce for a QUIC version
retry_integrity_secrets(Version) when Version =:= 16#00000001 orelse Version =:= 1 ->
%% QUIC v1 (RFC 9000)
{?RETRY_INTEGRITY_KEY_V1, ?RETRY_INTEGRITY_NONCE_V1};
retry_integrity_secrets(Version) when Version =:= 16#6b3343cf ->
%% QUIC v2 (RFC 9369)
{?RETRY_INTEGRITY_KEY_V2, ?RETRY_INTEGRITY_NONCE_V2};
retry_integrity_secrets(_Version) ->
%% Default to v1 for unknown versions
{?RETRY_INTEGRITY_KEY_V1, ?RETRY_INTEGRITY_NONCE_V1}.
%%====================================================================
%% Internal Functions
%%====================================================================
hash_len(sha256) -> 32;
hash_len(sha384) -> 48;
hash_len(sha512) -> 64.