Skip to content

[control] QUIC/mTLS key exchange + PSP-model data plane (APO-648/APO-644)#1

Merged
dilyevsky merged 20 commits into
mainfrom
dsky/apo-648-quic-psp-keyexchange
Jun 15, 2026
Merged

[control] QUIC/mTLS key exchange + PSP-model data plane (APO-648/APO-644)#1
dilyevsky merged 20 commits into
mainfrom
dsky/apo-648-quic-psp-keyexchange

Conversation

@dilyevsky

Copy link
Copy Markdown
Contributor

What & why

ICX previously loaded static 16-byte PSKs from a plaintext INI and fed them straight into AES-128-GCM with an AEAD nonce of 0x00000000‖counter64 and a process-local counter that reset to 0 on restart. Under a long-lived static key that is catastrophic GCM nonce reuse (S1 / APO-644), and there was no key exchange and no forward secrecy (S5 / APO-648) — one key-file or memory disclosure decrypts all recorded past and future traffic.

Both stem from one root cause: a long-lived key with a process-local counter. This PR replaces that with fresh, authenticated, per-session keys established over a QUIC/mTLS control plane, reshaping the key/nonce model to follow the Google PSP architecture while keeping ICX's existing Geneve/AF_XDP data plane on the wire. The whole design stays on FIPS-approved primitives (P-256/384 ECDHE, AES-GCM, SHA-2, HKDF, AES-CMAC/SP 800-108) so it is certifiable under Go 1.24's native FIPS 140-3 module (GODEBUG=fips140=on).

What changed

Control plane (new control/ package)

  • QUIC/mTLS channel with pinned raw peer public keys (RFC 7250-style SPKI pin) → mutual auth; ephemeral ECDHE → forward secrecy. Resumption and 0-RTT are disabled and asserted in code, so every (re)connect is a full handshake with fresh master keys.
  • PSP-faithful key schedule: master keys derived from the TLS exporter (RAM-only), SA keys via SP 800-108 counter-mode KDF with an AES-CMAC PRF (control/cmac.go, control/kdf.go), validated against the PSP spec's published test vectors.
  • Per-direction simplex SA negotiation: each peer allocates and announces its own RX SPI over a QUIC stream; both derive every key locally — no key material on the wire (control/transport.go, control/protocol.go, control/sa.go).
  • Orchestrator wiring the negotiated SAs into the datapath, with make-before-break reconnect (control/cp.go).

Data plane (handler.go, minimal touch)

  • AEAD nonce is now bound to the SPI (nonce[:4] == SPI), turning the formerly-ignored zero bytes into an integrity check and giving per-SA nonce separation.
  • The key-install seam gained a per-direction, key-aware anti-reset guard: it rejects only re-install of the currently-live transmit SA (same SPI and same key), so legitimate fresh-key reconnects with a reset counter are safe.
  • Decap hardening: anti-replay validated only after successful auth (S2 / APO-645) and an empty/short-plaintext guard (S4 / APO-647).

Forwarder

  • Top-level recover() converts datapath-transform panics into frame drops instead of taking down the busy loop.

Migration / cleanup

  • Retired static-INI keying entirely (the last nonce-reuse path): removed --key-file, loadKeysFromINI, the SIGHUP reload, and the Mode/SelectMode machinery. The control plane is now required, fail-closed.
  • Renamed the cipher-suite selector PSPVersion → ICXVersion (PSPv0/PSPv1 → AESGCM128/AESGCM256) since the data plane is not PSP (APO-758); wire bytes and the PSP key-schedule names are unchanged.

FIPS posture

Every primitive is in the Go FIPS 140-3 module: TLS 1.3 ECDHE P-256/384 + AES-GCM + SHA-2 + HKDF, QUIC AES-GCM packet protection, HKDF-SHA-256 master-key seed, SP 800-108/AES-CMAC SA derivation over the validated crypto/aes, and AES-128/256-GCM on the data plane. quic-go contains no crypto — it delegates to crypto/tls — so the validation boundary is just the Go module.

