Skip to content

Feat/pqc hpke - #51

Open
nean-and-i wants to merge 21 commits into
mainfrom
feat/pqc-hpke
Open

Feat/pqc hpke#51
nean-and-i wants to merge 21 commits into
mainfrom
feat/pqc-hpke

Conversation

@nean-and-i

Copy link
Copy Markdown
Contributor

Summary

Replace the file-based PQC key source with a two-message HPKE (RFC 9180) key agreement using crypto/hpke with the MLKEM1024-P384 hybrid KEM, carried inside Arnika's existing authenticated UDP envelope on the public link.

In scope

  • Two-message HPKE key agreement between two Arnika peers, ML-KEM-1024 + P-384
  • Delivery of a fresh 32-byte pqcKey to setPSK() once per interval
  • Transport over the existing public UDP port — no new listener, nothing scannable
  • No key material on disk, no certificates, no third-party dependencies

Explicitly unchanged

  • QKD key retrieval (KMS / ETSI GS QKD 014 repositories)
  • kdf/kdf.go — the HKDF combination
  • WireGuard injection (wireguardnetlink.go, wireguardmikrotik.go)
  • Fail-closed policy — setPSK() already aborts when the PQC source errors and
    cfg.IsPQCRequired() is true. This design adds no new policy.

nean-and-i and others added 21 commits September 6, 2026 19:44
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.
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.

2 participants