QUIB

© R.A.Sol

HPPR QUIB transport uses a custom crypto layer instead of TLS 1.3.

Primitives:

No TLS, no X.509, no rust ring. Both peers must run this crypto.

Handshake

Two-message exchange inside QUIC Initial CRYPTO frames. Both messages travel in the Initial encryption space. After ECDH completes, each side writes a single 0x00 confirmation byte into Handshake CRYPTO to signal readiness, then upgrades to Data-space keys.

Message 1 (client to server, Initial CRYPTO)

client_ephemeral_pubkey (33 bytes, SEC1 compressed secp256k1)
client_transport_parameters (variable)

Message 2 (server to client, Initial CRYPTO)

server_ephemeral_pubkey (33 bytes, SEC1 compressed secp256k1)
encrypted_hello_length (2 bytes, big-endian u16)
encrypted_hello (variable, ChaCha20-Poly1305)
server_transport_parameters (variable)

The entire Message 2 is sent in a single CRYPTO frame. The client derives the shared secret from the server’s ephemeral pubkey, then decrypts the hello and parses transport parameters in one step.

Transport parameters use quinn-proto’s wire encoding (TransportParameters::write / TransportParameters::read).

Encrypted HELLO Payload

The server encrypts a HELLO payload into Message 2 using hello_key from the key derivation XOF stream (offset 256, see Key Derivation).

Encryption uses ChaCha20-Poly1305 with a zero nonce (unique key per connection).

Plaintext is a standard Null HELLO packet (per 030-BASIC-COMMANDS.html) without Session-ID. Contains:

When available, Transport headers advertise other active transports.

The encrypted hello includes a 16-byte Poly1305 tag appended to the ciphertext.

Post-handshake stream bootstrap

QUIB keeps standard QUIC endpoint roles:

All application streams are client-initiated. The HELLO payload is delivered during the handshake, so no server-initiated stream is needed.

  1. client reads repo metadata from handshake_data() (the decrypted HELLO)
  2. client derives Session-ID from keying material
  3. client opens a bidirectional stream and sends commands
  4. server accepts streams and dispatches commands

The server uses accept_bi() for all streams. The client uses open_bi() for the command stream and any additional streams.

Session-ID Derivation

Session-ID is session_id from the key derivation XOF stream (offset 288, see Key Derivation).

Output is 32 bytes. Text format is Q#<b64a> (the Q# prefix distinguishes QUIB-derived session-ids from TAI-based ones).

Both sides compute the same value. This replaces the server-generated TAI session-id used on TCP connections.

The 🖧HELLO command remains available as an optional application-level request for capabilities refresh, repo-name lookup, or sealed status queries.

Key Agreement

shared_point = ECDH(client_ephemeral_secret, server_ephemeral_pubkey)
shared_bytes = shared_point.x (32 bytes, big-endian)

Both sides generate fresh ephemeral secp256k1 keypairs per connection.

Key Derivation

All keys for a connection are derived from the ECDH shared secret using a single BLAKE3 XOF stream:

stream = BLAKE3.derive_key_xof("hppr-🖧/quib/keys", shared_bytes)

Stream layout (368 bytes):

Offset Length Name
0 32 hs_c2s_packet
32 32 hs_s2c_packet
64 32 hs_c2s_header
96 32 hs_s2c_header
128 32 c2s_packet
160 32 s2c_packet
192 32 c2s_header
224 32 s2c_header
256 32 hello_key
288 32 session_id
320 12 hs_c2s_iv
332 12 hs_s2c_iv
344 12 c2s_iv
356 12 s2c_iv

Handshake keys (hs_*) protect the Handshake packet space. Data keys protect the 1-RTT (Data) packet space.

Each packet key has a sibling IV derived from the same XOF stream. The IV is used in nonce construction (see Payload Encryption below).

quinn-proto requires two key upgrades during handshake: Initial to Handshake, then Handshake to Data. Both sides derive keys from this layout and return them via write_handshake. Each side writes a 0x00 confirmation byte into Handshake CRYPTO when returning 1-RTT keys, ensuring the peer receives a Handshake-space packet and completes the transition to Data.