Testing

  • control + root packages: go build, go vet, and full go test green.
  • cli (separate, linux-only module): GOOS=linux go build ./... green.
  • KDF conformance asserted against the PSP spec test vectors; nonce-uniqueness and anti-reset guard covered by handler_test.go / cp_wire_test.go.

Tickets

Resolves APO-644 (S1 nonce reuse), APO-648 (S5 forward secrecy / control plane), APO-645 (replay-after-auth), APO-647 (empty-payload guard), APO-758 (PSPVersion→ICXVersion rename).

🤖 Generated with Claude Code

dilyevsky added 20 commits June 1, 2026 11:43
…ad guard (APO-647)

Two fixes to both decap paths (PhyToVirt and PhyToVirtInPlace):

APO-645 / S2 — verify the AEAD tag BEFORE touching the replay window.
ValidateCounter both checks and ADVANCES the sliding window, and it was
running before rxCipher.Open since the initial commit. That let an off-path
attacker who can spoof the outer 4-tuple advance the window with a forged high
counter, after which the real peer's in-window packets are rejected as "behind
window" — a remote DoS that needs no key. Move the replay check after a
successful Open so only authenticated counters move the window (the same
auth-then-replay order WireGuard uses). Tradeoff: a replayed frame is now
decrypted before being rejected, which is the accepted industry choice.

APO-647 / S4 — drop an empty decrypted payload before reading its version
nibble. The min-size guards read ipPacket[0] first and only then check
< IPvNMinimumSize, so they catch 1..19 bytes but still panic on exactly 0. An
authenticated peer can send a non-OOB frame with an empty plaintext (valid GCM:
empty body + tag). The encap path already had the len==0 guard; this adds the
symmetric one to decap. The differential fuzzer can't reach this (it needs a
valid tag), which is why it slipped the earlier crash-hardening pass.
The processFrames loop holds runtime.LockOSThread and has no recovery, so a
single panicking packet would tear down the whole queue goroutine (and, via
errgroup, the forwarder). The transforms are written to drop malformed frames
rather than panic, so this is a last-resort backstop: it converts any panic
into a frame drop and keeps the queue running. It also contains the GCM
inexact-overlap panic class the in-place aliasing contract depends on. The
recovered-panic log is bounded to a single emission so a crafted frame cannot
flood the logs.
First piece of the key-establishment control plane: the PSP-model key
derivation that turns an authenticated, forward-secret session into
per-Security-Association AEAD keys for the existing Geneve/AF_XDP data plane.

- cmac.go: AES-CMAC (NIST SP 800-38B / RFC 4493) built on crypto/aes so the
  derivation stays inside the Go FIPS 140-3 module.
- kdf.go: the SP 800-108 KDF and PSPVersion codepoints selecting AEAD
  (AES-GCM-128/256) and derived key size.
- kdf_test.go: test vectors.
Establish authenticated, forward-secret per-session keys over a dedicated
QUIC/mTLS control channel, following the PSP security model while keeping the
Geneve/AF_XDP data plane untouched. Resolves the static-PSK / no-forward-secrecy
gap (APO-648/S5); the SPI-bound nonce that closes the GCM reuse window (S1) lands
with the Phase 3 data-plane wiring.

- identity.go: long-term ECDSA P-256 identities, WireGuard-style pinned raw
  public keys (self-signed leaf, SPKI pin), genkey/pubkey-ready marshalling.
- tls.go: TLS 1.3-only config, ALPN icx-ctrl/1, pin verifier via
  VerifyConnection, RFC 8446 exporter -> 32-byte root secret. Under
  fips140=on the suite is AES-GCM + P-256/384 + SHA-2 + HKDF with no custom
  handshake crypto.
