QUIB
© R.A.Sol
HPPR QUIB transport uses a custom crypto layer instead of TLS 1.3.
Primitives:
- Key agreement: secp256k1 ECDH
- Key derivation: BLAKE3
derive_key - QUIC payload encryption: ChaCha20-Poly1305
- QUIC header protection: ChaCha20 (RFC 9001 §5.4.4 pattern)
- MAC: BLAKE3 keyed hash
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:
Repo-NameSeal-ByPHC(optional)FormatTransport(repeated, optional)Command(repeated)
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:
- the client initiates the connection (
connect) - the server accepts the connection (
accept)
All application streams are client-initiated. The HELLO payload is delivered during the handshake, so no server-initiated stream is needed.
- client reads repo metadata from
handshake_data()(the decrypted HELLO) - client derives Session-ID from keying material
- client opens a bidirectional stream and sends commands
- 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:
- Confidentiality: 2^62 QUIC packets
- Integrity: 2^36 decryption failures
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:
- Long headers:
header[0] ^= mask[0] & 0x0f - Short headers:
header[0] ^= mask[0] & 0x1f header[pn_offset .. pn_offset+pn_len] ^= mask[1..1+pn_len]
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 |