Add handshakes.pq.require, and give new paths and responsive peers the PQ attempt

This commit is contained in:
JackDoan committed 2026-10-14 10:18:00 -05:00
1 parent 22cdb0739a
commit 13658b74a0
8 files changed
+344 -14

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
# Post-quantum handshake transport compared with QUIC
Status: analysis, 2026-10-14. Compares the fragment transport in
[pq-handshake.md](pq-handshake.md) with how QUIC makes its handshake reliable. TLS is out of
scope; only the transport mechanics are compared.
## Summary
QUIC's handshake reliability is closed-loop: the receiver acknowledges, and the sender
repairs exactly what is missing. Ours is open-loop: repair symbols are sent up front, and a
timer sends fresh symbols. At handshake sizes (K <= 15) a QUIC-style acknowledgement
shrinks to a 16-bit bitmap, and with an MDS code to a single count. So the real comparison
is forward error correction versus acknowledgements, not QUIC versus us.
FEC is the right core. One piece of feedback is worth adding, initiator to responder and for
msg2 only (see [Worth taking from QUIC](#worth-taking-from-quic)).
## What QUIC does
- **Messages as a stream.** Handshake bytes travel as `CRYPTO` frames (offset and length)
and are reassembled in order. Initial and Handshake packets each have their own packet
number space.
- **Immediate acknowledgements.** Receivers must acknowledge Initial and Handshake packets
without delay. ACK frames list ranges of received packet numbers.
- **Loss detection** (RFC 9002). A packet is lost once 3 later packets are acknowledged, or
after 9/8 of the round-trip time. Lost bytes are resent in new packets.
- **Probe timeout (PTO).** If a whole flight vanishes, a probe timer fires. Before any RTT
sample it starts at about 1 s (333 ms initial RTT plus 4x its variance) and doubles each
time. Both endpoints run it.
- **Amplification limit and anti-deadlock.** The server may send at most 3x what it
received until the client's address is validated. The client must keep probing so a
server stuck at that limit cannot deadlock.
- **Retry.** A stateless round trip to validate the address. `NEW_TOKEN` lets later
connections skip it.
- **Congestion control from the first packet.** The initial window is 10 packets of 1200
bytes.
## Side by side
| | QUIC | pq-handshake |
|---|---|---|
| Receiver feedback | ACK ranges, sent immediately | None; the next handshake message is the only acknowledgement |
| Partial loss | +1 to 2 RTT (ACK, detect, resend) | 0 RTT while at most m symbols are lost |
| Total loss | PTO: about 1 s, then doubling | Next PQ tick: 300 ms, then 1 s (alternating), or 100/300/600 ms (sticky) |
| Spurious retransmit | Wasted bytes | Still useful: fresh repair indexes add to what the receiver holds |
| Reassembly | Every byte offset, in order | Any K of up to 256 symbols |
| Who retransmits | Both sides on their own timers | Initiator on a timer; the responder resends msg2 only when a repeat msg1 arrives (rate limited) |
| State from the first packet | Server buffers and acknowledges partial flights | Capped pre-authentication table that never acknowledges |
| Address validation | Retry, 1 RTT | Cookie under load, 1 RTT |
From the [simulation results](pq-handshake.md#simulation-results): phase 2 at 5% random
loss with no FEC completes on the first attempt 39.8% of the time. Under
acknowledgement-based recovery roughly 60% of handshakes would pay at least one extra round
trip. With FEC, 0.4% wait for a retry. Spending bandwidth to save round trips is the stated
design preference, and open-loop recovery delivers it.
## Where the designs already agree
- **1200-byte floor and the 3x amplification budget**, both taken directly from QUIC.
- **Cookie and Retry.** Both cost one round trip and both can be forged by an on-path
attacker. QUIC's Retry integrity tag uses a fixed, published key.
- **On-path injection.** QUIC's Initial keys are derived from the client's connection ID,
which an observer can read, so QUIC accepts the same handshake-blocking injection
weakness documented for our symbols in
[Injected symbols](pq-handshake.md#injected-symbols).
- **Phase 2 msg3 confirmation.** Resending msg3 until any authenticated packet arrives,
with a `Test` from the responder on completion, is QUIC's handshake confirmation and
`HANDSHAKE_DONE`.
## Worth taking from QUIC
### 1. A msg2 progress probe from the initiator
This is the main gap. Today, recovering a partially lost msg2 means the initiator resends a
full msg1 attempt; the responder decodes it, matches it in the answered-msg1 cache, and
resends msg2. In phase 2 that is about 12 fragments (~13 KB) uploaded just to act as an
acknowledgement, and recovery waits for the 300 ms or 1 s tick.
- The initiator already knows the responder's index: msg2 fragments carry it as `FragID`
(`sendFragmentedResponse` passes `hostinfo.localIndexId`).
- A one-packet probe, "msg2: have n of K", sent to that index would recover in about one
round trip. The responder finds it the way phase 2 plans to find msg3.
- Timing comes for free: msg1 send to first msg2 fragment is an RTT sample. Probe at
roughly RTT/8 plus the repair delay after the first msg2 fragment if decoding has not
completed.
- Forging a probe needs the 32-bit responder index, so only an on-path attacker can, and
the existing 3x budget bounds the reply.
- If no msg2 fragment arrived at all, the initiator keeps today's msg1 resend. That is the
same tail-loss case QUIC's PTO covers.
The reverse direction, the responder reporting progress on msg1, should not be added. It
needs per-entry timers on the pre-authentication path, which is exactly the state QUIC
servers carry and the reason QUIC needs Retry.
### 2. Buffer early data in phase 2
QUIC stacks hold a few packets they cannot decrypt yet. The phase 2 plan drops data that
reaches the responder before msg3 decodes. Because repair fragments trail by
`handshakes.pq.repair_delay` (10 ms), losing one msg3 data fragment drops about 10 ms of
the initiator's first data while the repair is in flight. A small queue on the pending
responder entry, replayed once msg3 verifies, avoids this.
### 3. Burst size across remotes
QUIC handshakes on a single path. `handleOutbound` sends the same `now` and `later` sets to
every known remote and to relays (`StartRelays`).
- Phase 1 is fine.
- In phase 2, four remotes means about 48 packets (~54 KB) leaving one uplink at once,
every PQ tick, about 4x QUIC's initial window.
- The Gilbert-Elliott simulation treats each path independently, so it does not capture
this self-inflicted burst on a constrained uplink such as cellular.
- Options: send the full FEC set only to the most likely remote and a reduced set to the
others, or interleave packets across remotes.
## Not worth taking
- **Packet numbers, ACK ranges, an RTT estimator, loss thresholds, congestion control.**
QUIC pays for this machinery because it also serves a data transport. Nebula carries
packets and leaves reliability to the overlay's own transports. Building it only for
handshakes of at most 15 fragments buys nothing a symbol count cannot.
- **Offset-based reassembly.** It gives up the any-K-of-256 property and makes spurious
retransmits wasteful again.
- **An RTT-adaptive retry timer.** Low value. The fixed 100 ms linear schedule already fires
sooner than QUIC's 1 s first PTO, and a spurious resend costs bandwidth, not correctness.
## Conclusion
Keep FEC as the core and add the msg2 progress probe. That gets QUIC's roughly one round
trip tail recovery without pre-authentication state, and cuts phase 2's retry cost on the
uplink by about 10x. Items 2 and 3 are phase 2 concerns; item 1 also helps phase 1.
+10 -2
View File
@@ -255,9 +255,17 @@ does not downgrade.
- Separate settings for initiating and accepting PQ handshakes, both off by default:
`handshakes.pq.initiate`, `handshakes.pq.accept`. `handshakes.pq.repair_delay` (default
10ms) sets how long repair fragments trail the data fragments.
- `handshakes.pq.require` makes every handshake PQ: it implies `initiate` and `accept`,
never offers a classical attempt, and drops classical msg1s.
- Old nodes silently drop unknown subtypes, so discovery is speculative: an initiator with
`initiate` enabled alternates PQ and classical attempts on successive retries, keeping
both pending, and the first to complete wins.
`initiate` enabled alternates PQ and classical attempts on successive timer retries,
keeping both pending, and the first to complete wins. A path nothing has been sent over
yet gets the PQ attempt first: an early resend triggered by a lighthouse reply is PQ, and
a relay that comes up pushes the PQ attempt through at once, counting as the PQ turn.
Without that, relays set up during the first attempt handed every relayed handshake to
the classical retry (seen on real hosts: 11 of 11). Once a msg2 fragment or cookie reply
arrives for the PQ attempt the peer evidently speaks PQ, and classical stops getting
turns.
- All `handshakes.pq` settings reload without a restart. Handshakes in flight carry on;
the new settings apply to the next attempt and the next packet.
- `handshakes.pq.sticky` (default off): once a peer has completed a PQ handshake, never fall