- transport.go: QUIC Dial/Listen (Retry-based address validation is the
  handshake-flood defense), no 0-RTT so every handshake is a fresh ECDHE
  (forward secrecy). Root secret -> HKDF master keys; SA negotiation over a
  QUIC stream where each peer announces only its RX SPI and both derive every
  key locally -- no key material on the wire. A successful NegotiateSAs, not a
  successful Dial, is the fail-closed key-confirmation precondition.
- sa.go/protocol.go: SPI allocation (MSB master-key selector, role-partitioned
  so tx key != rx key), SP800-108/AES-CMAC SA derivation, framed SA-offer.
- kdf.go: PSP version now fails closed on unknown codepoints instead of
  defaulting to a 16-byte key.
- _examples/keyexchange: runnable, self-verifying two-peer loopback demo that
  tracks the API; doubles as a smoke test (GODEBUG=fips140=on go run ...).

Adds quic-go v0.59.1 (no crypto of its own; delegates to crypto/tls, so the
FIPS boundary stays the Go module).
… seam (APO-644)

Reshape the data plane toward the PSP nonce model and harden the key-install
path, keeping the Geneve/AF_XDP wire format unchanged.

Nonce = epoch‖counter. The 12-byte GCM nonce was 0x00000000‖counter64; the high
4 bytes are now the 32-bit SPI (the value already carried in the Geneve
key-epoch option). All four TX sites (VirtToPhy, ToPhy, and the in-place
equivalents) write it before Seal; both decap sites verify nonce[:4] == the
selected epoch before Open and drop + count RXDropsSPIMismatch on a mismatch.
GCM already authenticates these bytes (nonce + header are in the tag), so the
check is fast-path tamper-rejection and a precise drop reason rather than new
authenticity. The decap parsers now also require the TxCounter option's declared
12-byte length before trusting its value as the nonce.

Key-install seam. UpdateVirtualNetworkKeys is split into a guarded public seam
over an unguarded installKeys. The seam fails closed on three invariants: a
non-zero epoch (matching control/sa.go's reserved-SPI rule), a strictly
increasing epoch, and rxKey != txKey. The last is load-bearing: both simplex
directions share the single epoch, so the SPI prefix is identical inbound and
outbound and the keys are the only thing separating the two directions' nonce
spaces — equal keys would collide nonces under one key.

Scope / honesty:
- This closes in-process re-install/rotation reuse and adds RX SPI tamper
  rejection. The cross-restart static-key case (counter resets to 0 under a
  reused key) is NOT closed here: the monotonicity guard compares in-memory
  state only. Restart safety comes from ephemeral per-session keys (the control
  plane) or durable epoch/counter state — Phase 4. The doc on
  UpdateVirtualNetworkKeys states this and the per-VNI serialization precondition.
- A single shared epoch cannot represent the control plane's distinct tx/rx
  SPIs; a TODO marks where Phase 4 wires them in.
- Hard flag day: a Phase-3 receiver drops frames from a pre-Phase-3 sender
  (nonce[:4]=0 != epoch). Peers must upgrade together per VNI; preserving the old
  all-zero-prefix format would reopen the reuse hazard.

Tests. The single-handler loopback tests (byte-equivalence, round-trip, fuzz,
bench) use one shared key, which the distinct-key guard now forbids, so they
route through a test-only InstallKeysForTest seam (export_test.go, compiled only
under `go test`). The foreign-package forwarder crypto test is split into two
peer handlers with distinct keys. Adds TestUpdateVirtualNetworkKeysGuards and
TestRXRejectsSPINonceMismatch. The in-place vs cross-buffer fuzz oracle still
agrees in both directions after the nonce binding.

Reviewed adversarially (22-agent workflow): no must-fix; the confirmed findings
(restart-reuse scoping, shared-epoch phrasing, flag-day, reserved-SPI, option
length) are folded in as guards and precise comments above.
Reflect the key-install guards added in f7332ab: rx and tx must differ and the
epoch must strictly increase within a process, so the CLI now refuses an INI
whose rx==tx. Spell out that a restart re-reads the INI at epoch 1 with the TX
counter reset to 0, so restarting against an unchanged key file reuses the
AES-GCM nonce sequence — rotate to fresh keys or use a key-exchange mechanism.
…atapath (APO-648)

Drive the QUIC/mTLS control plane into the data plane. A Tunnel establishes the
session, negotiates the first SA pair fail-closed, and keeps keys fresh: the
initiator rekeys on a timer (reacting to QUIC connection-context loss), the
responder serves rekeys from a blocking accept loop. Any negotiation error is
session-fatal and triggers reconnect-with-backoff (no retry on a dead session);
a rejected install (epoch regression after a reconnect) is logged and swallowed
so the data plane keeps its current keys and fails closed on their expiry.

Bridge the control plane's distinct, role-partitioned per-direction SPIs onto
the handler's single shared epoch via SharedEpoch (the initiator-allocated /
bit30==0 SPI): both peers install the identical scalar epoch while deriving
distinct per-direction keys from the distinct SPIs, so AES-GCM nonce uniqueness
rests on the distinct-key guard. This needs no handler/seam change; carrying the
genuine per-direction SPI on the wire is a future additive UpdateVirtualNetworkSAs.

