From 197f5e25893b845995853379125f607be18fc4e9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 12:08:48 +0200 Subject: docs: record 11.5.6 spike — QUIC channel binding constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated aioquic 1.3.0 before implementing the QUIC challenge/response, since the binding anchor gates the whole design. No RFC 5705 exporter exists (aioquic.tls.Context has no export_keying_material), so the preferred anchor is unavailable. Certificate access is asymmetric: the server reaches its own cert via the public tls.certificate, but the client can only reach the server's via tls._peer_certificate — a private attribute, behind a QuicConnection that exposes no tls accessor at all. That matters because binding a security check to a private API means an upgrade can remove it silently. Since make_proof() refuses an empty binding (11.5.21), a rename would fail loudly rather than degrade — but only while the refusal path stays strict. Three options recorded with a recommendation: certificate hash via the private attribute with a guard test that fails CI on upgrade, plus pinned aioquic; or bind to pk_node instead, which for QUIC may suffice since signaling is not hub-relayed — but that requires certificate pinning, as the QUIC client currently does not verify the TLS certificate at all; or upstream an exporter. No implementation started: the challenge/response needs both protocol sides, test updates in two files and multiple verification cycles. Co-Authored-By: Claude Opus 5 --- devel-phases-next.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/devel-phases-next.md b/devel-phases-next.md index 687e4f1..902f01d 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -614,7 +614,45 @@ phase reduces the node to two paths and brings both to the same standard. |---|---|---|---| | 11.5.4 | Extract `meshbay_common/handshake.py`: JWT verify → `scope == "user"` → denylist → **mandatory** `group_id` in claims → group hosted → GEK challenge → proof verify → ack | **C6**, M1, M9 | Single implementation; `webrtc_server.py` and `quic_server.py` contain no JWT logic of their own | | 11.5.5 | Both transports call it; test parametrized over `[webrtc, quic]` | C6 | A test that adds a step to the handshake fails for any transport that skips it | -| 11.5.6 | **Spike:** channel binding for QUIC. No DTLS fingerprint exists — bind to the QUIC server certificate hash as the analogue (`sha256(server_cert) ‖ sha256(client_cert)`); prefer an RFC 5705 TLS exporter if `aioquic` can expose one | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections | +| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections | + +#### 11.5.6 spike results (aioquic 1.3.0) + +**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the +preferred anchor is unavailable. + +**Certificate access is asymmetric and partly private:** + +| Side | Path to the server certificate | API status | +|---|---|---| +| Server | `tls.certificate` | public attribute | +| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` | + +`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is +`protocol._quic.tls._peer_certificate` — two levels of private API. + +**The risk this creates.** Binding a security check to a private attribute means an +aioquic upgrade can remove it silently. A channel binding that silently becomes +unavailable is the worst failure mode: 11.5.21 already established that the handshake +must *refuse* rather than degrade, so a rename would turn every QUIC connection into a +hard failure — noisy, but only if the refusal path is right. If it were ever made +tolerant, it would turn into a silent loss of MitM detection. + +**Options for the implementer, in order of preference:** + +1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in + `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and + non-None on a live connection — so an upgrade fails CI rather than production. Keep + `make_proof()` refusing an empty binding. +2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from + WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the + hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient + connection authentication on its own — but note the QUIC client currently does **not** + verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer), + so this option must be paired with pinning, or the TLS layer authenticates nobody. +3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5. + +Recommendation: option 1 with the guard test, and open option 3 upstream. ### C — Mutual authentication -- cgit v1.2.3