Feat/pqc hpke - #51
Open
nean-and-i wants to merge 21 commits into
Open
Conversation
ARNIKA_PSK was read with getEnvOrDefault and never validated. Unset, it made
the envelope HMAC key SHA-256(""), a publicly computable constant: any host
able to reach LISTEN_ADDRESS could inject valid packets, and the same value
seeds master/backup role selection. Arnika started anyway and appeared to work.
Make it mandatory with a 32-byte minimum, and redact it from the startup
config dump, which otherwise wrote the secret to stdout and from there into
journals and log aggregation.
This is a live weakness in current Arnika, independent of the PQC work that
follows, and it matters more once the PQC exchange rides the same envelope:
ARNIKA_PSK is then the sole quantum-safe authentication root.
Breaking for deployments running without a PSK, or with one shorter than 32
bytes. Both were already outside the security model.
Add PacketPQC ('Q') for all PQC key-agreement traffic, and dispatch it in
udpServer to a buffered pqcInbound channel with a non-blocking send, so a slow
or absent PQC consumer can never stall the QKD path. PQC packets get no
PacketAck and are never written to result; they carry their own kind=ack
inside the encrypted payload, so only one new type byte is observable.
Derive the envelope HMAC key per direction. signedPayload covers type,
timestamp and payload only, and both peers derived identical keys from
ARNIKA_PSK, so a packet A->B verified unchanged when reflected back to A.
The label comes from ARNIKA_ID parity, which the two peers are already
required to differ in for role election, so it needs no new configuration and
no extra round trip. This also hardens the existing QKD key-id path.
Note that directional keys change the envelope on the wire: a peer running
this code cannot authenticate one running an older build, so both ends must be
upgraded together.
MLKEM1024-P384 public keys and encapsulations are 1665 bytes each, so they do not fit one datagram. Add an 8-byte header - round, kind, seq, total - carried inside the AES-GCM payload, so the frame kind is not observable on the wire. pqcChunkPayload is 900, which keeps a frame at 1334 bytes once the envelope, base64 and UDP/IPv4 headers are added: inside a 1492-byte PPPoE path, with no IP fragmentation. Reassembly is keyed on (round, kind) for the single active round; frames from any other round are dropped, so there is no garbage to collect. Duplicate and out-of-order frames are handled. The decoder rejects malformed input rather than panicking, including a fuzz target for the audit programme.
Three calls per round against crypto/hpke with MLKEM1024-P384, HKDF-SHA384 and ExportOnly: the initiator generates a per-round key pair, the responder encapsulates and exports, the initiator decapsulates and exports the same 32 bytes. ExportOnly is deliberate - Seal and Open return errors by construction, so the context cannot be misused for message encryption. The round index is bound into the HPKE info, so material from another round derives a different key and the round fails cleanly. Key confirmation is mandatory, not defensive. FIPS 203 ML-KEM uses implicit rejection: decapsulating a malformed ciphertext returns a pseudorandom shared secret rather than an error, so a corrupted encapsulation would leave the peers holding different keys with nothing raised anywhere, surfacing an interval later as an unexplained WireGuard handshake failure. Both peers exchange a round-bound 16-byte HKDF tag and compare in constant time; nothing is published until they match. The test asserts exactly this: corrupt the encapsulation, observe that decapsulation still succeeds, and that confirmation catches the divergence. Note the tag uses stdlib crypto/hkdf, whose Key() takes info as a string and returns an error, rather than the x/crypto New()+io.ReadFull form.
One round per interval, driven by a clock-derived round index so it survives an asymmetric restart; an in-memory counter would deadlock when one peer restarts and the other does not. At most one round is active, and a concurrent start is rejected. Messages are sent with an ack and three retries, mirroring udpClient. HPKE is single-shot, so an exhausted retry budget has no partial state to recover: the round simply fails and the next one proceeds. Nothing is published unless confirmation succeeds, so a failed round leaves the previous key live until maxAge rather than poisoning the tunnel. GetNewKey reads an atomic register rather than a channel: it is called synchronously from setPSK(), must never block, may be called more than once per round, and needs the key's age. Staleness is an error, delegating the decision to the existing IsPQCRequired() branch; no new policy is added. The adapter owns no socket - it takes an inbound channel and a send function - which is what lets the tests run full rounds over a lossy in-memory pipe. At 1% and 5% loss every round completes; at 20% some fail cleanly with nothing published, and none hang. Deviation from the branch plan, which passes a static isMaster bool: the role is taken as a function of the round index, because the protocol document requires it be pinned per round from that index. Arnika's role alternates per interval, so a bool fixed at construction would be wrong for half of them.
Replace the file-based PQC source with the HPKE agreement. getPQCService now builds a PQCHPKERepository, dials the pinned peer for outbound frames and starts the agreement goroutine; frames arrive on the pqcInbound channel that udpServer already fills. The sender socket is dialled, not bound, so it takes an ephemeral source port and adds no listener: LISTEN_ADDRESS remains the only one Arnika serves. Delete repositories/pqc.go, its test and ci/pqc-dummy-keys.sh. PQC_PSK_FILE and its 0600 permission check are gone with them: the key is agreed with the peer and never touches disk, so there is no file to protect. UsePQC() is re-gated on the new PQC_ENABLED, without which the existing "!UsePQC() && IsPQCRequired()" guard would reject every valid configuration. Add PQC_ROUND_INTERVAL, PQC_MAX_KEY_AGE and PQC_ROUND_TIMEOUT, defaulting to INTERVAL, 2 x INTERVAL and INTERVAL/4, and reject a round timeout that is not shorter than the round interval, which would let rounds overlap. services/ and kdf/ are untouched: the key-derivation contract is unchanged and the consumer side of setPSK does not know the source changed. This is a breaking change to the PQC provider. Deployments reading the PQC key via file from an external provider must stop it, drop PQC_PSK_FILE and set PQC_ENABLED=true on both peers together.
Add docs/pqc-hpke.md following the adapter-document structure: at a glance, how the round works and why key confirmation is mandatory, how the module is constructed, host preparation, configuration reference, compile, a worked two-peer run, migration from an external PQC provider, the test matrix and security notes. Update the rest to match: KEYCONTROL.md module index and code map now point at pqc-hpke; README.md and INSTALL.md describe PQC_ENABLED and state that no external PQC daemon and no new port are needed; CODEFLOW.md gains the round state machine. SECURITY.md replaces the PQC_PSK_FILE directory-hardening section with the key agreement's own properties, and records that GHSA-rc6v-5rmx-w5mv's attack surface no longer exists because there is no file. It also adds ARNIKA_PSK rotation guidance for the AES-GCM random-nonce bound, and states plainly that the port is unscannable but not unfingerprintable: the type byte and timestamp are authenticated, not encrypted. PQC_PSK_FILE survives in the docs only where it must - the migration steps and the security note explaining what was removed. A reader migrating needs to be told which variable to drop.
Name the mechanism rather than one implementation of it: the module replaced a reader that took the PQC key via file from an external PQC provider, and the docs and code comments now say that. The acknowledgement in the README credits is unchanged.
The in-band PQC key agreement is the headline addition of the branch but was only described further down the README. Add it to the improvements list, next to the hexagonal architecture entry that makes it possible. Also note the per-direction key separation on the existing peer-authentication entry: it hardens that same envelope, and a reader comparing v1.x and v2.x should see it.
Added `hardenProcess()` to mitigate accidental key material leakage via core dumps, swap, or `/proc` access (Linux-only). Transitioned `ARNIKA_PSK` from `string` to `[]byte` for clearer zeroization at shutdown. Updated docs to highlight runtime/secret erasure behavior and refine key security guarantees.
PQC_ENABLED now defaults to true, so the quantum-secure hybrid is what an upgraded deployment gets without any configuration change; QKD-only becomes the explicit opt-out. Document it everywhere the default was stated: the README configuration table and mode matrix, docs/pqc-hpke.md, INSTALL.md (whose env files now show how to switch it off rather than on), CODEFLOW.md and the SECURITY.md checklist. The TestParse fixture asserted the old default and is updated with it. Two consequences worth stating. Until the first round completes GetNewKey() has nothing to return, so MODE decides: with the default AtLeastQkdRequired that is a warning and a fallback to the QKD key alone. And a peer left with the agreement disabled while its partner has it on contributes no PQC material, with the outcome again decided by MODE.
Two faults in the round lifecycle, both in this file. Scheduling. The first rekey ran before the first round, so GetNewKey() had nothing to return. Run a round immediately on start, and schedule later rounds to finish before their boundary by waking one round timeout early, so a key for that boundary exists rather than being published at it. The startup round only completes if both peers start inside the same interval, so its failure is expected and logged at INFO. This does NOT align the two peers' reads, and the comment says so. Arnika's rekey instant is independent of the round boundary, so the chance a publish lands between the two peers' setPSK calls is the read gap divided by the interval wherever the publish sits - shifting it changes nothing. Closing that needs the peers to agree on which round's key a rekey uses, which touches the core files ground rule 2 names and is left open. Confirmation. The responder published as soon as it had verified the initiator's tag, so one lost confirm left it holding a key the initiator did not have - the divergence the exchange exists to prevent. Acknowledge both tags: the responder publishes only once the initiator has acked its tag, and the initiator keeps answering retries briefly after publishing, so a lost ack costs a retry rather than the round. The residual is irreducible - the final ack is itself unacknowledged - but it now takes an exhausted retry budget rather than one dropped datagram, and the next round reconverges.
The harness can now lose one kind of frame in one direction, which is what the confirmation faults need: dropping every confirm from the responder must fail the round on both sides, and dropping the first confirm-ack must cost a retry rather than the round. Verified the first test discriminates: against the previous implementation it fails with "initiator published=false, responder published=true", the exact one-sided publish being fixed. The startup-round test uses a five-minute interval, so a key appearing within seconds can only have come from the immediate round. Loss profile is unchanged at 5/5, 5/5 and 3/5 rounds for 1%, 5% and 20% frame loss.
The default operation mode becomes the strictest one: both QKD and PQC keys are mandatory and there is no fallback. This changes what a missing PQC key means. Under the previous default it was a warning and a fallback to the QKD key alone; now the interval is failed and the tunnel invalidated with a random PSK. That is what makes the startup round in the preceding commit load-bearing rather than cosmetic - without it the first rekey after every start would invalidate the tunnel. Documented in the README mode matrix and configuration table, in the startup banner example, and in docs/pqc-hpke.md, whose account of what happens before the first round completes was written for the old default and was wrong for this one. CODEFLOW.md and docs/pqc-hpke.md also pick up the acknowledged confirmation exchange and the new scheduling from the preceding commits.
Twenty lines of Arnika log instead of ten, and DEBUG on one node, so a failed PSK comparison shows the rounds and rejections that led to it rather than just the last few lines.
…compile-time backend selection, streamlined runtime configuration constraints, and introduced `ValidateKeySources`. Updated docs for new wiring and build processes.
…ning rounds alone The containerlab run exposed two faults in the round schedule that the unit tests could not reach, because both need two processes starting independently. A write that fails must cost a retry, not the round. Node-A's startup round died three milliseconds in with "write: connection refused": node-B's listener came up sixty-two milliseconds later, and the kernel reported the gap as ICMP port-unreachable. sendFrames returned that straight to the caller, throwing away a round one retry would have completed. Writes are now retried at fifty millisecond spacing, and the startup round waits a short grace first so a peer starting at the same moment has time to bind. A round must never run without its peer. Node-A's startup round failed fast and node-B's failed slowly at its deadline, which left A a whole round ahead: A ran round 357756368 alone, burned its deadline, and the first usable key arrived at :44 instead of :39 - five seconds with the tunnel invalidated, which the strict default MODE makes visible. The target is now recomputed from the clock as the boundary that follows now, so it is in the future by construction and depends on nothing finer than which interval the peer is in. Starting late is preferred to skipping. Skipping read better but made the choice turn on which side of the ideal start instant each peer evaluated: peers milliseconds apart then chose different rounds, and the one running alone lost an interval. A late start costs part of a round's budget; disagreeing costs the round.
…lures Extracting the scheduling decision into pqcNextRound made it testable, and the test immediately failed the first version of the fix: two peers sixty-two milliseconds apart - the exact skew from the containerlab run - chose rounds 357756368 and 357756369. That is what drove the choice to start late rather than skip. The contract is now pinned across a whole interval in ten millisecond steps: the boundary served is always in the future, the wake never sits in the past or after the boundary, and every instant inside one interval maps to the same round, so nothing finer than the interval can make peers disagree. The transient-write test reproduces the startup failure directly, failing the initiator's first two writes with the same connection-refused error.
An end-to-end Arnika run on macOS using real WireGuard: two interfaces on loopback, the bundled KMS simulator, and two Arnika instances rotating the PSK on both ends. No Docker, no containerlab, no Linux. wg and wg-quick come from Homebrew, and wg-quick starts wireguard-go and creates a utun device, so nothing has to stand in for a WireGuard interface. On darwin the profile name is not the interface name; wg-quick records the real device in /var/run/wireguard/<profile>.name, and run.sh reads it the same way ci/show-psk.sh does, then hands it to Arnika as WIREGUARD_INTERFACE. qcicat1.conf and qcicat2.conf are a matched pair on loopback with fixed test keys. Each side's WIREGUARD_PEER_PUBLIC_KEY is read out of the [Peer] block of its own config, so the wiring cannot drift from the templates. The run watches five consecutive rotation cycles, asserting on each that one end produced a new key and the other holds the identical one: a single divergence is what a dead handshake looks like in production, and one rotation would not catch an intermittent one. It also checks that both ends write a PSK at all, that the tunnel carries traffic, and that it still does afterwards. All three processes stream to the console as they run, prefixed a1|, a2| and kms|, with --quiet to turn that off. The simulator needs DEBUG=true for its request and response logging and run.sh sets it; Arnika's [DEBUG] lines are unconditional. Every run ends with a count of rounds, writes, debug lines and warnings per log. Note that the simulator's [RESP] body lines carry the QKD keys it hands out, so its debug output is not safe to paste unread. That is called out in the README. Arnika's own output logs key IDs only, and preshared keys are reported as a sha256 prefix, never printed. Root is unavoidable - wg-quick owns the interfaces and their control sockets - so the script checks for sudo up front and stops with one clear line rather than failing halfway. Teardown runs even on failure.
The reader's lines were the only ones not following Arnika's log shape. They now read like every other subsystem: [INFO] PQC-HPKE[9998] [OK] round 357757632 agreed a fresh PQC key [WARNING] PQC-HPKE[9998] [FAIL] round 357757631 failed: ... The prefix is built in main.go beside PRIMARY, BACKUP and ARNIKA, so it picks up the same per-node colour, and is passed to the constructor as an argument rather than read from a global - the repository package has no access to the node's identity and should not acquire one. Docs that quoted the old line are updated. The local suite greps on "agreed a fresh PQC key", which is unchanged, so it needed no edit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace the file-based PQC key source with a two-message HPKE (RFC 9180) key agreement using
crypto/hpkewith the MLKEM1024-P384 hybrid KEM, carried inside Arnika's existing authenticated UDP envelope on the public link.In scope
pqcKeytosetPSK()once per intervalExplicitly unchanged
kdf/kdf.go— the HKDF combinationwireguardnetlink.go,wireguardmikrotik.go)setPSK()already aborts when the PQC source errors andcfg.IsPQCRequired()is true. This design adds no new policy.