QUIC Key Update

Each direction derives 44 bytes via BLAKE3 XOF from the current packet key:

stream = BLAKE3.derive_key_xof("hppr-🖧/quib/update", current_packet_key)
next_packet_key = stream[0..32]
next_iv          = stream[32..44]

Both key and IV are replaced. Header keys are not updated.

QUIC Payload Encryption

ChaCha20-Poly1305 (RFC 8439).

Nonce construction (12 bytes), following the TLS 1.3 pattern (RFC 9001 §5.3):

nonce = IV XOR (0x00000000 || BE64(packet_number))

The IV is the 12-byte sibling value derived alongside the packet key (from the XOF stream for initial/handshake/data keys, or from the key update XOF for rotated keys). The packet number is encoded as an 8-byte big-endian integer and XORed into the last 8 bytes of the IV.

Tag length: 16 bytes, appended to ciphertext.

Limits:

QUIC Header Protection

ChaCha20 mask generation per RFC 9001 §5.4.4.

Input: 16-byte sample from encrypted payload at offset pn_offset + 4.

counter = LE_u32(sample[0..4])
nonce   = sample[4..16]
mask    = ChaCha20(hp_key, counter, nonce, zeroes)[0..5]

Apply mask:

Sample size: 16 bytes.

Initial QUIC Keys

Before handshake completes, both peers derive identical keys from the destination connection ID using a single BLAKE3 XOF stream:

stream = BLAKE3.derive_key_xof("hppr-🖧/quib/initial", dst_cid)

Stream layout (152 bytes):

Offset Length Name
0 32 c2s_packet
32 32 s2c_packet
64 32 c2s_header
96 32 s2c_header
128 12 c2s_iv
140 12 s2c_iv

Initial keys use the same ChaCha20-Poly1305 and ChaCha20 algorithms as 1-RTT keys. Initial protection is not secret (the CID is on the wire).

Retry Integrity

Retry tags use BLAKE3 keyed MAC with a fixed public key, truncated to 16 bytes.

tag = BLAKE3.keyed_hash(RETRY_KEY, len(orig_dst_cid) || orig_dst_cid || retry_pseudo_packet)[..16]

RETRY_KEY is a fixed 32-byte constant compiled into both peers:

8a 3f c1 7b 52 e6 d9 04  ab 1e 73 f0 28 95 dc 46
b3 67 0a 5d e4 89 f1 3c  7e b2 05 6f d8 a1 43 97

This mirrors RFC 9001’s public retry integrity key. The tag is tamper-detection, not a secret.

HMAC

BLAKE3 keyed hash with 32-byte output.

mac = BLAKE3.keyed_hash(key, data)

Verification compares all 32 bytes.

Token Encryption

Address validation tokens use per-token derived ChaCha20-Poly1305.

Key derivation:

aead_key = BLAKE3.derive_key("hppr-🖧/quib/token-aead", master_key || random_bytes)

Seal and open use a zero nonce. Each token has a unique derived key, making nonce reuse impossible.

Export Keying Material

output = BLAKE3.derive_key(label, shared_bytes || context)

label is interpreted as a UTF-8 string for the BLAKE3 context parameter. Output length is variable via BLAKE3 XOF.

Peer Identity

peer_identity returns the peer’s ephemeral SEC1 compressed secp256k1 public key (33 bytes).

handshake_data returns the decrypted HELLO payload bytes (client side) or the client ephemeral public key (server side).

For clients, the HELLO payload is a standard Null packet containing the server’s repo-vkey and capabilities.

No 0-RTT

Early data is not supported. early_crypto returns None.

Context Strings

All BLAKE3 derive_key / XOF context strings used by this spec:

Context Use
hppr-🖧/quib/keys XOF for all handshake/data keys, IVs, hello key, session-id
hppr-🖧/quib/initial XOF for initial keys and IVs
hppr-🖧/quib/update XOF for key update (packet key + IV per direction)
hppr-🖧/quib/token-aead Token AEAD key derivation