Add SelectMode (static XOR control-plane, fail-closed, no silent fallback),
CanonicalInitiator (deterministic role election by SPKI order, equal keys
rejected), and Session.Context for prompt session-loss detection.

Tests: SelectMode truth table; CanonicalInitiator ordering + equal-key reject;
SharedEpoch agreement/validation; installSAs PSPv0/16-byte fail-closed and
swallow-on-rejection; two-peer loopback bring-up, rekey, pin-mismatch fail-closed,
and reconnect self-heal; plus an end-to-end control-plane->handler Geneve
round-trip proving SharedEpoch interoperates with zero drops while the naive
per-direction Tx.SPI epoch drops every frame.
…644)

Add a control-plane keying mode alongside the legacy static INI keys, selected
fail-closed: exactly one of --key-file or --identity-key/--peer-key, with no
silent fallback between them. In control-plane mode the CLI loads the identity,
pins the peer key, binds a dedicated control UDP socket, brings the tunnel up
synchronously (the forwarder never starts without keys), and runs the rekey loop
and forwarder under one errgroup sharing a cancel. SIGHUP static reload is gated
to static mode so exactly one installer touches a VNI.

This closes the production APO-644 path: ephemeral, forward-secret per-session
keys mean a restart yields fresh keys, so the static-INI restart nonce-reuse
hazard does not apply in control-plane mode.

New commands: icx genkey (writes a P-256 identity, O_CREATE|O_EXCL 0600, --force
to overwrite) and icx pubkey. New flags: --identity-key, --peer-key,
--control-port, --peer-control-port, --rekey-interval, --require-fips. Reject
--control-port == --port (the XDP filter would blackhole the control plane). Use
SIGINT/SIGTERM for graceful shutdown. README documents the control plane as the
recommended path, marks static keys legacy, and records the startup-ordering and
one-sided-restart caveats.
Durable-epoch seeding makes the data-plane epoch climb monotonically across
rekeys and restarts; AES-GCM nonce uniqueness then rests on each epoch install
starting its own TX counter at zero (nonce = epoch‖counter). That reset already
happens (installKeys stores a fresh transmitCipher), but nothing pinned it, so a
future refactor that carried the counter across installs would silently
reintroduce (key, nonce) reuse.

Document the invariant at the install site and add a white-box test
(TxCounterForTest seam + TestInstallResetsTxCounterPerEpoch) that installs two
epochs and asserts the counter resets to zero and counts from 1 again.
… (APO-648)

The per-session SPI allocator resets on every (re)connect, so the shared
data-plane epoch (the initiator-allocated SPI counter) regressed to 1 and the
survivor's strictly-increasing epoch guard rejected it — breaking a transient
reconnect, a responder restart, and an initiator restart alike.

Carry an epoch high-water forward and seed each new session's allocator above
it. Two layers, both initiator-only (only the initiator's SPI becomes the wire
epoch):

  - In memory, always on: fixes a transient reconnect (a latent rekey-deadlock)
    and a responder restart for free.
  - Durably, opt-in via an EpochStore: a FileEpochStore persists the high-water
    with fsync + atomic-rename + dir-fsync, integrity-protected by an
    HMAC-SHA256 keyed from the identity's fixed-width private scalar and bound to
    the (local, peer) identity pin. Adds initiator-restart recovery; RequireState
    fails closed on a corrupt/unreadable/persistently-failing store.

The margin is applied at seed time so it covers both the durable persistence gap
(<=2 generations) and a torn-exchange reconnect lead (1 generation, where the
responder committed an epoch the initiator never recorded). Persistence runs on
a dedicated coalescing goroutine so a wedged fsync cannot freeze the rekey/
reconnect loop; stop() is grace-bounded for the same reason. SPI-space
exhaustion and a stalled store (under RequireState) are terminal: Run fails
closed instead of hot-looping.

SeedFloor/ErrSPIExhausted on the allocator, Session.SeedRxFloor, and the Tunnel
wiring (load at Bringup, seed in establish, persist on install, fatal paths).
Tests cover the store format/corruption/round-trip, the persister, the
allocator seed, reconnect monotonicity under an enforcing guard, the
torn-exchange lead, the durable restart round-trip, and the responder ignoring
its store.
Expose the control plane's durable epoch high-water: --state-file enables a
FileEpochStore (built role-agnostically; the Tunnel consults it only when this
node is the elected initiator, so set it on both peers), and --require-state
fails closed on a corrupt/unreadable/un-writable store instead of degrading to a
fresh start. --require-state requires --state-file.

Rewrite the README "Restart / reconnect" notes to describe the two recovery
layers (in-memory always-on; durable with --state-file), the both-peers
requirement, and the integrity-tripwire scope (the MAC is not rollback- or
deletion-resistant, which is acceptable because per-session ephemeral keys mean
a rolled-back high-water cannot cause nonce reuse — only a transient outage).
Disable TLS 1.3 session resumption (SessionTicketsDisabled) and fail closed
in newSession on a resumed session or 0-RTT (DidResume / Used0RTT). The data
plane's AES-GCM nonce uniqueness rests on every control session deriving fresh
master keys, so that a per-direction SPI which resets or regresses after a
reconnect is always paired with a never-used key. Forbidding resumption is the
foundation that lets the install seam accept a reset SPI without persisted
state.
…t guard (APO-644)

UpdateVirtualNetworkSAs installs the two PSP simplex SAs under their own
role-partitioned SPIs (rxSPI we decrypt under, txSPI we encrypt to), replacing
the single shared epoch. The AES-GCM nonce is txSPI‖counter with the counter
reset to zero per generation, so nonce uniqueness across rekeys, reconnects and
restarts rests on each generation pairing that from-zero counter with a fresh
per-session key.

Because the control plane now guarantees fresh keys per session, this path does
NOT enforce SPI monotonicity: a reconnect resets the allocator to a low SPI and
that reset SPI must be re-accepted under its fresh key. The only fail-closed
guards are non-zero SPIs, distinct rx/tx keys, and a TX anti-reset check that
rejects re-installing the CURRENTLY-live transmit SA — same SPI AND same key.
The key comparison (new transmitCipher.key) is load-bearing: on a transient
reconnect the allocator can hand back a transmit SPI that collides with the
still-live one, but under a fresh key, which is safe; comparing the SPI alone
would spuriously reject that recovery.

The legacy static-key seam (UpdateVirtualNetworkKeys) keeps STRICT epoch
monotonicity, since static pre-shared keys carry no per-session freshness.
… (APO-648)

The Tunnel now hands the handler the two genuine per-direction SPIs from
NegotiateSAs (rxSPI = our receive SPI, txSPI = the peer's) via the reshaped
SAInstaller, instead of collapsing them onto one SharedEpoch. With fresh master
keys per session (see the fresh-ECDHE enforcement) a reset/regressed SPI is
always paired with a fresh key, so no persisted epoch state is needed for
recovery: the handler accepts the reset SPI and a transient reconnect or a
one-sided restart of either peer recovers seamlessly with zero on-disk state.

Removes the whole receive-SPI high-water layer that the old strict monotonicity
guard forced: SharedEpoch/roleBit, the EpochStore/persister and its fatal
tripwire, SeedFloor/SeedRxFloor, and the in-memory/durable high-water tracking
(control/epochstate.go and its test). Reconnect recovery is now proven by
TestTunnelReconnects / TestTunnelReconnectGuardNeverRejects, whose guardInstaller
mirrors the handler's same-SPI-AND-same-key anti-reset guard.
…-648)

The control-plane installer now forwards both per-direction SPIs to
UpdateVirtualNetworkSAs. Removes the --state-file/--require-state flags and the
durable-epoch wiring (EpochStore/RequireState on the Tunnel): with fresh
per-session keys there is no high-water to persist, so a restart recovers with
no on-disk state.
…(APO-648)

Remove the --state-file/--require-state flag documentation and rewrite the
"Restart / reconnect" section: recovery is now seamless and symmetric with no
persisted state to manage, because every (re)connect is a fresh ECDHE handshake
whose fresh keys make a reset per-direction SPI safe.
Remove the legacy static-key mode: the --key-file flag, runStaticKeying, the SIGHUP
INI reload loop, loadKeysFromINI, and the gopkg.in/ini.v1 dependency. The QUIC/mTLS
control plane is now the only keying path — run requires both --identity-key and
--peer-key, fail-closed, and always drives the rekey loop under the errgroup.

This closes the last AES-GCM nonce-reuse path (the residual APO-644 hazard): the static
path re-read the INI at epoch 1 on restart and reset the TX counter under an unchanged
persisted key. With the control plane as the sole installer, every session derives fresh
per-session ECDHE keys, so a reset counter is never paired with a reused key.
…seam

With static-INI keying gone, UpdateVirtualNetworkKeys has no production caller and is no
longer "the static --key-file path". Re-document it as the simple single-epoch manual-
keying seam used by tests and embedders (the control plane installs genuine per-direction
SAs via UpdateVirtualNetworkSAs). The strict epoch-monotonicity and distinct-key guards
are unchanged; the doc now states the caller's cross-restart responsibility for a
manually-supplied key that survives restarts.
The control plane is the only keying mode now, so the Mode enum, its String method, and
SelectMode (which chose between static-INI and control-plane keying) are dead. Remove
them along with TestSelectMode; the CLI validates the required identity/peer keys
directly.
…-758)

PSPVersion / PSPv0 / PSPv1 named a PSP wire attribute icx does not emit — icx
borrows PSP's key schedule but runs no PSP packet format (the data plane is
Geneve + AF_XDP). Rename the local AEAD cipher-suite selector to ICXVersion, with
AESGCM128 / AESGCM256 for the two suites, and the saOffer field to Version
(matching SA.Version). The genuine PSP key-schedule primitives (DeriveSAKey,
MasterKeys, MasterKeyIndex, the SPI bit-layout, the CMAC PRF) keep the PSP name —
they implement the spec and the name is a correct pointer to it.

Pure Go-identifier rename, no byte changes: the on-wire codepoints (0/1), the KDF
label bytes ("Pv0\0"/"Pv1\0"), and the masterKeyInfo domain string are untouched,
and the PSP-spec KDF vector test (TestDeriveSAKey_PSPSpec) still passes.
@linear-code

linear-code Bot commented Jun 15, 2026

Copy link
Copy Markdown

APO-648

APO-644

@dilyevsky dilyevsky merged commit 839a2c1 into main Jun 15, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant