Compare commits

..

59 Commits

Author SHA1 Message Date
riccardom
82ea56abe2 [client] pqkem: gate controller re-offer to kick the KEM exactly once
When the controller receives the responder's (KEM-less) offer it replies with
its own KEM offer instead of answering, so the only transaction that brings the
tunnel up is the one that also carries the PSK. Guard that reply with
ShouldSendBootstrapOffer so it fires only when no exchange is in flight: without
it, every responder offer triggered another offer (an offer-per-offer runaway).
The whole behaviour is isolated to the KEM path (config.PQ != nil); non-PQ
connections answer as before.
2026-08-06 18:04:30 +02:00
riccardom
1c47243da8 [client] pqkem: make signalling bootstrap idempotent through awaitingRekey
The controller sends its KEM offer both on its own guard event and in reply
to the responder's offer. SignalOffer was idempotent only while awaiting the
answer; once the answer arrived (awaitingRekey) a repeat call started a fresh
exchange with a different PSK, desyncing the two peers (one on the old PSK,
one on the new) so WireGuard derived misaligned transport keys and dropped all
data. Treat awaitingRekey as in-flight too and return the same offer.
2026-08-06 18:04:07 +02:00
riccardom
c7e888180a Introduces a forced imparity on MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".

Otherwise imagine two offers in parallel driving two answers at the same time

   A                   B
   | <----B-OFFER----- |
   | -----A-OFFER----> |
   |                   |
   |                   |
   ---------------------------------
  |****** ICE + WG Handshake ****** |
   ---------------------------------
   |                   |
   | <----B-ANSWER---- |
   | -----A-ANSWER---> |

PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.

So we impair the two nodes and only the offer of one of the two (the controller/initiator)
is allowed to progress and drive the answer (and carry the KEM material).

If a responder initiates an offer, we redo the offer towards it. This is oK
since the ICEworker don't treat offer/answers differently.
2026-08-06 15:42:27 +02:00
riccardom
191c0b94c7 Revert "Introduces a forced WG handshake on initial MLKEM bootstrap."
This reverts commit b5a72eca65.
2026-08-06 15:15:43 +02:00
riccardom
b5a72eca65 Introduces a forced WG handshake on initial MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".

Otherwise imagine two offers in parallel driving two answers at the same time

   A                   B
   | <----B-OFFER----- |
   | -----A-OFFER----> |
   |                   |
   |                   |
   ---------------------------------
  |****** ICE + WG Handshake ****** |
   ---------------------------------
   |                   |
   | <----B-ANSWER---- |
   | -----A-ANSWER---> |

PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.

So we impair the two nodes and only the offer of one of the two (the controller/initiator)
carries the KEM material.

This means that if the responder OFFER/ANSWER comes first, when the controller/initiator's one
completes (and the genuine PSK is shared between A and B, we need to force a new WG handshake with
the proper keys.
2026-08-06 14:42:07 +02:00
riccardom
7699697231 Anticipates PSK before WG does handshake so it finds it to set it 2026-08-05 16:39:55 +02:00
riccardom
fe530cf25a Skip default port send in signal proto 2026-08-04 19:17:44 +02:00
riccardom
915266ffff Allow non strict mode 2026-08-04 19:17:44 +02:00
riccardom
6d0f6c4bb8 Adds PQ connection tests 2026-08-04 19:17:44 +02:00
riccardom
45865ec80f Remove obvious comments; leave only the why of things 2026-08-04 19:17:44 +02:00
riccardom
171d22e4e8 Be more explicit on names that is a fake key to ensure we don't communicate with others in strict mode 2026-08-04 19:17:44 +02:00
riccardom
975479d4b8 Adds test to validate compromised keys are not accepted 2026-08-04 19:17:44 +02:00
riccardom
4d812a1353 Prioritize Kem over RP 2026-08-04 19:17:44 +02:00
riccardom
47e22ad6b8 pqkem: concurrency tests (recovery + race)
- RecoversViaResignalAfterDataPathBreak: a data-path rotation that can no longer
  converge raises OnRekeyFailed, and re-bootstrapping over signalling resyncs both
  peers on a fresh PSK even while the data path stays broken.
- ConcurrentRekeysNoRace: hammers the single-lock state machine with concurrent
  rotation clocks from many goroutines (run with -race) and asserts no split-brain
  via a final deterministic bootstrap.
2026-08-04 19:17:44 +02:00
riccardom
52a9843f95 pqkem: recover from persistent rekey failure by re-bootstrapping over signal
OnRekeyFailed now re-runs the KEM bootstrap over Signal (conn.RequestReoffer ->
handshaker.SendOffer) instead of only logging: a fresh signalling offer starts a new
exchange that overwrites the stalled PSK on both sides, resyncing after a persistent
data-path desync. Chosen over a responder-side awaitingAck revert (which fights the
confirm-less ack timing) and a full tunnel teardown (heavier). The tunnel stays up on
the previous PSK meanwhile since Signal is independent of the broken data path.
2026-08-04 19:17:44 +02:00
riccardom
cb73d7e959 Discriminate initial from rekey failure 2026-08-04 19:17:44 +02:00
riccardom
75c7a3bdca pqkem: strict (fail-closed) mode + wire status Quantum resistance
Strict mode (NB_PQ_MLKEM_STRICT, default off) closes the initial PQ-vulnerable
window (NET-1408): when enabled, conn.presharedKey programs a per-conn random
sentinel PSK until the ML-KEM exchange derives the real one, so no session can form
on a non-PQ key (the real PSK is pushed via SetPresharedKey once it converges).
Default stays opportunistic.

Also surface PQ status: the peer 'Quantum resistance' flag (RosenpassEnabled) is now
true when an ML-KEM PSK has been derived for the peer, not only for Rosenpass.
2026-08-04 19:17:44 +02:00
riccardom
97d99c9187 pqkem: rotate PSK in kernel mode instead of skipping
The idle-gate reads LastActivities, which only tracks per-peer data in userspace;
in kernel mode it is empty, so the gate treated every kernel peer as idle and
disabled data-path rotation entirely. Detect the bind via IsUserspaceBind and, in
kernel mode, report zero activity age (always 'active') so rotation runs on every
rekey. Lazy back-to-idle is already limited in kernel; the eBPF WG-activity
detection will later supply a real signal that excludes handshake/pqkem traffic.
2026-08-04 19:17:44 +02:00
riccardom
3e0f1ec083 pqkem: derive PSK with HKDF-SHA256
Replace the raw SHA-256 concat combiner with HKDF-SHA256 (crypto/hkdf, Go 1.24):
IKM = ML-KEM_ss || X25519_ss (draft-ietf-tls-ecdhe-mlkem order), salt = the
domain-separation label, info = full transcript (offer || answer) || canonicalised
peer identities. Keeps the transcript + identity binding while using a proper KDF.
2026-08-04 19:17:44 +02:00
riccardom
3877c84266 Don't rotate PQ keys if data path is idle for ~90s (less than a WG handhshake time 2026-08-04 19:17:44 +02:00
riccardom
44231f576e Adds log tracepoints
- Add a trace slog level (NB_PQ_MLKEM_LOG_LEVEL=trace) and move the verbose
  per-exchange lifecycle logs (offer/answer/PSK/ack/rotation) to it, so debug
  stays quiet and troubleshooting is opt-in.
- Stop logging the raw preshared key; drop the temporary pqkem-dbg OnRemoteOffer/
  OnRemoteAnswer probes.
- Demote the per-handshake conn log to trace.
2026-08-04 19:17:44 +02:00
riccardom
d2aeb2cc1f Fixes second answer dropped (the one carrying the PQ KEM data)
Prevents dropping concurrent answer / offer carrying the PQ ML-KEM data
2026-08-04 19:17:44 +02:00
riccardom
f8d58f23e5 Renames SetRemotePort to SetRemoteAddr 2026-08-04 19:17:44 +02:00
riccardom
341fb04d82 pqkem: clock data-path PSK rotation from WireGuard handshakes
Source OnDataPathRekeyed from the WGWatcher's per-handshake callback
(onWGCheckSuccess), which fires only on a fresh handshake, and OnDataPathDown
from the handshake-timeout path. A fresh handshake clocks the next chained
KEM exchange pushed over the data-path UDP transport.
2026-08-04 19:17:44 +02:00
riccardom
65454f23dc pqkem: register data-path endpoint from signalling
Learn the peer's data-path endpoint from the signalling offer/answer: its WG
overlay IP combined with the advertised pq UDP port (SetRemotePort -> AddPeer).
Registering here is safe before the tunnel is up because sends only ever fire
once it is (clocked by OnDataPathRekeyed). RemovePeer is wired at peer teardown
(engine.removePeer), not on transient disconnect.
2026-08-04 19:17:44 +02:00
riccardom
c937df5aea pqkem: apply derived PSK at WG peer-config time (pull) + keep push for rekey 2026-08-04 19:17:44 +02:00
riccardom
135d3f5e7f pqkem: carry KEM offer/answer over the signalling exchange 2026-08-04 19:17:44 +02:00
riccardom
d959adfb25 pqkem: dedicated slog logger via NB_PQ_MLKEM_LOG_LEVEL 2026-08-04 19:17:44 +02:00
riccardom
4874f5ae51 Homogeneous logs prefix 2026-08-04 19:17:44 +02:00
riccardom
622be61b11 Bit of renaming
peer -> peerAddrs
have types for remoteID and localID
t.Close log error
Manager SetTransport -> Start
2026-08-04 19:17:44 +02:00
riccardom
1b1ddebd4c Typo 2026-08-04 19:17:44 +02:00
riccardom
56ec125afc Race fix 2026-08-04 19:17:44 +02:00
riccardom
5f81c1e5b8 Makes Transport just a UDP socket.
Manager owns maps for remoteID <-> remote UDP addr
Engine talks to manager only
2026-08-04 19:17:44 +02:00
riccardom
d91b7f53a0 Adds transport 2026-08-04 19:17:44 +02:00
riccardom
610f87c4cb Communicate the port over the signal exchange 2026-08-04 19:17:44 +02:00
riccardom
69d986276a Ensure iface is up and with overlay ip assigned to get a valid UDP port 2026-08-04 19:17:44 +02:00
riccardom
a26aeeee47 Adds real callback setter for PSK on ready 2026-08-04 19:17:44 +02:00
riccardom
bdd1683fed Initializes PQ ML-KEM manager 2026-08-04 19:17:44 +02:00
riccardom
f669147a76 Adds no-op Transports and callbacks 2026-08-04 19:17:44 +02:00
riccardom
8dcbd4ed8e Added enabled env var 2026-08-04 19:17:44 +02:00
riccardom
273101bfcb Adds MLKEM Payload placeholder to client internals 2026-08-04 19:17:44 +02:00
riccardom
2187760567 Invert order of keys as per draft 2026-08-04 19:17:44 +02:00
riccardom
09315d754a Protocol update 2026-08-04 19:17:44 +02:00
riccardom
608234e947 Removes confirm. Uses next offer to deliver confirmation/ack of previous round
We clock the next Offer initiation to the OnDataPathRekeyed, so we have 2 minutes
ahead of us to do our attempts and stuff before to give up.
On failure, we will know because we will not receive a new answer.. but more importantly
the wg handshake will fail :D
2026-08-04 19:17:44 +02:00
riccardom
475b3e9790 Leave signal offer/answer as a pull/push operation not as an actual transport 2026-08-04 19:17:44 +02:00
riccardom
baea17efb6 Assume two transports: initial "signal" (control plane) one (no data path established yet) + data path one
Define OnDataPathRekeyed event to transition from control plane path to data plane path over the WG tunnel.

Keep confirm ALWAYS on NEW established WG tunnel (posthandshake with rekeying). We keep an active method
irrelevant of the WG handshake (we might decide that the indirect wg handshake is sufficient in the future).

Optimistic commit on responder(when sending answer), while on initiator we set it on getting the answer
2026-08-04 19:17:44 +02:00
riccardom
6cef0d8bee Epurate wg refs 2026-08-04 19:17:44 +02:00
riccardom
37ce7b4f6f Collapse Driver and Manager in one.
- Have just one manager => one lock
 - Session state is needed in driver to => we have it available now.
 - Isomorphically align to rosenpass components and functionality

File	Role	                                  rosenpass equivalent
kem.go	primitive pure X25519MLKEM768	          crypto.go/handshake
message.go	Offer/Answer/Confirm + Encode/Decode  messages.go
manager.go	Manager stateful, single lock	      server logic
callbacks.go	WGCallbackHandler (seam output)	  Handler
Transport (interfaccia)	seam trasporto pluggable  Conn
2026-08-04 19:17:44 +02:00
riccardom
a20ad0158e [squash] isInitial and answered can be inferred without state variables 2026-08-04 19:17:44 +02:00
riccardom
cdcf58a14c Manages convergence 2026-08-04 19:17:44 +02:00
riccardom
c3667140ea Models reattempts 2026-08-04 19:17:44 +02:00
riccardom
ca356353b1 Reuse answer, don't calculate again 2026-08-04 19:17:44 +02:00
riccardom
3f15ec14c1 Adds driver to glue together manager and outside world 2026-08-04 19:17:44 +02:00
riccardom
4ad3517869 Defines event callbacks 2026-08-04 19:17:44 +02:00
riccardom
f67332f062 Admits possible errors on Encode 2026-08-04 19:17:44 +02:00
riccardom
87c610b44b Bench key material boilerplate time/allocs
CGO_ENABLED=1 go test ./client/internal/pqkem/ -run '^$' -bench . -benchmem 2>&1 | grep -E "Benchmark|ns/op|PASS|ok" | head -20

BenchmarkX25519Keygen-14    	   33795	     34966 ns/op	     224 B/op	       5 allocs/op
BenchmarkX25519ECDH-14      	   33855	     33973 ns/op	      32 B/op	       1 allocs/op
BenchmarkMLKEMKeygen-14     	   21817	     67778 ns/op	    8200 B/op	       2 allocs/op
BenchmarkMLKEMEncaps-14     	   29918	     43235 ns/op	    1216 B/op	       2 allocs/op
BenchmarkMLKEMDecaps-14     	   26048	     56291 ns/op	      64 B/op	       2 allocs/op
PASS
ok  	github.com/netbirdio/netbird/client/internal/pqkem	9.751s
Shell cwd was reset to /home/riccardo/Desktop/Personal/netbirdio/netbird
2026-08-04 19:17:44 +02:00
riccardom
a9946d7ffe Pure mechanics of manager 2026-08-04 19:17:44 +02:00
riccardom
942e3a1924 Messages definition 2026-08-04 19:17:44 +02:00
riccardom
fd08e8aaf7 ML-KEM encapsulate/decapsulate module 2026-08-04 19:17:44 +02:00
61 changed files with 2760 additions and 1523 deletions

View File

@@ -475,132 +475,6 @@ jobs:
path: dist/
retention-days: 3
release_ui_gtk3:
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
# the binary links against the oldest supported glibc.
runs-on: ubuntu-22.04
outputs:
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Parse semver string
id: semver_parser
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Set snapshot flag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: |
echo "flags=--snapshot" >> $GITHUB_ENV
- name: Set build vars
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
echo "x-${{ github.repository }}"
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
else
echo "x-${{ github.repository }}"
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
fi
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
# Restore-only from the release_ui cache written by trusted runs; the
# module cache is identical (same go.sum) and stale build-cache
# entries just miss.
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-ui-go-releaser-
- name: Install modules
run: go mod tidy
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Set up pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
version: 11
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Decode GPG signing key
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
run: |
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
- name: Install wails3 CLI
# Version derived from go.mod so the binding generator always matches
# the wails runtime the binary links against.
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
# not exist on ubuntu-22.04.
run: |
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
dnf install -y -q rpm-sign curl >/dev/null 2>&1
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
rpm --import /tmp/rpm-pub.key
echo "=== Verifying RPM signatures ==="
for rpm_file in /dist/*.rpm; do
[ -f "$rpm_file" ] || continue
echo "--- $(basename $rpm_file) ---"
rpm -K "$rpm_file"
done
'
- name: Clean up GPG key
if: always()
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui_gtk3
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-gtk3
path: dist/
retention-days: 3
release_ui_darwin:
runs-on: macos-latest
outputs:
@@ -814,7 +688,7 @@ jobs:
comment_release_artifacts:
name: Comment release artifacts
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
needs: [release, release_ui, release_ui_darwin]
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
permissions:
contents: read
@@ -826,14 +700,12 @@ jobs:
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
with:
@@ -856,7 +728,6 @@ jobs:
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
];
@@ -913,7 +784,7 @@ jobs:
trigger_signer:
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines

View File

@@ -257,15 +257,6 @@ jobs:
with:
persist-credentials: false
- name: Verify fresh-install session cookie key hardening
run: |
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
- name: Verify Dex retirement notice
run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then

View File

@@ -1,131 +0,0 @@
version: 2
env:
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
project_name: netbird-ui
before:
hooks:
# Bindings are gitignored; regenerate before the frontend build so
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
# build without them).
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
# not exist on ubuntu-22.04.
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
builds:
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
- id: netbird-ui-gtk3
dir: client/ui
binary: netbird-ui
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
- gtk3
archives:
- id: linux-gtk3-arch
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
nfpms:
# Same package_name as the GTK4 packages -- the two are mutually-exclusive
# alternatives served from separate repo paths (see uploads below); a given
# distro points at exactly one of them. The file names must still differ:
# the Debian pool is shared storage keyed by file name, so a default-named
# gtk3 .deb would overwrite the stable one.
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb_gtk3
package_name: netbird-ui
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- deb
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird (>= 0.75.0)
- libgtk-3-0
- libwebkit2gtk-4.1-0
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm_gtk3
package_name: netbird-ui
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- rpm
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird >= 0.75.0
- (gtk3 or libgtk-3-0)
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
# The GTK4 UI job shares project_name, so the default checksum file name would
# collide with it on the shared GitHub release.
checksum:
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
changelog:
disable: true
uploads:
# The gtk3 packages reuse the netbird-ui package name, so they live in
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
# legacy distros point their repo config at.
- name: debian-gtk3
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
username: dev@wiretrustee.com
method: PUT
- name: yum-gtk3
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT

317
AGENTS.md
View File

@@ -14,22 +14,20 @@ in this file, not duplicated there.
## Contents
- [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Quick reference](#quick-reference)
- [Structure](#structure)
- [Where to look](#where-to-look)
- [Security](#security)
- [Agent conventions](#agent-conventions)
- [Repo-wide principles](#repo-wide-principles)
- [Type safety](#type-safety)
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
- [Error handling](#error-handling)
- [Comments](#comments)
- [Testing](#testing)
- [Pitfalls](#pitfalls)
- [Commits, PRs, releases](#commits-prs-releases)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
- [Contents](#contents)
- [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Quick reference](#quick-reference)
- [Structure](#structure)
- [Where to look](#where-to-look)
- [Repo-wide principles](#repo-wide-principles)
- [Error handling](#error-handling)
- [Comments](#comments)
- [Testing](#testing)
- [Pitfalls](#pitfalls)
- [Commits, PRs, releases](#commits-prs-releases)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
## STOP and ask the user before
@@ -159,125 +157,11 @@ netbird/
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
| End-to-end tests | `e2e/` |
## Security
### Never fail open
When a security check — access control, an IP restriction, an auth decision —
hits an error such as an unparseable value, an unavailable lookup, or a state it
does not recognize, it must **deny**. Never skip the check or allow the request
through because the check itself failed, and make the `default` and unknown cases
of a security-related `switch` deny rather than fall through.
### Daemon RPC input is untrusted
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
privilege boundary: treat every field as untrusted input rather than as something
the UI or CLI validated on the way in.
When you add or change an RPC, ask what the handler does with caller input while
running as root. If the answer touches a filesystem path, a URL or host, or a
privileged state change, it needs a gate **in the handler** — a check in the client
that normally calls it is not a check at all.
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
not own — so a symlink or hardlink aimed at a root-only file is rejected.
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
allow only known hosts for unprivileged callers. Prefer a lexical host
allowlist plus TLS verification over "resolve the host, then reject private
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
check time, attacker IP at connect time), while a name allowlist has no IP
check to race. Never accept `http://` where `https://` is expected.
- **A privileged state change** (SSH root login, management URL, deregistration)
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
named-pipe client token — and never from an RPC field. When
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
do not fall back to treating the caller as the transport peer.
## Agent conventions
### Three networking modes
Where packets actually flow depends on the mode the agent is running in. The
three are not interchangeable, so establish which one a change applies to — and
what it should do in the other two — before you write it.
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
client programs kernel facilities but never sees the traffic itself.
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
— exit nodes and network routes — goes through the userspace forwarder, which
terminates the connection and re-establishes it over OS sockets. Used on
platforms without kernel WireGuard® or when the user opts out.
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
networking. The forwarder does all routing by stitching userspace sockets, and
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
Used where the process cannot create a TUN device, such as the embedded client
(`client/embed/`) and the WASM build.
### The overlay interface is not "WireGuard"
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
reference there is simply inaccurate and rots as the transports change.
### IPv6 is a soft feature
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
it as soft rather than a requirement:
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
not on raw state fields, and skip the v6 path when the host has no v6 rather
than returning an error.
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
it, keeping the v4 path working.
- Never let a missing v6 break v4. Fail-closed is for security checks; a
capability mismatch skips the v6 work and carries on.
### Environment variables
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
than comparing strings inline, so an unexpected value is logged instead of
silently meaning false:
```go
const EnvDisableFeature = "NB_DISABLE_FEATURE"
func isDisabledByEnv() bool {
val := os.Getenv(EnvDisableFeature)
if val == "" {
return false
}
disabled, err := strconv.ParseBool(val)
if err != nil {
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
return false
}
return disabled
}
```
### Validating against protocol specs
When a change depends on what a protocol actually mandates, read the specification
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
summary, and check that you have the current RFC — the widely cited one for a
protocol is often superseded. Cite the section, not just the document, so a
reviewer can jump straight to the rule.
## Repo-wide principles
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
you touch, and delete imports, helpers, and parameters your refactor orphaned.
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
touch, and delete imports, helpers, and parameters your refactor orphaned.
Exception: unused parameters in shared code may be consumed by builds outside
this repository — do not remove them, ask instead.
3. **Function comments are mandatory for exported functions**, written as full
@@ -291,12 +175,9 @@ reviewer can jump straight to the rule.
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
comments, commit messages, and PR descriptions alike.
8. **Concurrency: do a two-pass race analysis after every change** that touches
shared state, including reads of existing maps and slices. Guard them with a
mutex (or an atomic or channel where that fits better), keep critical
sections short, and run `go test -race` on the touched packages. See
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
to check for.
8. **Concurrency: do a two-pass race analysis after every change** that adds
shared state. Guard maps and slices with a mutex, keep critical sections
short, and run `go test -race` on the touched packages.
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
add the counterpart or a build-tagged fallback for the others.
@@ -304,93 +185,6 @@ reviewer can jump straight to the rule.
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
keep peer IPs and hostnames out of logs above debug level.
## Type safety
**No bare primitives for domain concepts.** A `string` parameter for an account
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
because the compiler cannot catch the swap. Declare the type once and use it
throughout, converting only at the boundaries where data enters or leaves —
protobuf, gRPC, HTTP, an external library.
```go
type ServiceID string
type AccountID string
// Internal: typed all the way through
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
// Proto boundary: convert once, on the way in and on the way out
svcID := ServiceID(mapping.GetId())
req.ServiceId = string(svcID)
```
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
boundary and pass the typed value inward.
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
compared mapped address silently fails to match those rules.
- **Ports are `uint16`** internally; use `int` only where a library forces it and
convert immediately.
- **Enums are a typed string with constants**, so the valid set is discoverable
and a typo fails to compile.
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
string`) rather than an alias (`type serviceID = string`) — an alias silently
accepts bare strings.
## Concurrency and lifecycle
Beyond the mutex hygiene in the principles above, check for these failure
modes.
- **Never read a struct field inside a goroutine** when another goroutine may nil
or reassign it. Pass the value as a parameter, or capture it into a local before
launching. This matters most when `Stop()` nils a field without waiting for the
goroutine to finish.
```go
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
m.Start(ctx, ifaceName)
}(iface.Name())
```
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
need out from under the lock, release it, then wait.
```go
func (m *Manager) Stop() {
m.mu.Lock()
cancel, done := m.cancel, m.done
m.mu.Unlock()
if cancel != nil {
cancel()
<-done
}
}
```
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
nil cancel — and must release the state they guarded. Clear maps and caches;
a cancelled goroutine holding a live map still pins that memory. Note that a
nil map only panics on writes; reads and iteration behave like an empty map,
so where post-close use must be rejected, check the stopped flag explicitly.
- **Publish coupled state only after every fallible step succeeds.** When several
fields form an invariant, build them into locals and assign them to the receiver
at the end. Assigning as you go leaves the object half-initialized when a later
step fails, so a readiness predicate reports ready while a coupled field is nil.
If an earlier step already had an external side effect — a created chain, an
opened handle, an inserted rule — roll it back before returning the error.
- **Clean up what you own on constructor error paths.** Once a constructor has
started something, every later error path must undo it: cancel a goroutine and
wait for it to exit, stop a ticker, close a watcher. The object is never
returned, so its `Close` will never run.
- **A failed `Start` must undo everything it started.** When a component brings up
several subsystems in sequence — connection manager, watchers, routing, DNS,
flow, persisted state — a failure partway through has to tear down the ones
already running, not just close the handle the error came from. Put the
already-started guard *before* that teardown path, so a rejected second `Start`
cannot dismantle the one that is running.
## Error handling
Use single-assignment form when the error is only needed inside the `if`:
@@ -454,45 +248,6 @@ Log the errors you choose not to act on:
- Close errors may be ignored for read-only operations; log them at debug for
writes.
**Do not log and return the same error.** It gets reported twice, from two places,
and the second reader cannot tell whether it happened once or twice. Return it and
let the caller decide. The exception is an API handler that has already written a
response. Internal helpers return errors rather than logging and swallowing them.
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
interface is not nil, so `err != nil` is true and callers take the failure path on
success. Return the error only where it is actually set:
```go
if _, err := conn.Write(buf); err != nil { // good
return err
}
return nil
```
**Accumulate with `multierror` when an operation should continue past individual
failures** — teardown, cleanup, or setup where partial success is acceptable.
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
still see a plain nil on full success:
```go
func (m *Manager) Cleanup() error {
var merr *multierror.Error
for _, r := range m.resources {
if err := r.Close(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
}
}
return nberrors.FormatErrorOrNil(merr)
}
```
| Scenario | Approach | Why |
| --------------------- | --------------------- | ----------------------------------------- |
| Cleanup / teardown | Accumulate | Clean up as much as possible |
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
| Setup with partial OK | Accumulate | Degraded operation is still useful |
## Comments
Comment the **why**, never the **what**. Default to no comment, and add one only
@@ -514,14 +269,10 @@ checksum = updateChecksum(checksum, oldPort, newPort)
### Length budget
Neither of these is linter-enforced, so they are conventions the surrounding code
mostly follows rather than hard limits:
- **Around 90 characters per line.** Wrap the comment rather than running well past
it.
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
on exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body rarely should.
- **90 characters per line.** Wrap the comment, do not run past it.
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body may not.
The budget is a smell detector, not a rule to game. Do not compress a needed
explanation into cryptic shorthand to fit — if a block of code needs more than
@@ -578,19 +329,6 @@ up, and the 250-character budget does not apply to them.
otherwise.
- **Message guidance:** optional for `NoError`/`Error`; always give context for
comparison, boolean, and collection assertions.
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
reason you expect* — a test that fails for an unrelated reason proves nothing —
then apply the fix and confirm it passes. Add the thin surrounding cases while
you are there.
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
then `os.Unsetenv`.
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
parent function returns, running its `defer`s, while parallel subtests are
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
there, but `t.Cleanup` works in both cases.
- **Explanatory comments in tests are welcome.** Describe the scenario being set
up; the comment budget below does not apply to them.
```go
server, err := StartTestServer()
@@ -642,8 +380,7 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
than replacing it with your own summary: describe the change, link the issue,
tick the checklist honestly (including "ran locally" and "single purpose"),
and complete the documentation section. Do not tick a box you have not
verified, and do not delete rows that do not apply — the docs gate in CI reads
that section and fails when it is missing.
verified, and do not delete rows that do not apply.
- **Keep the PR description short.** Under 1000 words on top of the template's
own text, and usually far less — a few paragraphs. Reviewers read the diff;
@@ -702,12 +439,6 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
on their own. Propose that split to the user rather than opening one large PR
and hoping.
Prefer GitHub's stacked pull requests for such a sequence, rather than
hand-managing base branches: open each PR against the branch below it instead of
`main`, so every PR's diff shows only its own change. Merging a layer retargets
the PRs above it, and branch protections and required checks on the base branch
still apply to each one.
- **User-facing changes need a docs PR** in
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
description.

View File

@@ -2,8 +2,8 @@
// its wg interface into firewalld's "trusted" zone. This is required because
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
// versions, which returns EPERM to any other process that tries to insert
// rules into them. Trusting the interface makes firewalld itself add the
// accept rules to its own chains instead.
// rules into them. The workaround mirrors what Tailscale does: let firewalld
// itself add the accept rules to its own chains by trusting the interface.
package firewalld
// TrustedZone is the firewalld zone name used for interfaces whose traffic

View File

@@ -50,6 +50,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/pqkem"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
@@ -197,6 +198,10 @@ type Engine struct {
// rpManager is a Rosenpass manager
rpManager *rosenpass.Manager
// pqkemManager runs the ML-KEM post-quantum PSK exchange (gated by NB_ENABLE_PQ_MLKEM).
// It owns the data-path transport and peer endpoint routing.
pqkemManager *pqkem.Manager
// syncMsgMux is used to guarantee sequential Management Service message processing
syncMsgMux *sync.Mutex
@@ -555,7 +560,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
publicKey := e.config.WgPrivateKey.PublicKey()
e.flowManager = netflow.NewManager(e.wgInterface, publicKey[:], e.statusRecorder)
if e.config.RosenpassEnabled {
// Rosenpass and ML-KEM are mutually exclusive. ML-KEM (NB_ENABLE_PQ_MLKEM) takes precedence
if e.config.RosenpassEnabled && pqkem.Enabled() {
log.Warnf("rosenpass and ML-KEM post-quantum are mutually exclusive; ML-KEM is enabled, so rosenpass is disabled")
}
if e.config.RosenpassEnabled && !pqkem.Enabled() {
log.Infof("rosenpass is enabled")
if e.config.RosenpassPermissive {
log.Infof("running rosenpass in permissive mode")
@@ -644,6 +653,30 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.rpManager.SetInterface(e.wgInterface)
}
// Start the ML-KEM PQ manager after the interface is up so its dedicated UDP
// transport can bind on the WG overlay IP.
if pqkem.Enabled() {
tr, pqErr := newPQTransport(e.config.WgAddr.IP)
if pqErr != nil {
log.Errorf("pqkem: transport bind failed, exchange disabled: %v", pqErr)
} else {
cbHandler := pqCallbackHandler{
wg: e.wgInterface,
// On a persistent rekey failure, re-bootstrap the KEM over Signal: a
// fresh signalling offer starts a new exchange that overwrites the
// stalled PSK on both sides, recovering from a data-path desync.
reoffer: func(remoteKey string) {
if conn, ok := e.peerStore.PeerConn(remoteKey); ok {
conn.RequestReoffer()
}
},
}
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, pqkem.NewLogger())
e.pqkemManager.Start(tr)
log.Infof("pqkem: enabled (udp port %d on overlay %s)", e.pqkemManager.LocalPort(), e.config.WgAddr.IP)
}
}
// if inbound conns are blocked there is no need to create the ACL manager
if e.firewall != nil && !e.config.BlockInbound {
e.acl = acl.NewDefaultManager(e.firewall)
@@ -907,6 +940,10 @@ func (e *Engine) removePeer(peerKey string) error {
e.connMgr.RemovePeerConn(peerKey)
if e.pqkemManager != nil {
e.pqkemManager.RemovePeer(pqkem.RemoteID(peerKey))
}
err := e.statusRecorder.RemovePeer(peerKey)
if err != nil {
log.Warnf("received error when removing peer %s from status recorder: %v", peerKey, err)
@@ -1893,6 +1930,10 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
},
ICEConfig: e.createICEConfig(),
}
if e.pqkemManager != nil {
config.PQ = pqHandshaker{mgr: e.pqkemManager}
config.PQStrict = pqkem.Strict()
}
serviceDependencies := peer.ServiceDependencies{
StatusRecorder: e.statusRecorder,
@@ -2076,6 +2117,10 @@ func (e *Engine) close() {
_ = e.rpManager.Close()
}
if e.pqkemManager != nil {
e.pqkemManager.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
@@ -2852,6 +2897,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
Version: msg.GetBody().GetNetBirdVersion(),
RosenpassPubKey: rosenpassPubKey,
RosenpassAddr: rosenpassAddr,
MlkemPayload: msg.GetBody().GetMlkemPayload(),
MlkemPort: int(msg.GetBody().GetMlkemPort()),
RelaySrvAddress: msg.GetBody().GetRelayServerAddress(),
RelaySrvIP: relayIP,
SessionID: sessionID,

View File

@@ -3,6 +3,7 @@ package peer
import (
"context"
"fmt"
"math"
"net"
"net/netip"
"runtime"
@@ -26,6 +27,7 @@ import (
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
@@ -74,6 +76,39 @@ type RosenpassConfig struct {
PermissiveMode bool
}
// PQHandshaker attaches post-quantum ML-KEM material to signalling offers/answers and
// feeds received material back. It is implemented by the engine over the pqkem
// manager and is nil when the PQ exchange is disabled. remoteKey is the peer's
// WireGuard public key.
type PQHandshaker interface {
// OfferPayload returns the KEM offer to embed in an outgoing offer (nil if this
// peer is not the KEM initiator) and the local PQ data-path port to announce.
OfferPayload(remoteKey string) (payload []byte, port int)
// ShouldSendBootstrapOffer reports whether, as the controller, we should reply to a
// received responder offer with our own KEM offer (true only when no exchange is
// already in flight — so we kick the KEM once and ignore further offers).
ShouldSendBootstrapOffer(remoteKey string) bool
// AnswerPayload processes a received KEM offer (nil if absent) and returns the KEM
// answer to embed in the outgoing answer (nil if none) and the local PQ port.
AnswerPayload(remoteKey string, recvOffer []byte) (payload []byte, port int)
// OnAnswer feeds a received KEM answer (nil if absent).
OnAnswer(remoteKey string, recvAnswer []byte)
// PSK returns the peer's latest derived post-quantum PSK to program at WG
// peer-config time (the pull path). ok is false until one has been derived.
PSK(remoteKey string) (wgtypes.Key, bool)
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling:
// its WG overlay IP with the announced pq UDP port (port 0 means the peer omitted
// it and is on the default port).
SetRemoteAddr(remoteKey string, addr netip.AddrPort)
// OnDataPathRekeyed signals a fresh WireGuard handshake for the peer; it clocks the
// next chained PSK rotation pushed over the data path. sinceActivity is how long
// ago the peer last exchanged real user data, so the rotation can be skipped for
// idle tunnels.
OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration)
// OnDataPathDown signals the peer's tunnel went down.
OnDataPathDown(remoteKey string)
}
// ConnConfig is a peer Connection configuration
type ConnConfig struct {
// Key is a public key of a remote peer
@@ -91,6 +126,12 @@ type ConnConfig struct {
RosenpassConfig RosenpassConfig
// PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled.
PQ PQHandshaker
// PQStrict fails closed: block peer traffic until the ML-KEM PSK is established,
// instead of letting the tunnel come up classically and upgrading to PQ later.
PQStrict bool
// ICEConfig ICE protocol configuration
ICEConfig icemaker.Config
}
@@ -149,6 +190,11 @@ type Conn struct {
// pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
// transport is up.
pendingFirstPacket []byte
// pqStrictSentinelKey is a per-conn random sentinel PSK used in PQ strict mode to
// fail closed: it is programmed until the real ML-KEM PSK is derived, so no session
// can form on a non-PQ key. Per-conn random so two strict peers never match by chance.
pqStrictSentinelKey *wgtypes.Key
}
// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
@@ -206,6 +252,14 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
metricsRecorder: services.MetricsRecorder,
}
if config.PQ != nil && config.PQStrict {
if k, err := wgtypes.GenerateKey(); err != nil {
connLog.Errorf("pqkem: failed to generate strict-mode sentinel key, strict fail-closed disabled for this peer: %v", err)
} else {
conn.pqStrictSentinelKey = &k
}
}
return conn, nil
}
@@ -670,6 +724,22 @@ func (conn *Conn) onGuardEvent() {
}
}
// RequestReoffer sends a fresh signalling offer for the peer, re-running the
// post-quantum bootstrap over Signal. Used to recover from a persistent data-path
// rekey failure: a new exchange overwrites the stalled PSK on both sides. No-op if the
// connection is not open yet.
func (conn *Conn) RequestReoffer() {
conn.mu.Lock()
h := conn.handshaker
conn.mu.Unlock()
if h == nil {
return
}
if err := h.SendOffer(); err != nil {
conn.Log.Debugf("pqkem: recovery re-offer failed: %v", err)
}
}
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -681,6 +751,10 @@ func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection")
if conn.config.PQ != nil {
conn.config.PQ.OnDataPathDown(conn.config.Key)
}
// Close the active connection based on current priority
switch conn.currentConnPriority {
case conntype.Relay:
@@ -723,7 +797,7 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by
ConnStatus: conn.evalStatus(),
Relayed: conn.isRelayed(),
RelayServerAddress: relayServerAddr,
RosenpassEnabled: isRosenpassEnabled(rosenpassPubKey),
RosenpassEnabled: conn.quantumResistant(rosenpassPubKey),
}
err := conn.statusRecorder.UpdatePeerRelayedState(peerState)
@@ -742,7 +816,7 @@ func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time)
RemoteIceCandidateType: iceConnInfo.RemoteIceCandidateType,
LocalIceCandidateEndpoint: iceConnInfo.LocalIceCandidateEndpoint,
RemoteIceCandidateEndpoint: iceConnInfo.RemoteIceCandidateEndpoint,
RosenpassEnabled: isRosenpassEnabled(iceConnInfo.RosenpassPubKey),
RosenpassEnabled: conn.quantumResistant(iceConnInfo.RosenpassPubKey),
}
err := conn.statusRecorder.UpdatePeerICEState(peerState)
@@ -946,6 +1020,35 @@ func (conn *Conn) onWGCheckSuccess() {
conn.mu.Lock()
conn.wgTimeouts = 0
conn.mu.Unlock()
// A fresh WireGuard handshake clocks the post-quantum PSK rotation. Pass how long
// ago the peer last exchanged real user data (keepalives excluded) so the pqkem
// manager can skip rotation on idle tunnels — rotating then would push data-path
// traffic that keeps the lazy connection artificially active.
if conn.config.PQ != nil {
conn.config.PQ.OnDataPathRekeyed(conn.config.Key, conn.dataActivityAge())
}
}
// dataActivityAge returns how long ago the peer last exchanged real user data
// (WireGuard keepalives excluded), per the same LastActivities signal the
// lazy-connection inactivity monitor uses. It reports a very large duration when no
// activity has ever been recorded, so the peer is treated as idle.
//
// In kernel mode there is no per-peer data-activity signal (LastActivities is
// userspace-only), so we cannot tell active from idle. We report zero — always
// "active" — so PSK rotation is not disabled in kernel mode. Lazy back-to-idle is
// already limited there; the eBPF WG-activity detection (future) will supply a real
// signal that excludes handshake/pqkem traffic.
func (conn *Conn) dataActivityAge() time.Duration {
if !conn.config.WgConfig.WgInterface.IsUserspaceBind() {
return 0
}
last, ok := conn.config.WgConfig.WgInterface.LastActivities()[conn.config.WgConfig.RemoteKey]
if !ok {
return time.Duration(math.MaxInt64)
}
return monotime.Since(last)
}
// recordConnectionMetrics records connection stage timestamps as metrics
@@ -987,6 +1090,23 @@ func (conn *Conn) AgentVersionString() string {
}
func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
// Post-quantum: once the ML-KEM exchange has derived a PSK for this peer, program
// it here so the peer's next WireGuard handshake adopts it. Applied at peer-config
// time (bootstrap / reconnect); steady-state rotation is pushed separately.
if conn.config.PQ != nil {
if psk, ok := conn.config.PQ.PSK(conn.config.Key); ok {
return &psk
}
if conn.config.PQStrict && conn.pqStrictSentinelKey != nil {
// Fail closed: program a non-matching sentinel so no session forms on a
// non-PQ key until the ML-KEM exchange derives the real PSK (pushed via
// SetPresharedKey once it converges). "pending" — turns into a "stuck"
// warning from the manager if the exchange keeps failing (see raiseFailure).
conn.Log.Debugf("pqkem: strict mode — no PQ PSK yet, blocking peer traffic until the ML-KEM exchange converges")
return conn.pqStrictSentinelKey
}
}
if conn.config.RosenpassConfig.PubKey == nil {
return conn.config.WgConfig.PreSharedKey
}
@@ -1026,6 +1146,21 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool {
return remoteRosenpassPubKey != nil
}
// quantumResistant reports whether the peer's tunnel is post-quantum protected, for
// the status "Quantum resistance" field: either Rosenpass (the remote advertised a
// Rosenpass key) or the ML-KEM exchange (a PQ PSK has been derived for this peer).
func (conn *Conn) quantumResistant(remoteRosenpassPubKey []byte) bool {
if isRosenpassEnabled(remoteRosenpassPubKey) {
return true
}
if conn.config.PQ != nil {
if _, ok := conn.config.PQ.PSK(conn.config.Key); ok {
return true
}
}
return false
}
func evalConnStatus(in connStatusInputs) guard.ConnStatus {
// "Relay up and needed" — the peer uses relay and the transport is connected.
relayUsedAndUp := in.peerUsesRelay && in.relayConnected

View File

@@ -0,0 +1,86 @@
package peer
import (
"net/netip"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// fakePQ is a minimal PQHandshaker: only PSK is exercised by presharedKey, the rest
// are no-op stubs to satisfy the interface.
type fakePQ struct {
psk wgtypes.Key
ok bool
}
func (f fakePQ) OfferPayload(string) ([]byte, int) { return nil, 0 }
func (f fakePQ) ShouldSendBootstrapOffer(string) bool { return false }
func (f fakePQ) AnswerPayload(string, []byte) ([]byte, int) { return nil, 0 }
func (f fakePQ) OnAnswer(string, []byte) {}
func (f fakePQ) PSK(string) (wgtypes.Key, bool) { return f.psk, f.ok }
func (f fakePQ) SetRemoteAddr(string, netip.AddrPort) {}
func (f fakePQ) OnDataPathRekeyed(string, time.Duration) {}
func (f fakePQ) OnDataPathDown(string) {}
// TestConn_presharedKey_PQ covers the post-quantum branch of presharedKey across the
// three states that matter: a derived PSK is programmed, and — before one exists —
// strict mode blocks with a sentinel while non-strict falls open to the ordinary key.
func TestConn_presharedKey_PQ(t *testing.T) {
derivedPSK, err := wgtypes.GenerateKey()
require.NoError(t, err)
nbPSK, err := wgtypes.GenerateKey()
require.NoError(t, err)
newConn := func() *Conn {
return &Conn{
Log: log.WithField("peer", "pq-test"),
config: ConnConfig{
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
WgConfig: WgConfig{PreSharedKey: &nbPSK},
RosenpassConfig: RosenpassConfig{},
},
}
}
t.Run("derived PSK is programmed", func(t *testing.T) {
for _, strict := range []bool{false, true} {
c := newConn()
c.config.PQ = fakePQ{psk: derivedPSK, ok: true}
c.config.PQStrict = strict
if strict {
sentinel, _ := wgtypes.GenerateKey()
c.pqStrictSentinelKey = &sentinel
}
got := c.presharedKey(nil)
require.NotNil(t, got)
require.Equal(t, derivedPSK, *got, "the derived PQ PSK must win (strict=%v)", strict)
}
})
t.Run("non-strict falls open to the ordinary key before a PSK exists", func(t *testing.T) {
c := newConn()
c.config.PQ = fakePQ{ok: false}
c.config.PQStrict = false
got := c.presharedKey(nil)
require.NotNil(t, got, "non-strict must not block")
require.Equal(t, nbPSK, *got, "non-strict falls through to the NetBird PSK, not a sentinel")
})
t.Run("strict blocks with the per-conn sentinel before a PSK exists", func(t *testing.T) {
sentinel, err := wgtypes.GenerateKey()
require.NoError(t, err)
c := newConn()
c.config.PQ = fakePQ{ok: false}
c.config.PQStrict = true
c.pqStrictSentinelKey = &sentinel
got := c.presharedKey(nil)
require.NotNil(t, got)
require.Equal(t, sentinel, *got, "strict must return the blocking sentinel")
require.NotEqual(t, nbPSK, *got, "the sentinel must not be the ordinary key")
})
}

View File

@@ -39,6 +39,16 @@ type OfferAnswer struct {
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
// MlkemPayload carries the post-quantum X25519MLKEM768 handshake message
// (pqkem-framed offer on an OFFER, answer on an ANSWER) that seeds the
// WireGuard PSK. Opaque here — the pqkem library frames and parses it. Nil
// when the peer does not run the ML-KEM PQ exchange.
MlkemPayload []byte
// MlkemPort is the peer's ML-KEM PQ service UDP port (bound on its WG overlay
// IP) where data-path rekey messages are sent. Zero when not running the exchange.
MlkemPort int
// relay server address
RelaySrvAddress string
// RelaySrvIP is the IP the remote peer is connected to on its
@@ -81,14 +91,20 @@ type Handshaker struct {
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
h := &Handshaker{
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
remoteOffersCh: make(chan OfferAnswer),
remoteAnswerCh: make(chan OfferAnswer),
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
// Buffered by 1: the single Listen goroutine can be busy handling an offer
// (sendAnswer does a blocking signal send) exactly when the matching answer
// arrives on the other channel. Unbuffered, that answer would hit the
// non-blocking send's default and be dropped — fatal for the post-quantum
// exchange, which needs the answer to converge. A 1-slot cushion lets it wait
// until Listen loops back, without ever blocking the signal receiver.
remoteOffersCh: make(chan OfferAnswer, 1),
remoteAnswerCh: make(chan OfferAnswer, 1),
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
@@ -120,6 +136,42 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
// Post-quantum: the KEM material rides only the controller's offer, so the two
// peers derive a single shared PSK (a bidirectional KEM would yield two
// different PSKs and WireGuard would pick misaligned ones). If we are the
// controller and receive the responder's (KEM-less) offer, we must NOT let
// that KEM-less transaction drive the connection: it would bring WireGuard up
// on a pre-PQ key before the KEM completes (the race). Instead we reply with
// OUR offer, which carries the KEM, so the only transaction that establishes
// the tunnel is the one that also derives the PSK. This also guarantees a
// responder-initiated wake still triggers a KEM offer (no stuck responder).
// The re-offer reuses our stable ICE session id, so the peer dedups repeats.
if h.config.PQ != nil && isController(h.config) {
// Reply with our KEM offer exactly once, to kick the exchange. If one is
// already in flight, ignore this offer — re-sending on every responder
// offer would be an offer-per-offer runaway.
if h.config.PQ.ShouldSendBootstrapOffer(h.config.Key) {
h.log.Debugf("pqkem: controller received a responder offer, replying with our KEM offer instead of an answer")
if err := h.sendOffer(); err != nil {
h.log.Errorf("failed to send KEM offer in response to peer offer: %s", err)
}
} else {
h.log.Debugf("pqkem: controller received a responder offer but a KEM exchange is already in flight, ignoring")
}
continue
}
// Derive+store the KEM PSK (inside sendAnswer's AnswerPayload) BEFORE bringing
// up the connection: the relay/ICE workers configure the WG endpoint, which
// pulls the PSK for the first handshake. Notifying them first would race the
// KEM exchange and hand the first handshake a not-yet-derived key.
if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -127,11 +179,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
if h.iceListener != nil && h.RemoteICESupported() {
h.iceListener(&remoteOfferAnswer)
}
if err := h.sendAnswer(); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
@@ -142,6 +189,15 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
// Feed the KEM answer (derive+store PSK) BEFORE bringing up the connection so
// the WG endpoint config pulls the real PSK for the first handshake instead of
// racing ahead of the KEM exchange.
if h.config.PQ != nil {
h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload)
}
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -156,6 +212,18 @@ func (h *Handshaker) Listen(ctx context.Context) {
}
}
// pqRegisterEndpoint feeds the post-quantum handshaker the peer's data-path endpoint
// (its WG overlay IP plus the advertised pq UDP port) learned from a remote offer/answer.
func (h *Handshaker) pqRegisterEndpoint(remotePort int) {
if h.config.PQ == nil || remotePort < 0 || remotePort > 65535 || len(h.config.WgConfig.AllowedIps) == 0 {
return
}
// remotePort may be 0 (the peer omitted it, meaning the default port); the adapter
// resolves 0 to DefaultPort.
addr := netip.AddrPortFrom(h.config.WgConfig.AllowedIps[0].Addr(), uint16(remotePort))
h.config.PQ.SetRemoteAddr(h.config.Key, addr)
}
func (h *Handshaker) SendOffer() error {
h.mu.Lock()
defer h.mu.Unlock()
@@ -195,13 +263,23 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
if h.config.PQ != nil {
offer.MlkemPayload, offer.MlkemPort = h.config.PQ.OfferPayload(h.config.Key)
}
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
func (h *Handshaker) sendAnswer(remoteOffer *OfferAnswer) error {
answer := h.buildOfferAnswer()
if h.config.PQ != nil {
var recvOffer []byte
if remoteOffer != nil {
recvOffer = remoteOffer.MlkemPayload
}
answer.MlkemPayload, answer.MlkemPort = h.config.PQ.AnswerPayload(h.config.Key, recvOffer)
}
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)

View File

@@ -10,6 +10,7 @@ import (
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/iface/wgproxy"
"github.com/netbirdio/netbird/monotime"
)
type WGIface interface {
@@ -19,4 +20,11 @@ type WGIface interface {
GetProxy() wgproxy.Proxy
Address() wgaddr.Address
RemoveEndpointAddress(key string) error
// LastActivities returns the last real-data activity time per peer (WireGuard
// keepalives excluded), used to gate post-quantum PSK rotation on active tunnels.
LastActivities() map[string]monotime.Time
// IsUserspaceBind reports whether WireGuard runs in userspace. Only there does
// LastActivities track per-peer data activity; in kernel mode it is unavailable,
// so PSK rotation cannot be gated on activity.
IsUserspaceBind() bool
}

View File

@@ -63,6 +63,8 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
},
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassAddr: offerAnswer.RosenpassAddr,
MlkemPayload: offerAnswer.MlkemPayload,
MlkemPort: offerAnswer.MlkemPort,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,

View File

@@ -0,0 +1,59 @@
package pqkem
import (
"crypto/ecdh"
"crypto/mlkem"
"crypto/rand"
"testing"
)
func BenchmarkX25519Keygen(b *testing.B) {
c := ecdh.X25519()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := c.GenerateKey(rand.Reader); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkX25519ECDH(b *testing.B) {
c := ecdh.X25519()
a, _ := c.GenerateKey(rand.Reader)
p, _ := c.GenerateKey(rand.Reader)
pub := p.PublicKey()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := a.ECDH(pub); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMLKEMKeygen(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := mlkem.GenerateKey768(); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMLKEMEncaps(b *testing.B) {
dk, _ := mlkem.GenerateKey768()
ek := dk.EncapsulationKey()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ek.Encapsulate()
}
}
func BenchmarkMLKEMDecaps(b *testing.B) {
dk, _ := mlkem.GenerateKey768()
_, ct := dk.EncapsulationKey().Encapsulate()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := dk.Decapsulate(ct); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,18 @@
package pqkem
// CallbackHandler is implemented by the host and invoked by the library. The
// library only reports events; the host owns the reaction. Keeping this an
// interface — rather than touching the transport or keying directly — is what lets
// the KEM code be extracted as a standalone library.
type CallbackHandler interface {
// OnNewPSKReady fires when a fresh post-quantum PSK has been derived for a peer
// and must be programmed into the consumer's secure channel. It is invoked at
// the commit point of each side: the initiator on receiving the answer, the
// responder on receiving the confirm.
OnNewPSKReady(remoteID RemoteID, psk PSK) error
// OnRekeyFailed fires when an exchange fails to converge within the allotted
// time. The host should tear the peer connection down so it re-establishes, and
// log a WARN. The library reports the event; it does not dictate the reaction.
OnRekeyFailed(remoteID RemoteID) error
}

View File

@@ -0,0 +1,65 @@
package pqkem
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestManager_NonCapablePeerNotOffered: a peer known not to run the KEM (it advertised
// no PQ port over signalling) is never offered an exchange, and no failure is raised —
// this is what stops the reoffer storm against non-PQ peers (e.g. Rosenpass peers).
func TestManager_NonCapablePeerNotOffered(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil) // initiator vs "aaaa"
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
d.MarkNonCapable("aaaa")
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.Nil(t, offer, "a non-capable peer must not be offered a KEM exchange")
require.Empty(t, wg.failed, "a non-capable peer must not raise a rekey failure")
}
// TestManager_MarkNonCapableCancelsInFlight: if we start an exchange with a peer whose
// capability is not yet known and then learn it does not run the KEM, the in-flight
// exchange is cancelled and no further offer is produced (no timeout -> no failure).
func TestManager_MarkNonCapableCancelsInFlight(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil)
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
// Capability unknown -> the bootstrap offer goes out optimistically.
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
// Now we learn the peer is non-PQ: the exchange must be dropped.
d.MarkNonCapable("aaaa")
next, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.Nil(t, next, "after learning non-capability the peer is no longer offered")
require.Empty(t, wg.failed, "cancelling an in-flight exchange must not raise a failure")
}
// TestManager_EstablishedPeerNotDowngraded: a stray zero-port observation must not tear
// down a peer we already have a working PQ session with.
func TestManager_EstablishedPeerNotDowngraded(t *testing.T) {
dA, dB, _, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
require.NotEqual(t, PSK{}, wgB.psk("aaaa"), "established a PSK")
dB.MarkNonCapable("aaaa") // stray zero after establishment
// The peer keeps its derived PSK (MarkNonCapable is a no-op once established).
psk, ok := dB.PSK("aaaa")
require.True(t, ok, "an established peer must keep its PSK despite a stray zero")
require.NotEqual(t, PSK{}, psk)
}

View File

@@ -0,0 +1,77 @@
package pqkem
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestConcurrency_RecoversViaResignalAfterDataPathBreak exercises the A-light recovery:
// a data-path rotation can no longer converge (OnRekeyFailed), and re-bootstrapping over
// signalling resyncs both peers on a fresh PSK — even while the data path stays broken,
// since the signal channel is independent of it.
func TestConcurrency_RecoversViaResignalAfterDataPathBreak(t *testing.T) {
dA, dB, wgA, wgB, lbB := pair(t)
defer dA.Stop()
defer dB.Stop()
// Tighten B's timings and make a single rotation miss raise OnRekeyFailed. Set
// before any exchange loop spawns (the loop reads these fields).
dB.retryInterval = 5 * time.Millisecond
dB.maxRetries = 2
dB.maxRekeyFailures = 1
bootstrap(t, dA, dB)
dA.OnDataPathRekeyed("bbbb", 0)
dB.OnDataPathRekeyed("aaaa", 0)
psk1 := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, psk1)
require.Equal(t, psk1, wgA.psk("bbbb"), "converged on the same PSK after bootstrap+rotation")
// Data path breaks: the rotation can no longer converge -> OnRekeyFailed.
lbB.drop.Store(true)
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
require.Eventually(t, func() bool { return failedCount(wgB) >= 1 }, time.Second, 5*time.Millisecond)
// Recovery: re-bootstrap over signalling with the data path STILL broken. It must
// still converge (signal is independent of the data path) on a fresh PSK.
bootstrap(t, dA, dB)
psk2 := wgB.psk("aaaa")
require.NotEqual(t, psk1, psk2, "recovery derived a fresh PSK")
require.Equal(t, psk2, wgA.psk("bbbb"), "both sides resync after recovery")
}
// TestConcurrency_ConcurrentRekeysNoRace hammers both managers with concurrent rotation
// clocks from many goroutines. Its primary job (with -race) is to prove the single-lock
// state machine has no data races or deadlocks under contention; a final deterministic
// bootstrap then asserts there is no split-brain (both sides on the same PSK).
func TestConcurrency_ConcurrentRekeysNoRace(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
dB.OnDataPathRekeyed("aaaa", 0) // initiator chains a rotation
dA.OnDataPathRekeyed("bbbb", 0) // responder side is a no-op, still stresses the lock
}
}()
}
wg.Wait()
// The storm may leave an exchange mid-flight (concurrent cancellation). Force a
// clean convergence over signalling, then assert no split-brain.
bootstrap(t, dA, dB)
a, b := wgA.psk("bbbb"), wgB.psk("aaaa")
require.NotEqual(t, PSK{}, b)
require.Equal(t, a, b, "both sides converge on the same PSK, no split-brain")
}

View File

@@ -0,0 +1,273 @@
package pqkem
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
)
// idHex renders an exchange ID for logs.
func idHex(id ExchangeID) string { return hex.EncodeToString(id[:]) }
// pskFingerprint is a short, non-secret digest of a derived PSK: identical on both
// peers iff they derived the same key. Logged instead of the raw PSK so debug logs
// never carry the actual WireGuard preshared key.
func pskFingerprint(psk PSK) string {
sum := sha256.Sum256(psk[:])
return hex.EncodeToString(sum[:8])
}
// startExchange creates a fresh initiator exchange (acknowledging ackID, zero for a
// bootstrap) and returns the framed offer for the caller to send — pushed over the
// data path for a chained rekey, or handed to the host for signalling when viaSignal
// is set. Any previous in-flight exchange for the peer is cancelled.
func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID ExchangeID) ([]byte, error) {
init, err := NewInitiator()
if err != nil {
return nil, err
}
id, err := newExchangeID()
if err != nil {
return nil, err
}
raw, err := (&OfferMsg{ExchangeID: id, AckID: ackID, KEMOffer: init.Offer()}).Encode()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(m.rootCtx)
m.mu.Lock()
if old := m.exchanges[remoteID]; old != nil && old.cancel != nil {
old.cancel()
}
m.exchanges[remoteID] = &exchangeCtl{
id: id,
state: stateAwaitingAnswer,
startedAt: time.Now(),
cancel: cancel,
lastSent: raw,
initiator: init,
viaSignal: viaSignal,
}
m.mu.Unlock()
m.wait.Add(1)
go m.initiatorLoop(ctx, remoteID, id)
via := "data-path"
if viaSignal {
via = "signal"
}
m.trace("pqkem: offer sent", "peer", remoteID, "exchange", idHex(id), "acks", idHex(ackID), "via", via)
return raw, nil
}
// processOffer (responder) first acknowledges the previous exchange the offer names
// (that offer riding the data path under the freshly adopted key proves it worked),
// then derives the PSK for the new offer, commits it optimistically, and returns the
// framed answer. A duplicate offer returns the cached answer without re-deriving.
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
if o.AckID != (ExchangeID{}) {
m.ackConverged(remoteID, o.AckID)
}
m.mu.Lock()
if ex := m.exchanges[remoteID]; ex != nil && ex.id == o.ExchangeID {
state, last := ex.state, ex.lastSent
m.mu.Unlock()
if state == stateReserved {
return nil, nil
}
m.trace("pqkem: duplicate offer, resending cached answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
return last, nil
}
// Reserve the slot so a concurrent duplicate offer bails.
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()}
m.mu.Unlock()
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
if err != nil {
return nil, err
}
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
if err != nil {
return nil, err
}
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != o.ExchangeID {
m.mu.Unlock()
m.trace("pqkem: exchange superseded during respond, dropping answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
return nil, nil
}
ex.state = stateAwaitingAck
ex.lastSent = raw
ex.pendingPSK = psk
m.psks[remoteID] = psk
m.capable[remoteID] = true // a real KEM offer proves the peer runs the exchange
m.mu.Unlock()
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
// Commit optimistically so our data path can rekey to the new PSK.
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
return nil, err
}
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
return raw, nil
}
// processAnswer (initiator) derives and commits the PSK and parks in
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
// lock makes a concurrent/duplicate answer bail.
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
haveID := "none"
if ex != nil {
haveID = idHex(ex.id)
}
m.mu.Unlock()
m.trace("pqkem: unexpected answer dropped (inconsistency)", "peer", remoteID, "answer_for", idHex(a.ExchangeID), "have_exchange", haveID)
return nil
}
ex.state = stateAwaitingRekey
init := ex.initiator
ex.initiator = nil
m.mu.Unlock()
m.trace("pqkem: answer received", "peer", remoteID, "exchange", idHex(a.ExchangeID))
psk, err := init.Finish(a.KEMAnswer, m.binding(remoteID))
if err != nil {
return err
}
// The initiator has converged: the responder must have derived the key to answer.
m.mu.Lock()
m.established[remoteID] = true
m.failures[remoteID] = 0
m.psks[remoteID] = psk
m.capable[remoteID] = true // a real KEM answer proves the peer runs the exchange
m.mu.Unlock()
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
return m.cbHandler.OnNewPSKReady(remoteID, psk)
}
// ackConverged (responder) records convergence of the exchange named by ackID: a
// later offer acknowledging it proves both sides operate on that exchange's key. Only
// acts on a matching stateAwaitingAck exchange; anything else is ignored.
func (m *Manager) ackConverged(remoteID RemoteID, ackID ExchangeID) {
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != ackID || ex.state != stateAwaitingAck {
m.mu.Unlock()
m.trace("pqkem: ack for unknown/mismatched exchange, ignored (inconsistency)", "peer", remoteID, "acks", idHex(ackID))
return
}
delete(m.exchanges, remoteID)
m.established[remoteID] = true
m.failures[remoteID] = 0
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
m.mu.Unlock()
m.trace("pqkem: previous exchange confirmed by ack", "peer", remoteID, "exchange", idHex(ackID))
}
// initiatorLoop enforces the offer->answer convergence deadline and retransmits the
// initiator's outstanding data-path offer while awaiting the answer (a
// signalling-bootstrapped offer is retransmitted by the host, so it is not resent
// here). Exhausting the deadline before the answer arrives is a failure. Once the
// answer is in (state past awaitingAnswer) the loop exits: the next rotation is driven
// by OnDataPathRekeyed, and the idle wait for it has no deadline.
func (m *Manager) initiatorLoop(ctx context.Context, remoteID RemoteID, id ExchangeID) {
defer m.wait.Done()
t := time.NewTicker(m.retryInterval)
defer t.Stop()
attempts := 0
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != id {
m.mu.Unlock()
return
}
switch ex.state {
case stateAwaitingAnswer:
if attempts >= m.maxRetries {
delete(m.exchanges, remoteID)
initial := !m.established[remoteID]
fail := m.registerFailureLocked(remoteID)
m.mu.Unlock()
m.raiseFailure(remoteID, fail, initial)
return
}
viaSignal := ex.viaSignal
msg := ex.lastSent
attempts++
m.mu.Unlock()
if !viaSignal {
if err := m.pushDataPath(remoteID, msg); err != nil {
m.logger.Warn("pqkem: offer retransmit failed", "peer", remoteID, "err", err)
}
}
default:
// Past awaiting the answer (converged) or superseded: the loop's job
// is done. The next rotation is driven externally by OnDataPathRekeyed,
// so there is no deadline while idle-waiting for it (that wait can be
// as long as the transport's natural rekey interval).
m.mu.Unlock()
return
}
}
}
}
// registerFailureLocked applies policy B and reports whether OnRekeyFailed is due:
// an initial exchange (peer never established) fails immediately; a rekey tolerates
// up to maxRekeyFailures consecutive misses (we stay on the still-valid previous
// PSK) before failing. Assumes m.mu is held.
func (m *Manager) registerFailureLocked(remoteID RemoteID) bool {
if !m.established[remoteID] {
return true
}
m.failures[remoteID]++
if m.failures[remoteID] >= m.maxRekeyFailures {
m.failures[remoteID] = 0
return true
}
return false
}
// raiseFailure reports a convergence failure. initial distinguishes a never-established
// peer (bootstrap failed → no PQ PSK at all; in strict mode the peer stays blocked =
// "stuck") from a rekey failure (a previous PSK is still in force and traffic continues).
func (m *Manager) raiseFailure(remoteID RemoteID, fail, initial bool) {
if !fail {
m.logger.Warn("pqkem: rekey attempt timed out, will retry next cycle", "peer", remoteID)
return
}
if initial {
m.logger.Warn("pqkem: initial exchange failed — no PQ PSK established for peer (strict mode keeps the peer blocked until it converges)", "peer", remoteID)
} else {
m.logger.Warn("pqkem: rekey failed after retries — staying on the previous PSK", "peer", remoteID)
}
if err := m.cbHandler.OnRekeyFailed(remoteID); err != nil {
m.logger.Error("pqkem: OnRekeyFailed handler error", "peer", remoteID, "err", err)
}
}

View File

@@ -0,0 +1,74 @@
package pqkem
import (
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// dropTransport is a pqkem.Transport that silently discards everything.
type dropTransport struct{}
func (dropTransport) Send(netip.AddrPort, []byte) error { return nil }
func (dropTransport) LocalPort() int { return 0 }
func (dropTransport) Run(func(netip.AddrPort, []byte)) {}
func (dropTransport) Close() error { return nil }
func failedCount(f *fakeWG) int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.failed)
}
func TestManager_InitialTimeoutFailsImmediately(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil) // bbbb > aaaa -> initiator
d.Start(dropTransport{})
d.retryInterval = 5 * time.Millisecond
d.maxRetries = 3
defer d.Stop()
// Bootstrap offer is produced for signalling; no answer ever comes back -> the
// initial exchange fails fast.
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
require.Eventually(t, func() bool { return failedCount(wg) == 1 }, time.Second, 5*time.Millisecond)
}
func TestManager_RekeyToleratesKFailures(t *testing.T) {
dA, dB, _, wgB, lbB := pair(t)
defer dA.Stop()
defer dB.Stop()
// Tighten B's timings before any exchange loop spawns (the loop reads these
// fields, so writing them after a loop is running would race).
dB.retryInterval = 5 * time.Millisecond
dB.maxRetries = 2
// Establish: bootstrap + data-path-rekeyed so B becomes established and its data
// path is usable.
bootstrap(t, dA, dB)
dA.OnDataPathRekeyed("bbbb", 0)
dB.OnDataPathRekeyed("aaaa", 0)
require.NotEqual(t, PSK{}, wgB.psk("aaaa"))
// Drop B's outbound so rekeys can no longer converge.
lbB.drop.Store(true)
// K-1 data-path rekeys must NOT raise OnRekeyFailed.
for i := 0; i < DefaultMaxRekeyFailures-1; i++ {
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
}
require.Equal(t, 0, failedCount(wgB), "no failure before K attempts")
// The K-th failure raises it once.
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
require.Eventually(t, func() bool { return failedCount(wgB) == 1 }, time.Second, 5*time.Millisecond)
}

View File

@@ -0,0 +1,138 @@
package pqkem
import (
"context"
"log/slog"
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
// EnvEnabled is the environment variable that turns the ML-KEM post-quantum
// exchange on for this client. Accepts on/off aliases plus anything
// strconv.ParseBool understands (true/false/1/0).
const EnvEnabled = "NB_ENABLE_PQ_MLKEM"
// Enabled reports whether the ML-KEM PQ exchange is enabled via the environment.
// An empty or unrecognized value is treated as disabled.
func Enabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(EnvEnabled)))
switch raw {
case "":
return false
case "on":
return true
case "off":
return false
}
enabled, err := strconv.ParseBool(raw)
if err != nil {
log.Warnf("failed to parse %s value %q: %v", EnvEnabled, raw, err)
return false
}
return enabled
}
// EnvStrict enables strict (fail-closed) mode: block peer traffic until the ML-KEM
// PSK has been established, instead of the default opportunistic behaviour that lets
// the tunnel come up classically and upgrades to PQ once the exchange converges.
const EnvStrict = "NB_PQ_MLKEM_STRICT"
// Strict reports whether strict (fail-closed) mode is enabled via the environment.
// An empty or unrecognized value is treated as disabled (opportunistic).
func Strict() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict))) {
case "on":
return true
case "", "off":
return false
}
enabled, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(EnvStrict)))
if err != nil {
log.Warnf("failed to parse %s value %q: %v", EnvStrict, os.Getenv(EnvStrict), err)
return false
}
return enabled
}
// EnvLogLevel overrides the ML-KEM manager's slog level (trace/debug/info/warn/error).
// Defaults to info. The verbose per-exchange lifecycle logs are emitted at trace.
const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"
// LevelTrace is a custom slog level below Debug for the verbose per-exchange lifecycle
// logs, so they stay off unless NB_PQ_MLKEM_LOG_LEVEL=trace (and the daemon log level
// is trace, since the records are forwarded to logrus).
const LevelTrace = slog.LevelDebug - 4
// NewLogger builds the slog logger for the ML-KEM manager. It forwards records to
// logrus so PQ logs land in the same sink as the rest of the daemon (console +
// client.log) rather than stdout. Verbosity is gated by EnvLogLevel.
func NewLogger() *slog.Logger {
return slog.New(slogToLogrus{})
}
func logLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvLogLevel))) {
case "trace":
return LevelTrace
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// slogToLogrus is a slog.Handler that forwards records to logrus, so the ML-KEM
// manager's logs go wherever the daemon's logrus is configured (console + client.log)
// instead of stdout. Verbosity is gated by EnvLogLevel via logLevel().
type slogToLogrus struct {
fields log.Fields
}
func (h slogToLogrus) Enabled(_ context.Context, level slog.Level) bool {
return level >= logLevel()
}
func (h slogToLogrus) Handle(_ context.Context, r slog.Record) error {
fields := make(log.Fields, len(h.fields)+r.NumAttrs())
for k, v := range h.fields {
fields[k] = v
}
r.Attrs(func(a slog.Attr) bool {
fields[a.Key] = a.Value.Any()
return true
})
entry := log.WithFields(fields)
switch {
case r.Level >= slog.LevelError:
entry.Error(r.Message)
case r.Level >= slog.LevelWarn:
entry.Warn(r.Message)
case r.Level >= slog.LevelInfo:
entry.Info(r.Message)
case r.Level >= slog.LevelDebug:
entry.Debug(r.Message)
default:
entry.Trace(r.Message)
}
return nil
}
func (h slogToLogrus) WithAttrs(attrs []slog.Attr) slog.Handler {
fields := make(log.Fields, len(h.fields)+len(attrs))
for k, v := range h.fields {
fields[k] = v
}
for _, a := range attrs {
fields[a.Key] = a.Value.Any()
}
return slogToLogrus{fields: fields}
}
func (h slogToLogrus) WithGroup(_ string) slog.Handler { return h }

View File

@@ -0,0 +1,178 @@
// Package pqkem is a spike (NET-1406) for a post-quantum pre-shared-key exchange
// that could replace Rosenpass. It performs an X25519MLKEM768 hybrid key
// encapsulation and derives a 32-byte pre-shared key (PSK).
//
// The exchange is a single round trip designed to ride the (already
// authenticated) Signal offer/answer channel:
//
// initiator --Offer(1216B)--> responder
// initiator <--Answer(1120B)-- responder
//
// Both sides then hold the same PSK, which is bound to the two peers' identities
// (their peer identity keys) so the derived key cannot be transplanted
// to a different peer pair even if the transport authentication were bypassed.
//
// Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on
// the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for
// this group), and ML-KEM_ss ‖ X25519_ss as the KDF input. The PSK is derived with
// HKDF-SHA256 over that hybrid secret, salted with a domain-separation label and
// bound (via the HKDF info) to the full transcript and the canonicalised peer
// identities.
package pqkem
import (
"crypto/ecdh"
"crypto/hkdf"
"crypto/mlkem"
"crypto/rand"
"crypto/sha256"
"fmt"
)
const (
// OfferSize is the initiator message: ML-KEM-768 encapsulation key ‖ X25519 public key
// (share order per draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768).
OfferSize = mlkem.EncapsulationKeySize768 + 32 // 1216
// AnswerSize is the responder message: ML-KEM-768 ciphertext ‖ X25519 public key.
AnswerSize = mlkem.CiphertextSize768 + 32 // 1120
pskLabel = "netbird-pq-psk-v1"
)
// PSK is the 32-byte derived pre-shared key handed to the consumer to key its channel.
type PSK [32]byte
// Binding identifies the peer pair the PSK is derived for. Callers set both
// peer identity keys; the order does not matter (it is canonicalised).
type Binding struct {
LocalID []byte
RemoteID []byte
}
// Initiator holds the ephemeral secrets between Offer and Finish.
type Initiator struct {
x25519 *ecdh.PrivateKey
mlkemDK *mlkem.DecapsulationKey768
offer []byte
}
// NewInitiator generates the ephemeral X25519 + ML-KEM-768 keypairs.
func NewInitiator() (*Initiator, error) {
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("x25519 keygen: %w", err)
}
dk, err := mlkem.GenerateKey768()
if err != nil {
return nil, fmt.Errorf("ml-kem keygen: %w", err)
}
offer := make([]byte, 0, OfferSize)
offer = append(offer, dk.EncapsulationKey().Bytes()...)
offer = append(offer, x.PublicKey().Bytes()...)
return &Initiator{x25519: x, mlkemDK: dk, offer: offer}, nil
}
// Offer returns the initiator message to send over Signal.
func (i *Initiator) Offer() []byte {
return i.offer
}
// Finish consumes the responder's answer and derives the PSK.
func (i *Initiator) Finish(answer []byte, b Binding) (PSK, error) {
if len(answer) != AnswerSize {
return PSK{}, fmt.Errorf("answer: got %d bytes, want %d", len(answer), AnswerSize)
}
ct := answer[:mlkem.CiphertextSize768]
peerX := answer[mlkem.CiphertextSize768:]
ssMLKEM, err := i.mlkemDK.Decapsulate(ct)
if err != nil {
return PSK{}, fmt.Errorf("ml-kem decapsulate: %w", err)
}
pub, err := ecdh.X25519().NewPublicKey(peerX)
if err != nil {
return PSK{}, fmt.Errorf("parse peer x25519: %w", err)
}
ssX, err := i.x25519.ECDH(pub)
if err != nil {
return PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
}
return derivePSK(ssMLKEM, ssX, i.offer, answer, b)
}
// Respond consumes an initiator offer, produces the answer, and derives the PSK.
func Respond(offer []byte, b Binding) (answer []byte, psk PSK, err error) {
if len(offer) != OfferSize {
return nil, PSK{}, fmt.Errorf("offer: got %d bytes, want %d", len(offer), OfferSize)
}
peerEK := offer[:mlkem.EncapsulationKeySize768]
peerX := offer[mlkem.EncapsulationKeySize768:]
ek, err := mlkem.NewEncapsulationKey768(peerEK)
if err != nil {
return nil, PSK{}, fmt.Errorf("parse peer ml-kem key: %w", err)
}
ssMLKEM, ct := ek.Encapsulate()
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, PSK{}, fmt.Errorf("x25519 keygen: %w", err)
}
pub, err := ecdh.X25519().NewPublicKey(peerX)
if err != nil {
return nil, PSK{}, fmt.Errorf("parse peer x25519: %w", err)
}
ssX, err := x.ECDH(pub)
if err != nil {
return nil, PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
}
answer = make([]byte, 0, AnswerSize)
answer = append(answer, ct...)
answer = append(answer, x.PublicKey().Bytes()...)
// derivePSK uses the same argument order on both sides; the responder's local
// binding is the mirror of the initiator's, canonicalised inside derivePSK.
psk, err = derivePSK(ssMLKEM, ssX, offer, answer, b)
if err != nil {
return nil, PSK{}, err
}
return answer, psk, nil
}
// derivePSK runs HKDF-SHA256 over the hybrid shared secret (ML-KEM_ss ‖ X25519_ss,
// per draft-ietf-tls-ecdhe-mlkem), salted with the domain-separation label, and binds
// the result — via the HKDF info — to the full transcript (offer ‖ answer) and the
// canonicalised peer identities, so the PSK cannot be transplanted to another peer
// pair or a different exchange.
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) (PSK, error) {
lo, hi := canonicalPair(b.LocalID, b.RemoteID)
ikm := make([]byte, 0, len(ssMLKEM)+len(ssX))
ikm = append(ikm, ssMLKEM...)
ikm = append(ikm, ssX...)
info := make([]byte, 0, len(offer)+len(answer)+len(lo)+len(hi))
info = append(info, offer...)
info = append(info, answer...)
info = append(info, lo...)
info = append(info, hi...)
var psk PSK
key, err := hkdf.Key(sha256.New, ikm, []byte(pskLabel), string(info), len(psk))
if err != nil {
return PSK{}, fmt.Errorf("hkdf derive psk: %w", err)
}
copy(psk[:], key)
return psk, nil
}
func canonicalPair(a, b []byte) (lo, hi []byte) {
if string(a) <= string(b) {
return a, b
}
return b, a
}

View File

@@ -0,0 +1,105 @@
package pqkem
import (
"crypto/mlkem"
"testing"
"github.com/stretchr/testify/require"
)
// TestExchange_TamperedCiphertextFailsClosed verifies the core fail-closed
// property: mutating the ML-KEM ciphertext in the answer does not error (ML-KEM
// uses implicit rejection — Decapsulate always returns a value) but yields a
// different shared secret, so the initiator derives a PSK that does NOT match the
// responder's. A mismatched PSK means WireGuard passes no bytes: tamper => no data.
func TestExchange_TamperedCiphertextFailsClosed(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tampered := append([]byte(nil), answer...)
tampered[0] ^= 0xff // flip a bit in the ML-KEM ciphertext
pskA, err := init.Finish(tampered, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err, "implicit rejection: decapsulate still succeeds")
require.NotEqual(t, pskB, pskA, "tampered ciphertext must not yield the responder's PSK")
}
// TestExchange_TamperedX25519ShareDiverges flips a byte in the answer's X25519
// share: the classical half of the hybrid secret changes, so the derived PSK
// diverges from the responder's (fail-closed on the ECDH half too).
func TestExchange_TamperedX25519ShareDiverges(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tampered := append([]byte(nil), answer...)
tampered[mlkem.CiphertextSize768] ^= 0x01 // first byte of the X25519 public key
pskA, err := init.Finish(tampered, Binding{LocalID: wgA, RemoteID: wgB})
// Either the point is rejected (error) or the ECDH differs (different PSK);
// in both cases the honest PSK is never reproduced.
if err == nil {
require.NotEqual(t, pskB, pskA, "tampered X25519 share must not yield the responder's PSK")
}
}
// TestExchange_AllZeroX25519Rejected feeds an all-zero X25519 share (a low-order
// point) in the answer. The stdlib ECDH must reject it, so Finish errors rather
// than deriving a PSK from a degenerate secret.
func TestExchange_AllZeroX25519Rejected(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
bad := append([]byte(nil), answer...)
for i := mlkem.CiphertextSize768; i < len(bad); i++ {
bad[i] = 0
}
_, err = init.Finish(bad, Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "all-zero X25519 share (low-order point) must be rejected")
}
// TestExchange_SizeBoundaries locks the exact-length framing checks: one byte
// short or long on either message is rejected, not silently truncated/padded.
func TestExchange_SizeBoundaries(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
offer := init.Offer()
_, _, err = Respond(offer[:OfferSize-1], Binding{})
require.Error(t, err, "offer one byte short")
_, _, err = Respond(append(append([]byte(nil), offer...), 0), Binding{})
require.Error(t, err, "offer one byte long")
answer, _, err := Respond(offer, Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
_, err = init.Finish(answer[:AnswerSize-1], Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "answer one byte short")
_, err = init.Finish(append(append([]byte(nil), answer...), 0), Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "answer one byte long")
}
// TestExchange_BindingIsSymmetric confirms the canonicalisation: the two peers
// pass their identities in opposite (Local, Remote) order yet derive the same PSK,
// so identity binding does not depend on who is initiator vs responder.
func TestExchange_BindingIsSymmetric(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
require.Equal(t, pskB, pskA, "swapped Local/Remote order must canonicalise to the same PSK")
}

View File

@@ -0,0 +1,89 @@
package pqkem
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
var (
wgA = []byte("peer-A-wireguard-pubkey-32bytes!")
wgB = []byte("peer-B-wireguard-pubkey-32bytes!")
)
func TestExchange_DerivesMatchingPSK(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
require.Len(t, init.Offer(), OfferSize)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
require.Len(t, answer, AnswerSize)
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
require.Equal(t, pskB, pskA, "both sides must derive the same PSK")
require.NotEqual(t, PSK{}, pskA, "PSK must not be zero")
}
func TestExchange_PSKBoundToPeerIdentities(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
// responder computes with the honest pair...
_, pskHonest, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
// ...a second responder run with a different peer identity yields a different PSK,
// even though the KEM material would otherwise combine identically.
wgC := []byte("peer-C-wireguard-pubkey-32bytes!")
_, pskWrong, err := Respond(init.Offer(), Binding{LocalID: wgC, RemoteID: wgA})
require.NoError(t, err)
require.NotEqual(t, pskHonest, pskWrong, "PSK must be bound to the peer pair")
}
func TestExchange_RejectsMalformedMessages(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
_, _, err = Respond(init.Offer()[:10], Binding{})
require.Error(t, err)
_, err = init.Finish([]byte("too short"), Binding{})
require.Error(t, err)
}
// TestExchange_ReportSizesAndTiming is a spike measurement, not a pass/fail gate.
// Run with: go test -run TestExchange_ReportSizesAndTiming -v ./client/internal/pqkem/
func TestExchange_ReportSizesAndTiming(t *testing.T) {
const iters = 200
var tInit, tResp, tFinish time.Duration
for i := 0; i < iters; i++ {
s0 := time.Now()
init, err := NewInitiator()
require.NoError(t, err)
tInit += time.Since(s0)
s1 := time.Now()
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tResp += time.Since(s1)
s2 := time.Now()
_, err = init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
tFinish += time.Since(s2)
}
t.Logf("wire sizes: offer=%d B answer=%d B (Rosenpass static pubkey ~524160 B)", OfferSize, AnswerSize)
t.Logf("total on-wire per handshake: %d B (~%.0fx smaller than RP static key)", OfferSize+AnswerSize, 524160.0/float64(OfferSize+AnswerSize))
t.Logf("avg NewInitiator (keygen): %s", tInit/iters)
t.Logf("avg Respond (encaps+dh): %s", tResp/iters)
t.Logf("avg Finish (decaps+dh): %s", tFinish/iters)
t.Logf("avg full handshake CPU: %s", (tInit+tResp+tFinish)/iters)
}

View File

@@ -0,0 +1,460 @@
package pqkem
import (
"context"
"crypto/rand"
"fmt"
"log/slog"
"net/netip"
"sync"
"time"
)
const (
// DefaultRetryInterval is how often the initiator retransmits its outstanding
// data-path offer while awaiting the answer.
DefaultRetryInterval = 2 * time.Second
// DefaultMaxRetries bounds how many ticks an exchange may run before it is
// declared failed. The convergence deadline is thus MaxRetries * RetryInterval.
DefaultMaxRetries = 10
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
DefaultMaxRekeyFailures = 3
// rotationActivityWindow gates rotation on recent real-data activity: a rekey
// clocks a rotation only if the peer exchanged user data within this window. It
// must stay shorter than the data path's rekey interval (WireGuard
// REKEY_AFTER_TIME ~120s) so the rotation's own traffic — which itself renews the
// activity signal — ages out before the next rekey, letting an idle tunnel stop
// rotating instead of self-sustaining.
rotationActivityWindow = 90 * time.Second
)
// LocalID and RemoteID are peer identity keys (e.g. WireGuard public keys). They are
// distinct types so the local and a remote identity cannot be mixed up.
type (
LocalID string
RemoteID string
)
// Transport is the data-path socket the Manager drives (the analogue of
// go-rosenpass's Conn). It is a dumb mover of bytes to/from endpoints: the Manager
// owns the remoteID<->endpoint routing and hands the transport a resolved endpoint
// to Send, and reverse-resolves the source of each inbound datagram. Its lifecycle
// belongs to the Manager (Run at Start, Close at Stop).
type Transport interface {
// Send delivers msg to the given data-path endpoint.
Send(endpoint netip.AddrPort, msg []byte) error
// LocalPort is the bound local UDP port, announced to peers so they know where
// to send data-path messages.
LocalPort() int
// Run starts delivering inbound datagrams as (source endpoint, msg) to onInbound
// and returns immediately; it runs until Close.
Run(onInbound func(src netip.AddrPort, msg []byte))
// Close stops delivery and releases the socket.
Close() error
}
// exchangeState is the single source of truth for an exchange's role and phase.
type exchangeState uint8
const (
stateReserved exchangeState = iota // responder: deriving the answer
stateAwaitingAnswer // initiator: offer sent, awaiting the answer
stateAwaitingRekey // initiator: PSK derived+set, awaiting OnDataPathRekeyed to chain the next offer
stateAwaitingAck // responder: answer sent, awaiting the next offer that acks this exchange
)
// exchangeCtl holds all state for one in-flight exchange with a peer, under the
// Manager's single lock. state drives every decision. lastSent is the current
// data-path retransmit payload (the offer, for the initiator). initiator is the
// ephemeral handle used at Finish; pendingPSK is the responder's derived key.
// viaSignal records that the offer went to the host for the signalling channel, so
// the loop does not retransmit it on the data path. Only the initiator runs a
// retransmit loop, so only it sets cancel.
type exchangeCtl struct {
id ExchangeID
state exchangeState
startedAt time.Time
cancel context.CancelFunc
lastSent []byte
initiator *Initiator
pendingPSK PSK
viaSignal bool
}
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
// drives the X25519MLKEM768 exchange, owns the peer endpoint routing and the data-path
// transport, and surfaces the derived PSK and convergence to the host via
// CallbackHandler. It is event-driven: the bootstrap is triggered by the host
// (SignalOffer) and each rotation is clocked by OnDataPathRekeyed. The cryptography is
// the pure kem.go primitives; all state lives here under one lock.
type Manager struct {
localID LocalID
cbHandler CallbackHandler
logger *slog.Logger
retryInterval time.Duration
maxRetries int
maxRekeyFailures int
rootCtx context.Context
rootCancel context.CancelFunc
mu sync.Mutex
transport Transport
exchanges map[RemoteID]*exchangeCtl // in-flight exchange per peer
established map[RemoteID]bool // peer has completed at least one exchange
failures map[RemoteID]int // consecutive rekey failures per peer
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
capable map[RemoteID]bool // peer runs the KEM (advertised a PQ port); false = known non-capable
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
wait sync.WaitGroup
}
// NewManager builds a manager for the local peer identified by its peer identity key
// (used for the deterministic initiator role and the identity binding). A nil logger
// falls back to slog.Default(). Install the data-path transport with Start.
func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.Default()
}
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
localID: localID,
cbHandler: h,
logger: logger,
retryInterval: DefaultRetryInterval,
maxRetries: DefaultMaxRetries,
maxRekeyFailures: DefaultMaxRekeyFailures,
rootCtx: ctx,
rootCancel: cancel,
exchanges: make(map[RemoteID]*exchangeCtl),
established: make(map[RemoteID]bool),
failures: make(map[RemoteID]int),
psks: make(map[RemoteID]PSK),
capable: make(map[RemoteID]bool),
peerAddrs: make(map[RemoteID]netip.AddrPort),
peersByAddr: make(map[netip.AddrPort]RemoteID),
}
}
// Start installs the data-path transport and begins its inbound delivery. The Manager
// owns it from here; Stop closes it.
func (m *Manager) Start(t Transport) {
m.mu.Lock()
m.transport = t
m.mu.Unlock()
if t != nil {
t.Run(m.onDataPathInbound)
}
}
// LocalPort is the data-path transport's bound UDP port (0 if no transport), to be
// announced to peers.
func (m *Manager) LocalPort() int {
m.mu.Lock()
t := m.transport
m.mu.Unlock()
if t == nil {
return 0
}
return t.LocalPort()
}
// IsInitiator reports whether the local peer drives the exchange for this remote
// peer. Roles are deterministic (lexicographic identity-key compare) so exactly one
// side initiates, mirroring how Rosenpass picks its handshake initiator.
func (m *Manager) IsInitiator(remoteID RemoteID) bool {
return string(m.localID) > string(remoteID)
}
// PSK returns the latest PSK derived for the peer, for the host to program at WG
// peer-config time (the pull path). ok is false until an exchange has derived one.
func (m *Manager) PSK(remoteID RemoteID) (PSK, bool) {
m.mu.Lock()
defer m.mu.Unlock()
psk, ok := m.psks[remoteID]
return psk, ok
}
// trace logs at LevelTrace, the verbose per-exchange lifecycle level gated by
// NB_PQ_MLKEM_LOG_LEVEL=trace.
func (m *Manager) trace(msg string, args ...any) {
m.logger.Log(context.Background(), LevelTrace, msg, args...)
}
// AddPeer registers where a peer's data-path messages are sent and received: its
// overlay endpoint (IP:port). This is pure routing and says nothing about capability —
// PQ capability is decided solely from the peer's KEM payload (see processOffer /
// processAnswer / MarkNonCapable), never from an endpoint or port.
func (m *Manager) AddPeer(remoteID RemoteID, endpoint netip.AddrPort) {
if !endpoint.IsValid() {
return
}
m.mu.Lock()
if old, ok := m.peerAddrs[remoteID]; ok {
delete(m.peersByAddr, old)
}
m.peerAddrs[remoteID] = endpoint
m.peersByAddr[endpoint] = remoteID
m.mu.Unlock()
}
// MarkNonCapable records that a peer does not run the KEM: it answered our offer with
// no KEM material over signalling (the capability signal is the peer's payload, not its
// optional data-path port). Any in-flight exchange is cancelled and further offers are
// suppressed (see SignalOffer), so a non-PQ peer never drives the rekey-recovery storm.
// An already-established peer is left untouched — a stray empty answer must not tear
// down a working PQ session.
func (m *Manager) MarkNonCapable(remoteID RemoteID) {
m.mu.Lock()
defer m.mu.Unlock()
if m.established[remoteID] {
return
}
if prev, ok := m.capable[remoteID]; ok && !prev {
return // already known non-capable, nothing to do
}
m.capable[remoteID] = false
if ex := m.exchanges[remoteID]; ex != nil {
if ex.cancel != nil {
ex.cancel()
}
delete(m.exchanges, remoteID)
}
m.trace("pqkem: peer advertises no PQ service — treating as non-capable, no KEM attempted", "peer", remoteID)
}
// RemovePeer stops any in-flight exchange for a peer and drops its state and routing.
func (m *Manager) RemovePeer(remoteID RemoteID) {
m.mu.Lock()
if ex, ok := m.exchanges[remoteID]; ok {
if ex.cancel != nil {
ex.cancel()
}
delete(m.exchanges, remoteID)
}
delete(m.established, remoteID)
delete(m.failures, remoteID)
delete(m.psks, remoteID)
delete(m.capable, remoteID)
if ep, ok := m.peerAddrs[remoteID]; ok {
delete(m.peersByAddr, ep)
delete(m.peerAddrs, remoteID)
}
m.mu.Unlock()
}
// Stop cancels all in-flight exchanges, closes the transport, and waits for the
// exchange goroutines to exit.
func (m *Manager) Stop() {
m.rootCancel()
m.wait.Wait()
m.mu.Lock()
t := m.transport
m.transport = nil
m.exchanges = make(map[RemoteID]*exchangeCtl)
m.psks = make(map[RemoteID]PSK)
m.mu.Unlock()
if t != nil {
if err := t.Close(); err != nil {
m.logger.Warn("pqkem: closing data-path transport", "err", err)
}
}
}
// ---- Signalling channel (host-driven; rides the host's negotiation) ----
// SignalOffer returns the KEM offer for the host to embed in its outgoing offer to
// remoteID (bootstrap). It returns (nil, nil) when the local peer is not the
// initiator. It is idempotent for an in-flight bootstrap: a repeat call returns the
// same offer rather than starting a new exchange.
//
// A signal re-negotiation always re-bootstraps (fresh exchange): the remote may have
// restarted and lost its PSK, so reusing a locally frozen one would desync. The derived
// PSK still survives idle in the manager (dropped only on account-level peer removal),
// so a pure lazy wake with no re-negotiation reuses it via the conn's WG-config pull.
func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
if !m.IsInitiator(remoteID) {
return nil, nil
}
m.mu.Lock()
if capable, ok := m.capable[remoteID]; ok && !capable {
m.mu.Unlock()
return nil, nil // peer does not run the KEM; do not offer (avoids a failure/reoffer loop)
}
// Idempotent while a signalling bootstrap is in flight OR already derived a PSK but
// not yet chained a rotation (awaitingRekey): return the SAME offer instead of
// starting a new exchange. This matters when the controller both offers on its own
// guard AND re-offers in response to the responder's offer — without this, the
// second call would start a fresh exchange (a different PSK) and desync the peers.
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal &&
(ex.state == stateAwaitingAnswer || ex.state == stateAwaitingRekey) {
last := ex.lastSent
m.mu.Unlock()
return last, nil
}
m.mu.Unlock()
// bootstrap offer acknowledges nothing (zero AckID).
return m.startExchange(remoteID, true, ExchangeID{})
}
// ShouldSendBootstrapOffer reports whether we should emit a fresh KEM offer to kick a
// bootstrap for this peer. True only if we are the initiator, the peer is not known
// non-capable, and no exchange is already in flight. The host uses this when it (as the
// controller) receives the responder's offer: it replies with a KEM offer exactly once
// to start the exchange, and ignores further responder offers while one is in flight,
// avoiding an offer-per-offer runaway.
func (m *Manager) ShouldSendBootstrapOffer(remoteID RemoteID) bool {
if !m.IsInitiator(remoteID) {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
if capable, ok := m.capable[remoteID]; ok && !capable {
return false
}
return m.exchanges[remoteID] == nil
}
// SignalOnOffer processes a KEM offer the host extracted from an incoming offer and
// returns the KEM answer for the host to embed in its outgoing answer.
func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error) {
typ, msg, err := Decode(offer)
if err != nil {
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
}
if typ != MsgOffer {
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
}
return m.processOffer(remoteID, msg.(*OfferMsg))
}
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
// There is no reply: the next offer (over the data path) acknowledges this exchange.
func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
typ, msg, err := Decode(answer)
if err != nil {
return fmt.Errorf("decode signal answer from %s: %w", remoteID, err)
}
if typ != MsgAnswer {
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
}
return m.processAnswer(remoteID, msg.(*AnswerMsg))
}
// ---- Data path ----
// onDataPathInbound is the transport's inbound handler: it reverse-resolves the
// source endpoint to a peer and dispatches. Unknown sources are dropped.
func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
m.mu.Lock()
remoteID, ok := m.peersByAddr[src]
m.mu.Unlock()
if !ok {
return
}
if err := m.OnDataPathMessage(remoteID, msg); err != nil {
m.trace("pqkem: inbound", "peer", remoteID, "err", err)
}
}
// OnDataPathMessage handles a KEM message received over the data path from remoteID
// and pushes any reply back over the data path.
func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
typ, msg, err := Decode(raw)
if err != nil {
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
}
switch typ {
case MsgOffer:
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
if err != nil {
return err
}
if answer == nil {
return nil
}
return m.pushDataPath(remoteID, answer)
case MsgAnswer:
return m.processAnswer(remoteID, msg.(*AnswerMsg))
default:
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
}
}
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh data-path rekey
// (fired on first establishment AND every rekey). If we are the initiator that just
// derived a PSK, it chains the next exchange: a fresh offer over the data path that
// acknowledges the just-completed one (its arrival under the new key proves to the
// responder the key works). sinceActivity is how long ago the peer last exchanged real
// user data; past rotationActivityWindow the tunnel is treated as idle and rotation is
// skipped — an idle tunnel has nothing to protect, and rotating would emit data-path
// traffic that keeps the peer artificially active (see conn.onWGCheckSuccess).
func (m *Manager) OnDataPathRekeyed(remoteID RemoteID, sinceActivity time.Duration) {
if sinceActivity >= rotationActivityWindow {
m.trace("pqkem: peer idle, skipping data-path rotation", "peer", remoteID, "since_activity", sinceActivity)
return
}
m.mu.Lock()
ex := m.exchanges[remoteID]
chain := ex != nil && ex.state == stateAwaitingRekey
var ackID ExchangeID
if chain {
ackID = ex.id
}
m.mu.Unlock()
m.trace("pqkem: data-path rekey signal", "peer", remoteID, "chaining", chain)
if !chain {
return
}
offer, err := m.startExchange(remoteID, false, ackID)
if err != nil {
m.logger.Error("pqkem: chain offer failed to start", "peer", remoteID, "err", err)
return
}
if err := m.pushDataPath(remoteID, offer); err != nil {
m.logger.Warn("pqkem: send chain offer failed", "peer", remoteID, "err", err)
return
}
m.trace("pqkem: chain offer sent over data path", "peer", remoteID)
}
// OnDataPathDown notifies that the peer's data path went down. Rotations resume once
// the host re-bootstraps over signalling on reconnect; in-flight data-path sends will
// simply fail until then. Reserved as an explicit hook.
func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
// ---- internals ----
// pushDataPath resolves the peer's endpoint and sends over the data-path transport,
// erroring if the peer is unknown or no transport is set.
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
m.mu.Lock()
ep, ok := m.peerAddrs[remoteID]
t := m.transport
m.mu.Unlock()
if !ok {
return fmt.Errorf("no data-path endpoint for peer %s", remoteID)
}
if t == nil {
return fmt.Errorf("no data-path transport")
}
return t.Send(ep, msg)
}
func (m *Manager) binding(remoteID RemoteID) Binding {
return Binding{LocalID: []byte(m.localID), RemoteID: []byte(remoteID)}
}
func newExchangeID() (ExchangeID, error) {
var id ExchangeID
if _, err := rand.Read(id[:]); err != nil {
return ExchangeID{}, fmt.Errorf("generate exchange id: %w", err)
}
return id, nil
}

View File

@@ -0,0 +1,193 @@
package pqkem
import (
"fmt"
"net/netip"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// netSwitch is an in-memory UDP fabric: transports register their endpoint and get
// datagrams delivered to their inbound handler.
type netSwitch struct {
mu sync.Mutex
h map[netip.AddrPort]func(netip.AddrPort, []byte)
}
func newSwitch() *netSwitch {
return &netSwitch{h: map[netip.AddrPort]func(netip.AddrPort, []byte){}}
}
func (s *netSwitch) register(ep netip.AddrPort, fn func(netip.AddrPort, []byte)) {
s.mu.Lock()
s.h[ep] = fn
s.mu.Unlock()
}
func (s *netSwitch) deliver(dst, src netip.AddrPort, msg []byte) error {
s.mu.Lock()
fn := s.h[dst]
s.mu.Unlock()
if fn == nil {
return fmt.Errorf("no route to %s", dst)
}
fn(src, msg)
return nil
}
// loopback is an endpoint-based pqkem.Transport over a netSwitch, with a switchable
// drop flag.
type loopback struct {
ep netip.AddrPort
sw *netSwitch
drop atomic.Bool
}
func (l *loopback) Send(dst netip.AddrPort, msg []byte) error {
if l.drop.Load() {
return nil
}
return l.sw.deliver(dst, l.ep, append([]byte(nil), msg...))
}
func (l *loopback) LocalPort() int { return int(l.ep.Port()) }
func (l *loopback) Run(onInbound func(netip.AddrPort, []byte)) { l.sw.register(l.ep, onInbound) }
func (l *loopback) Close() error { return nil }
type fakeWG struct {
mu sync.Mutex
psks map[RemoteID]PSK
failed []RemoteID
}
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
f.mu.Lock()
defer f.mu.Unlock()
f.psks[remoteID] = psk
return nil
}
func (f *fakeWG) OnRekeyFailed(remoteID RemoteID) error {
f.mu.Lock()
defer f.mu.Unlock()
f.failed = append(f.failed, remoteID)
return nil
}
func (f *fakeWG) psk(peer RemoteID) PSK {
f.mu.Lock()
defer f.mu.Unlock()
return f.psks[peer]
}
var (
epA = netip.MustParseAddrPort("100.64.0.1:51833")
epB = netip.MustParseAddrPort("100.64.0.2:51833")
)
// pair builds two wired managers (B is the initiator, "bbbb" > "aaaa") sharing a
// netSwitch, with each peer's data-path endpoint registered. lbB is B's loopback
// (for toggling drop).
func pair(t *testing.T) (dA, dB *Manager, wgA, wgB *fakeWG, lbB *loopback) {
t.Helper()
sw := newSwitch()
wgA = newFakeWG()
wgB = newFakeWG()
dA = NewManager("aaaa", wgA, nil)
dB = NewManager("bbbb", wgB, nil)
dA.Start(&loopback{ep: epA, sw: sw})
lbB = &loopback{ep: epB, sw: sw}
dB.Start(lbB)
dA.AddPeer("bbbb", epB)
dB.AddPeer("aaaa", epA)
return dA, dB, wgA, wgB, lbB
}
// bootstrap runs the signalling offer/answer (the test plays the host carrying bytes).
func bootstrap(t *testing.T, dA, dB *Manager) {
t.Helper()
offer, err := dB.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
answer, err := dA.SignalOnOffer("bbbb", offer)
require.NoError(t, err)
require.NotNil(t, answer)
require.NoError(t, dB.SignalOnAnswer("aaaa", answer))
}
func TestManager_BootstrapDerivesSamePSK(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
pskA := wgA.psk("bbbb")
pskB := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, pskA)
require.Equal(t, pskB, pskA, "both sides derive the same PSK from the bootstrap exchange")
}
func TestManager_ChainRotatesAndAcks(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk1 := wgB.psk("aaaa")
// Data path up: B (initiator) chains the next offer over the data path, which
// rotates both to a fresh PSK and acknowledges A.
dA.OnDataPathRekeyed("bbbb", 0)
dB.OnDataPathRekeyed("aaaa", 0)
psk2A := wgA.psk("bbbb")
psk2B := wgB.psk("aaaa")
require.Equal(t, psk2B, psk2A, "both sides converge on the rotated PSK")
require.NotEqual(t, psk1, psk2B, "the chain rotated to a new PSK")
}
func TestManager_RotationSkippedWhenIdle(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk1 := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, psk1)
// Idle: the peer's last real-data activity is older than the window, so a rekey
// must NOT clock a rotation.
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow)
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow)
require.Equal(t, psk1, wgB.psk("aaaa"), "idle peer must not rotate the PSK")
require.Equal(t, psk1, wgA.psk("bbbb"), "idle peer must not rotate the PSK")
// Active: activity within the window clocks the rotation as usual.
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow-1)
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow-1)
psk2 := wgB.psk("aaaa")
require.NotEqual(t, psk1, psk2, "recent activity must clock a rotation")
require.Equal(t, psk2, wgA.psk("bbbb"), "both sides converge on the rotated PSK")
}
func TestManager_NonInitiatorReturnsNoOffer(t *testing.T) {
dA := NewManager("aaaa", newFakeWG(), nil)
defer dA.Stop()
offer, err := dA.SignalOffer("bbbb") // not the initiator vs "bbbb"
require.NoError(t, err)
require.Nil(t, offer)
}
func TestManager_StopIsIdempotent(t *testing.T) {
dA := NewManager("aaaa", newFakeWG(), nil)
dA.Start(&loopback{ep: epA, sw: newSwitch()})
dA.Stop()
dA.Stop() // must not panic or hang
}

View File

@@ -0,0 +1,121 @@
package pqkem
import (
"crypto/mlkem"
"fmt"
)
// Wire framing for the PQ-KEM exchange. Messages are self-contained, versioned,
// transport-agnostic byte blobs: the same bytes ride the signalling channel
// (initial bootstrap) or a data-tunnel packet (rekey). The library only ever sees
// opaque []byte at the transport seam.
//
// Layout (all messages): [type:1][version:1][exchangeID:16][payload...]
//
// There is no confirm message: an exchange is acknowledged by the NEXT offer, which
// carries the acked exchange's id (see OfferMsg.AckID) and — riding the data path
// under the freshly adopted key — proves that key works.
const (
// ProtocolVersion is bumped on any wire-incompatible change; a peer rejects
// messages it does not understand rather than misparsing them.
ProtocolVersion uint8 = 1
// ExchangeIDSize identifies one exchange so answers/acks correlate and stale
// messages are dropped.
ExchangeIDSize = 16
headerSize = 1 + 1 + ExchangeIDSize
)
// MsgType tags the two message kinds of the exchange.
type MsgType uint8
const (
MsgOffer MsgType = iota + 1
MsgAnswer
)
// ExchangeID is the per-exchange correlator. The zero value means "none" (an offer
// that acknowledges nothing, i.e. the first exchange of a connection).
type ExchangeID [ExchangeIDSize]byte
// OfferMsg carries the initiator's public material (X25519 pub ‖ ML-KEM encap key)
// and AckID, the id of the previous exchange this offer acknowledges (zero if none).
type OfferMsg struct {
ExchangeID ExchangeID
AckID ExchangeID
// KEMOffer is the raw Initiator.Offer() blob (OfferSize bytes).
KEMOffer []byte
}
// AnswerMsg carries the responder's reply (ML-KEM ciphertext ‖ X25519 pub) for the
// round identified by ExchangeID.
type AnswerMsg struct {
ExchangeID ExchangeID
// KEMAnswer is the raw Respond() answer blob (AnswerSize bytes).
KEMAnswer []byte
}
// Encode serialises the offer with its framed header (payload = AckID ‖ KEMOffer).
func (m *OfferMsg) Encode() ([]byte, error) {
if len(m.KEMOffer) != OfferSize {
return nil, fmt.Errorf("offer payload: got %d, want %d", len(m.KEMOffer), OfferSize)
}
payload := make([]byte, 0, ExchangeIDSize+OfferSize)
payload = append(payload, m.AckID[:]...)
payload = append(payload, m.KEMOffer...)
return frame(MsgOffer, m.ExchangeID, payload), nil
}
// Encode serialises the answer with its framed header.
func (m *AnswerMsg) Encode() ([]byte, error) {
if len(m.KEMAnswer) != AnswerSize {
return nil, fmt.Errorf("answer payload: got %d, want %d", len(m.KEMAnswer), AnswerSize)
}
return frame(MsgAnswer, m.ExchangeID, m.KEMAnswer), nil
}
// Decode parses a framed message into one of *OfferMsg / *AnswerMsg.
func Decode(buf []byte) (MsgType, any, error) {
if len(buf) < headerSize {
return 0, nil, fmt.Errorf("message too short: %d bytes", len(buf))
}
typ := MsgType(buf[0])
if ver := buf[1]; ver != ProtocolVersion {
return typ, nil, fmt.Errorf("unsupported protocol version %d (want %d)", ver, ProtocolVersion)
}
var id ExchangeID
copy(id[:], buf[2:headerSize])
payload := buf[headerSize:]
switch typ {
case MsgOffer:
if len(payload) != ExchangeIDSize+OfferSize {
return typ, nil, fmt.Errorf("offer payload: got %d, want %d", len(payload), ExchangeIDSize+OfferSize)
}
var ack ExchangeID
copy(ack[:], payload[:ExchangeIDSize])
return typ, &OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: payload[ExchangeIDSize:]}, nil
case MsgAnswer:
if len(payload) != AnswerSize {
return typ, nil, fmt.Errorf("answer payload: got %d, want %d", len(payload), AnswerSize)
}
return typ, &AnswerMsg{ExchangeID: id, KEMAnswer: payload}, nil
default:
return typ, nil, fmt.Errorf("unknown message type %d", typ)
}
}
func frame(typ MsgType, id ExchangeID, payload []byte) []byte {
buf := make([]byte, headerSize+len(payload))
buf[0] = byte(typ)
buf[1] = ProtocolVersion
copy(buf[2:], id[:])
copy(buf[headerSize:], payload)
return buf
}
// compile-time assurance the KEM blob sizes referenced here stay in sync with kem.go.
var _ = [1]struct{}{}[OfferSize-(32+mlkem.EncapsulationKeySize768)]

View File

@@ -0,0 +1,57 @@
package pqkem
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMessageRoundTrip(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
id := ExchangeID{1, 2, 3, 4}
ack := ExchangeID{9, 9, 9}
offBytes, err := (&OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: init.Offer()}).Encode()
require.NoError(t, err)
typ, decoded, err := Decode(offBytes)
require.NoError(t, err)
require.Equal(t, MsgOffer, typ)
require.Equal(t, id, decoded.(*OfferMsg).ExchangeID)
require.Equal(t, ack, decoded.(*OfferMsg).AckID)
require.Equal(t, init.Offer(), decoded.(*OfferMsg).KEMOffer)
ansBytes, err := (&AnswerMsg{ExchangeID: id, KEMAnswer: answer}).Encode()
require.NoError(t, err)
typ, decoded, err = Decode(ansBytes)
require.NoError(t, err)
require.Equal(t, MsgAnswer, typ)
require.Equal(t, answer, decoded.(*AnswerMsg).KEMAnswer)
}
func TestDecodeRejects(t *testing.T) {
// too short
_, _, err := Decode([]byte{1, 1})
require.Error(t, err)
// wrong version
bad := make([]byte, headerSize+ExchangeIDSize+OfferSize)
bad[0] = byte(MsgOffer)
bad[1] = ProtocolVersion + 1
_, _, err = Decode(bad)
require.Error(t, err)
// unknown type
bad2 := make([]byte, headerSize)
bad2[0] = 99
bad2[1] = ProtocolVersion
_, _, err = Decode(bad2)
require.Error(t, err)
// offer with wrong payload size
_, err = (&OfferMsg{KEMOffer: []byte{1, 2, 3}}).Encode()
require.Error(t, err)
}

View File

@@ -0,0 +1,150 @@
package internal
import (
"net/netip"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
// pqPresharedKeySetter is the subset of the WireGuard interface the ML-KEM callback
// needs: programming a peer's preshared key. *iface.WGIface satisfies it.
type pqPresharedKeySetter interface {
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
}
// pqCallbackHandler programs the derived PQ PSK onto the WireGuard peer. It is the
// engine-side implementation of pqkem.CallbackHandler.
type pqCallbackHandler struct {
wg pqPresharedKeySetter
// reoffer re-bootstraps the KEM over Signal for a peer (a fresh signalling offer)
// to recover from a persistent data-path rekey failure. Nil disables recovery.
reoffer func(remoteKey string)
}
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
// if the peer is not present, mirroring Rosenpass).
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) error {
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
// peer is not configured yet, so this is a no-op there and the PSK is instead
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
log.Tracef("pqkem: programming PSK for peer %s", remoteID)
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
}
// OnRekeyFailed reports a failed PQ (re)key convergence and re-bootstraps the KEM over
// Signal to recover: a fresh signalling offer starts a new exchange that overwrites the
// stalled PSK on both sides, resyncing after a persistent data-path desync. The tunnel
// stays up on the previous PSK meanwhile (the Signal channel is independent of the
// broken data path).
func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error {
log.Warnf("pqkem: post-quantum rekey failed for peer %s, re-bootstrapping over signal", remoteID)
if h.reoffer != nil {
h.reoffer(string(remoteID))
}
return nil
}
// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys),
// wiring the host's signalling offers/answers to the KEM exchange.
type pqHandshaker struct {
mgr *pqkem.Manager
}
// announcedPort is the PQ data-path port to advertise to peers. It is omitted (0) when
// the manager is on DefaultPort, since peers assume the default when no port is sent;
// only a non-default (collision-forced) port is announced explicitly.
func (p pqHandshaker) announcedPort() int {
if port := p.mgr.LocalPort(); port != DefaultPort {
return port
}
return 0
}
func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, int) {
payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey))
if err != nil {
log.Warnf("pqkem: build offer for %s: %v", remoteKey, err)
}
return payload, p.announcedPort()
}
func (p pqHandshaker) ShouldSendBootstrapOffer(remoteKey string) bool {
return p.mgr.ShouldSendBootstrapOffer(pqkem.RemoteID(remoteKey))
}
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) {
if len(recvOffer) == 0 {
// Capability signal (responder side): the KEM offer flows initiator->responder,
// so if we are the responder for this peer (it is the KEM initiator by role) an
// empty offer means it does not run the KEM. If we are the initiator, an empty
// offer is normal — the peer is the responder and puts its material in the
// answer — so we must not flag it.
if !p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
return nil, p.announcedPort()
}
payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer)
if err != nil {
log.Warnf("pqkem: build answer for %s: %v", remoteKey, err)
}
return payload, p.announcedPort()
}
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
if len(recvAnswer) == 0 {
// Capability signal (initiator side): the KEM answer flows responder->initiator,
// so an empty answer to our offer means the peer does not run the KEM — mark it
// non-capable to stop offering (no failure/reoffer storm). Only meaningful when
// we are the initiator: as the responder we also receive an (empty) answer to
// our own non-KEM offer from a perfectly capable peer, which must not be flagged.
if p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
return
}
if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil {
log.Warnf("pqkem: process answer from %s: %v", remoteKey, err)
}
}
// PSK exposes the peer's derived PSK for the conn to program at WG peer-config time.
func (p pqHandshaker) PSK(remoteKey string) (wgtypes.Key, bool) {
psk, ok := p.mgr.PSK(pqkem.RemoteID(remoteKey))
if !ok {
return wgtypes.Key{}, false
}
return wgtypes.Key(psk), true
}
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling. A
// zero port means the peer omitted it (it is on DefaultPort), so we resolve it here —
// DefaultPort lives in this package, not in peer. Sends only ever fire once the tunnel
// is up (clocked by OnDataPathRekeyed), so registering here is safe even before
// connection-up.
func (p pqHandshaker) SetRemoteAddr(remoteKey string, addr netip.AddrPort) {
if !addr.Addr().IsValid() {
return
}
port := addr.Port()
if port == 0 {
port = DefaultPort
}
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), netip.AddrPortFrom(addr.Addr(), port))
}
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
// sinceActivity is how long ago the peer last exchanged real user data; the manager
// skips rotation for idle tunnels.
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration) {
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey), sinceActivity)
}
// OnDataPathDown signals the peer's tunnel went down.
func (p pqHandshaker) OnDataPathDown(remoteKey string) {
p.mgr.OnDataPathDown(pqkem.RemoteID(remoteKey))
}

View File

@@ -0,0 +1,37 @@
package internal
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
type pqNoopHandler struct{}
func (pqNoopHandler) OnNewPSKReady(pqkem.RemoteID, pqkem.PSK) error { return nil }
func (pqNoopHandler) OnRekeyFailed(pqkem.RemoteID) error { return nil }
// TestPQAdapter_CapabilityRoleAware locks the role-aware capability signal: the KEM
// payload only flows initiator-offer -> responder-answer, so an empty message in the
// other direction comes from a perfectly capable peer and must NOT flag it. Only the
// message that should carry material (the answer we receive as initiator) marks a peer
// non-capable when empty.
func TestPQAdapter_CapabilityRoleAware(t *testing.T) {
// localID "zzzz" > "aaaa" => this manager is the KEM initiator for peer "aaaa".
mgr := pqkem.NewManager("zzzz", pqNoopHandler{}, nil)
defer mgr.Stop()
h := pqHandshaker{mgr: mgr}
// An empty OFFER from our peer is normal here: as the initiator's responder it puts
// its material in the answer, not the offer. It must not disable our offering.
h.AnswerPayload("aaaa", nil)
payload, _ := h.OfferPayload("aaaa")
require.NotNil(t, payload, "an empty offer from a responder-role peer must not mark it non-capable")
// An empty ANSWER to our offer means the peer does not run the KEM -> stop offering.
h.OnAnswer("aaaa", nil)
payload2, _ := h.OfferPayload("aaaa")
require.Nil(t, payload2, "an empty answer to our offer marks the peer non-capable, so we stop offering")
}

View File

@@ -0,0 +1,72 @@
package internal
import (
"fmt"
"net"
"net/netip"
log "github.com/sirupsen/logrus"
)
// DefaultPort is the preferred UDP port for the ML-KEM data-path service, bound on
// the WG overlay IP. Since each client owns a distinct overlay IP, this port is
// almost always free, so it need not be announced (peers assume it). A peer only
// announces Body.mlkemPort when a collision forced it onto a different port.
const DefaultPort = 51833
// pqTransport is the ML-KEM data-path transport: a dumb UDP socket bound on the WG
// overlay IP. It implements pqkem.Transport — the manager owns the remoteID<->endpoint
// routing and drives this socket's lifecycle (Run / Close).
type pqTransport struct {
conn *net.UDPConn
port int
}
// newPQTransport binds a UDP socket on the WG overlay IP, preferring DefaultPort and
// falling back to an OS-assigned ephemeral port if it is in use. Call it after the WG
// interface is up so the overlay IP is assigned; when the bound port is not
// DefaultPort it must be announced to peers via Body.mlkemPort.
func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) {
if !overlayIP.IsValid() {
return nil, fmt.Errorf("invalid overlay IP for pqkem transport")
}
ip := net.IP(overlayIP.AsSlice())
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: DefaultPort})
if err != nil {
log.Debugf("pqkem: default port %d unavailable on %s (%v), using an ephemeral port", DefaultPort, overlayIP, err)
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
if err != nil {
return nil, fmt.Errorf("bind pqkem udp on overlay %s: %w", overlayIP, err)
}
}
return &pqTransport{conn: conn, port: conn.LocalAddr().(*net.UDPAddr).Port}, nil
}
// Send implements pqkem.Transport.
func (t *pqTransport) Send(endpoint netip.AddrPort, msg []byte) error {
_, err := t.conn.WriteToUDPAddrPort(msg, endpoint)
return err
}
// LocalPort implements pqkem.Transport.
func (t *pqTransport) LocalPort() int { return t.port }
// Run implements pqkem.Transport: the receive loop, delivering each datagram as
// (source endpoint, msg). Exits when the socket is closed.
func (t *pqTransport) Run(onInbound func(src netip.AddrPort, msg []byte)) {
go func() {
buf := make([]byte, 2048)
for {
n, src, err := t.conn.ReadFromUDPAddrPort(buf)
if err != nil {
return
}
msg := make([]byte, n)
copy(msg, buf[:n])
onInbound(src, msg)
}
}()
}
// Close implements pqkem.Transport.
func (t *pqTransport) Close() error { return t.conn.Close() }

View File

@@ -1,35 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Preferences } from "@bindings/services";
export const useKeepConnectedOnQuit = () => {
const [keepConnected, setKeepConnected] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
Preferences.Get()
.then((prefs) => {
if (cancelled) return;
setKeepConnected(prefs?.keepConnectedOnQuit ?? false);
})
.catch((err: unknown) => {
if (cancelled) return;
console.warn("[useKeepConnectedOnQuit] load preferences failed", err);
setKeepConnected(false);
});
return () => {
cancelled = true;
};
}, []);
const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => {
setKeepConnected(keep);
try {
await Preferences.SetKeepConnectedOnQuit(keep);
} catch (err: unknown) {
setKeepConnected(!keep);
console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err);
}
}, []);
return { keepConnected, setKeepConnectedOnQuit };
};

View File

@@ -11,7 +11,6 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
export function SettingsGeneral() {
const { t } = useTranslation();
@@ -20,7 +19,6 @@ export function SettingsGeneral() {
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl();
const { mdm, features } = useRestrictions();
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
@@ -59,15 +57,6 @@ export function SettingsGeneral() {
helpText={t("settings.general.autostart.help")}
/>
)}
<FancyToggleSwitch
value={keepConnected ?? false}
onChange={(v) => {
void setKeepConnectedOnQuit(v);
}}
loading={keepConnected === null}
label={t("settings.general.keepConnectedOnQuit.label")}
helpText={t("settings.general.keepConnectedOnQuit.help")}
/>
</SectionGroup>
{!mdm.managementURL && !features.disableUpdateSettings && (

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Ändern des Autostarts fehlgeschlagen"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Nach dem Beenden verbunden bleiben",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Anzeigesprache"
},

View File

@@ -735,14 +735,6 @@
"message": "Autostart Change Failed",
"description": "Error-dialog title when changing the autostart setting fails."
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Stay Connected After Quitting",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Display Language",
"description": "Label for the display-language picker."

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Error al cambiar el inicio automático"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado al salir",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Idioma de la interfaz"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Échec de la modification du démarrage automatique"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Rester connecté après la fermeture",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Langue daffichage"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Az automatikus indítás módosítása sikertelen"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Kapcsolat megtartása kilépéskor",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Megjelenítési nyelv"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Modifica avvio automatico non riuscita"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Resta connesso dopo la chiusura",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Lingua dell'interfaccia"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "自動起動の変更に失敗しました"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "終了後も接続を維持",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "表示言語"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Falha ao alterar o início automático"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado ao sair",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Idioma de exibição"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "Не удалось изменить автозапуск"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Оставаться подключённым после выхода",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Язык интерфейса"
},

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": {
"message": "更改自启动设置失败"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "退出后保持连接",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "显示语言"
},

View File

@@ -180,7 +180,6 @@ func main() {
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
Preferences: prefStore,
})
listenForShowSignal(context.Background(), tray)

View File

@@ -58,10 +58,6 @@ type UIPreferences struct {
// decision has run for this OS user. It only ever transitions to true
// and is never reset, so the default-on flow runs at most once, ever.
AutostartInitialized bool `json:"autostartInitialized"`
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
// Its false zero value preserves the historical disconnect-on-quit
// behaviour for preference files written before the field existed.
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
}
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
@@ -187,26 +183,6 @@ func (s *Store) SetAutostartInitialized(done bool) error {
return nil
}
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
s.mu.Lock()
if s.current.KeepConnectedOnQuit == keep {
s.mu.Unlock()
return nil
}
next := s.current
next.KeepConnectedOnQuit = keep
if err := s.persistLocked(next); err != nil {
s.mu.Unlock()
return fmt.Errorf("persist preferences: %w", err)
}
s.current = next
s.mu.Unlock()
s.broadcast(next)
return nil
}
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
if lang == "" {

View File

@@ -238,42 +238,6 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
}
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
withTempConfigDir(t)
emitter := &recordingEmitter{}
s, err := NewStore(nil, emitter)
require.NoError(t, err)
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
require.NoError(t, s.SetKeepConnectedOnQuit(true))
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
require.NoError(t, s.SetKeepConnectedOnQuit(true))
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
reloaded, err := NewStore(nil, nil)
require.NoError(t, err)
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
}
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
withTempConfigDir(t)
// A preferences file written before the field existed must keep the
// historical disconnect-on-quit behaviour rather than silently opting out.
path, err := preferencesPath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
s, err := NewStore(nil, nil)
require.NoError(t, err)
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
}
func TestStore_ExistedAtLoad(t *testing.T) {
withTempConfigDir(t)

View File

@@ -49,11 +49,5 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
if app == nil || app.Screen == nil {
return p, true
}
// The wails GTK3 backend caches screens from the active window; a tray app
// has none at startup, so the cache is empty and PhysicalToDipPoint would
// dereference a nil nearest screen. Raw pixels are correct there anyway.
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
return p, true
}
return app.Screen.PhysicalToDipPoint(p), true
}

View File

@@ -34,7 +34,3 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done)
}
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
return s.store.SetKeepConnectedOnQuit(keep)
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/version"
)
@@ -51,9 +50,8 @@ type TrayServices struct {
WindowManager *services.WindowManager
// Session is bound to authsession directly because the services wrapper
// only re-exposes the React subset.
Session *authsession.Session
Localizer *Localizer
Preferences *preferences.Store
Session *authsession.Session
Localizer *Localizer
}
type Tray struct {
@@ -463,12 +461,10 @@ func (t *Tray) handleQuit() {
t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch()
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
}
t.app.Quit()
}

View File

@@ -1,40 +0,0 @@
//go:build linux && gtk3 && !(linux && 386)
package main
import (
"errors"
"github.com/godbus/dbus/v5"
)
// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process
// XEmbed StatusNotifierWatcher entirely. The real implementation
// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only
// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the
// C layer we stub the host out on gtk3 builds. The tray still works on every
// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator,
// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/
// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md.
// xembedHost is a placeholder so the package compiles on gtk3 builds; the real
// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never
// instantiated here because xembedTrayAvailable always reports false.
type xembedHost struct{}
// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because
// newXembedHost never returns a non-nil host.
func (*xembedHost) run() {}
// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe
// loop in startStatusNotifierWatcher exits immediately and newXembedHost is
// never reached. recenter_linux.go's predicate becomes a harmless no-op too.
func xembedTrayAvailable() bool {
return false
}
// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go;
// it is unreachable because xembedTrayAvailable returns false on gtk3.
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
return nil, errors.New("xembed host unsupported on gtk3 build")
}

View File

@@ -1,4 +1,4 @@
//go:build linux && !gtk3 && !(linux && 386)
//go:build linux && !(linux && 386)
package main

View File

@@ -1,5 +1,3 @@
//go:build linux && !gtk3 && !(linux && 386)
#include "xembed_tray_linux.h"
#include <X11/Xatom.h>

2
go.mod
View File

@@ -340,4 +340,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4

4
go.sum
View File

@@ -490,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=

View File

@@ -11,10 +11,6 @@ SED_STRIP_PADDING='s/=//g'
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
# Static IP for Traefik inside the compose bridge network. The management
# server trusts X-Forwarded-* headers from this address only.
TRAEFIK_IP="172.30.0.10"
check_docker_compose() {
if command -v docker-compose &> /dev/null; then
echo "docker-compose"
@@ -84,7 +80,7 @@ read_nb_domain() {
if ! check_domain_resolves "$value"; then
echo "" > /dev/stderr
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr
echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
local confirm=""
echo -n "Continue anyway? [y/N]: " > /dev/stderr
read -r confirm < /dev/tty
@@ -96,23 +92,6 @@ read_nb_domain() {
echo "$value"
}
read_letsencrypt_email() {
if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then
echo "$NETBIRD_LETSENCRYPT_EMAIL"
return
fi
local value=""
echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr
echo -n "Email address: " > /dev/stderr
read -r value < /dev/tty
if [[ -z "$value" ]]; then
echo "Email is required for Let's Encrypt." > /dev/stderr
read_letsencrypt_email
return
fi
echo "$value"
}
read_required() {
local prompt="$1"
local value=""
@@ -225,11 +204,11 @@ init_environment() {
check_openssl
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
echo "Generated files already exist in $(pwd)."
echo "If you want to reinitialize the environment, please remove them first:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
echo " rm -f .env docker-compose.yml config.yaml"
echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
echo "Be aware this will remove all data from the database."
exit 1
fi
@@ -251,9 +230,6 @@ init_environment() {
echo ""
NETBIRD_DOMAIN=$(read_nb_domain)
echo ""
NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email)
echo ""
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
@@ -262,7 +238,6 @@ init_environment() {
POSTGRES_DB="netbird"
POSTGRES_PASSWORD=$(rand_secret)
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
@@ -272,7 +247,6 @@ init_environment() {
echo "Selected:"
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
echo " Domain: ${NETBIRD_DOMAIN}"
echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}"
echo ""
echo "Rendering files into $(pwd) ..."
install -m 600 /dev/null .env
@@ -282,6 +256,7 @@ init_environment() {
if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
fi
render_caddyfile > Caddyfile
install -m 600 /dev/null config.yaml
render_config_yaml >> config.yaml
@@ -308,7 +283,7 @@ init_environment() {
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
echo ""
echo "Tail logs:"
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik"
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
}
# ------------------------------------------------------------------
@@ -331,11 +306,6 @@ NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
# Domain
NETBIRD_DOMAIN=${NETBIRD_DOMAIN}
# Reverse proxy (Traefik)
NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL}
NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6}
NETBIRD_TRAEFIK_IP=${TRAEFIK_IP}
# Image tags. Default to "latest"
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
@@ -408,78 +378,26 @@ EOF
render_compose_common() {
cat <<'EOF'
# Reverse proxy with automatic TLS via Let's Encrypt. Routes are declared as
# labels on the services below and picked up through the Docker provider.
traefik:
caddy:
<<: *default
image: traefik:${NETBIRD_TRAEFIK_TAG}
container_name: netbird-traefik
networks:
netbird:
ipv4_address: ${NETBIRD_TRAEFIK_IP}
command:
# Logging
- "--log.level=INFO"
- "--accesslog=true"
# Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=netbird"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.allowACMEByPass=true"
# readTimeout bounds the whole request, and gRPC streams / relay WebSockets
# never end one; idleTimeout would close the keep-alive connection they
# are reused over. Entrypoint-wide is the only scope Traefik offers here.
# writeTimeout is left alone: it already defaults to 0.
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=0"
# HTTP to HTTPS redirect
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt ACME
- "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
image: caddy:2
container_name: netbird-caddy
networks: [netbird]
environment:
- CADDY_SECURE_DOMAIN=${NETBIRD_DOMAIN}
ports:
- '443:443'
- '443:443/udp'
- '80:80'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- netbird_traefik_letsencrypt:/letsencrypt
labels:
- traefik.enable=true
# Shared security headers, referenced by every NetBird router below. A
# label-declared middleware only exists while its container runs, so this
# lives on Traefik itself: declaring it on an app container would drop
# every router referencing it whenever that container restarts.
- traefik.http.middlewares.nb-security.headers.stsSeconds=3600
- traefik.http.middlewares.nb-security.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.nb-security.headers.contentTypeNosniff=true
- traefik.http.middlewares.nb-security.headers.browserXssFilter=true
- traefik.http.middlewares.nb-security.headers.referrerPolicy=strict-origin-when-cross-origin
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.X-Frame-Options=SAMEORIGIN
# Empty value strips the header. Only the dashboard's nginx sets one; the
# server emits none. Do not quote it — "" would send a literal Server: "".
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.Server=
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
dashboard:
<<: *default
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
container_name: netbird-dashboard
networks: [netbird]
labels:
- traefik.enable=true
# Dashboard catch-all: lowest priority so every route below wins
- traefik.http.routers.netbird-dashboard.rule=Host(`${NETBIRD_DOMAIN}`)
- traefik.http.routers.netbird-dashboard.entrypoints=websecure
- traefik.http.routers.netbird-dashboard.tls=true
- traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-dashboard.middlewares=nb-security@docker
- traefik.http.routers.netbird-dashboard.service=dashboard
- traefik.http.routers.netbird-dashboard.priority=1
- traefik.http.services.dashboard.loadbalancer.server.port=80
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
@@ -517,28 +435,6 @@ render_compose_server() {
- netbird_data:/var/lib/netbird
- ./config.yaml:/etc/netbird/config.yaml
command: ["--config", "/etc/netbird/config.yaml"]
labels:
- traefik.enable=true
# Signal + Management gRPC (needs an h2c backend for HTTP/2 cleartext)
- traefik.http.routers.netbird-grpc.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-grpc.middlewares=nb-security@docker
- traefik.http.routers.netbird-grpc.service=netbird-server-h2c
- traefik.http.routers.netbird-grpc.priority=100
# Relay WebSocket, management API, and the embedded IdP
- traefik.http.routers.netbird-backend.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))
- traefik.http.routers.netbird-backend.entrypoints=websecure
- traefik.http.routers.netbird-backend.tls=true
- traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-backend.middlewares=nb-security@docker
- traefik.http.routers.netbird-backend.service=netbird-server
- traefik.http.routers.netbird-backend.priority=100
# Services
- traefik.http.services.netbird-server.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.scheme=h2c
environment:
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
@@ -601,18 +497,6 @@ render_compose_flow() {
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
- NB_FLOW_NATS_STREAM=traffic-events
- NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET}
labels:
- traefik.enable=true
# Flow receiver gRPC (h2c backend)
- traefik.http.routers.netbird-flow.rule=Host(`${NETBIRD_DOMAIN}`) && PathPrefix(`/flow.FlowService/`)
- traefik.http.routers.netbird-flow.entrypoints=websecure
- traefik.http.routers.netbird-flow.tls=true
- traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-flow.middlewares=nb-security@docker
- traefik.http.routers.netbird-flow.service=netbird-flow-h2c
- traefik.http.routers.netbird-flow.priority=100
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c
EOF
}
@@ -652,16 +536,61 @@ EOF
fi
cat <<'EOF'
netbird_postgres:
netbird_traefik_letsencrypt:
netbird_caddy_data:
networks:
netbird:
name: netbird
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/24
gateway: 172.30.0.1
EOF
}
render_caddyfile() {
cat <<'EOF'
{
servers :80,:443 {
protocols h1 h2c h2 h3
}
}
(security_headers) {
header * {
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
-Server
Referrer-Policy strict-origin-when-cross-origin
}
}
:80 {
redir https://{$CADDY_SECURE_DOMAIN}{uri} permanent
}
{$CADDY_SECURE_DOMAIN}:443 {
import security_headers
# Signal (gRPC over h2c)
reverse_proxy /signalexchange.SignalExchange/* h2c://netbird-server:80
# Management (gRPC over h2c + HTTP)
reverse_proxy /management.ManagementService/* h2c://netbird-server:80
reverse_proxy /api/* netbird-server:80
reverse_proxy /ws-proxy/* netbird-server:80
# Embedded IdP (OAuth2 endpoints served by netbird server)
reverse_proxy /oauth2/* netbird-server:80
# Relay (WebSocket multiplexed on the same port)
reverse_proxy /relay* netbird-server:80
EOF
if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then
cat <<'EOF'
# Flow receiver (gRPC over h2c)
reverse_proxy /flow.FlowService/* h2c://receiver:80
EOF
fi
cat <<'EOF'
# Dashboard
reverse_proxy /* dashboard:80
}
EOF
}
@@ -680,7 +609,7 @@ server:
logLevel: "info"
logFile: "console"
# TLS is terminated by Traefik in front; leave this block empty.
# TLS is terminated by Caddy in front; leave this block empty.
tls:
certFile: ""
keyFile: ""
@@ -697,23 +626,12 @@ server:
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
localAuthDisabled: false
signKeyRefreshEnabled: false
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
dashboardRedirectURIs:
- "https://${NETBIRD_DOMAIN}/nb-auth"
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"
cliRedirectURIs:
- "http://localhost:53000/"
# Trust X-Forwarded-* only from the Traefik container's static address. Both
# keys must stay in step with the ipv4_address pinned in docker-compose.yml:
# trustedPeers decides whether forwarded headers are read at all, and leaving
# it unset falls back to 0.0.0.0/0.
reverseProxy:
trustedPeers:
- "${TRAEFIK_IP}/32"
trustedHTTPProxies:
- "${TRAEFIK_IP}/32"
store:
engine: "postgres"
dsn: "${POSTGRES_DSN}"

View File

@@ -348,7 +348,6 @@ initialize_default_values() {
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)
NETBIRD_STUN_PORT=3478
# Docker images
@@ -528,8 +527,7 @@ generate_configuration_files() {
# Common files for all configurations
render_dashboard_env > dashboard.env
install -m 600 /dev/null config.yaml
render_combined_yaml >> config.yaml
render_combined_yaml > config.yaml
return 0
}
@@ -913,7 +911,6 @@ server:
auth:
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
signKeyRefreshEnabled: true
sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"
dashboardRedirectURIs:
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"

View File

@@ -15,11 +15,7 @@ set -o pipefail
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
#
# If any step fails once the stack has been touched, the script rolls itself
# back automatically: generated files are removed, the Postgres volume this run
# created is dropped, and the original deployment is started again.
#
# To revert a successful migration:
# To revert:
# docker compose down
# rm -f docker-compose.override.yml config.yaml.enterprise
# # If Postgres migration was done, also restore the SQLite backup printed
@@ -29,15 +25,6 @@ set -o pipefail
OVERRIDE_FILE="docker-compose.override.yml"
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
# Rollback bookkeeping. ROLLBACK_STATE flips to "armed" the moment the script
# starts mutating the deployment, and back to "disarmed" once the migration has
# completed successfully.
ROLLBACK_STATE="disarmed"
ENV_EXISTED="unknown"
ENV_BACKUP=""
PG_VOLUME_NAME=""
BACKUP_DIR=""
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
check_docker_compose() {
@@ -374,77 +361,7 @@ render_enterprise_config() {
# Execution steps
# ---------------------------------------------------------------------------
combined_container_id() {
$DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1
}
container_data_mount() {
local container="$1"
[[ -n "$container" ]] || return 0
docker inspect "$container" --format \
'{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null
}
# The name comes from the container, so `-v` cannot invent an empty volume here.
# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not
# be reported as empty: that would abort a healthy migration over a pull error
# or an unreadable bind mount.
data_dir_state() {
local src="$1" out
if [[ "$src" == /* ]]; then
[[ -d "$src" ]] || return 2
out=$(ls -A "$src" 2>/dev/null) || return 2
else
docker volume inspect "$src" &> /dev/null || return 0
out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2
fi
[[ -z "$out" ]] && return 0
return 1
}
check_data_directory() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
local container
container=$(combined_container_id)
if [[ -z "$container" ]]; then
echo "" > /dev/stderr
echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr
echo "The migration backs up the store by copying it out of that container," > /dev/stderr
echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr
echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
exit 1
fi
local src
src=$(container_data_mount "$container")
if [[ -z "$src" ]]; then
echo "" > /dev/stderr
echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr
echo "Cannot locate the NetBird store to back it up." > /dev/stderr
exit 1
fi
local state=0
data_dir_state "$src" || state=$?
if [[ $state -eq 0 ]]; then
echo "" > /dev/stderr
echo "The NetBird data directory is empty:" > /dev/stderr
echo " $src" > /dev/stderr
echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr
echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr
exit 1
fi
if [[ $state -eq 2 ]]; then
echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr
echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr
fi
echo " Data directory: $src"
}
# Only for the Postgres volume, which has no container to read it off yet.
resolve_compose_volume() {
resolve_data_volume() {
local short="$1"
local actual
# Resolve project-prefixed volume name from Docker Compose config first.
@@ -474,21 +391,18 @@ resolve_compose_volume() {
backup_sqlite() {
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
local container
container=$(combined_container_id)
if [[ -z "$container" ]]; then
echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr
exit 1
fi
echo "Backing up the NetBird store to $BACKUP_DIR ..."
docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"
local data_volume_actual
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
docker run --rm \
-v "${data_volume_actual}:/var/lib/netbird:ro" \
-v "${BACKUP_DIR}:/backup" \
busybox \
sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
local copied
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
if [[ -z "$copied" ]]; then
echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr
echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
exit 1
fi
echo " done"
@@ -500,135 +414,6 @@ run_migrate_store() {
echo " done"
}
# ---------------------------------------------------------------------------
# Rollback — a failed run must not leave the operator with a stopped stack and
# half-written artifacts.
# ---------------------------------------------------------------------------
# Resolve the name Compose would give the Postgres volume before the override
# exists, so a leftover volume can be spotted up front.
compose_project_name() {
local container project
container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1)
if [[ -n "$container" ]]; then
project=$(docker inspect "$container" \
--format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null)
if [[ -n "$project" ]]; then
echo "$project"
return 0
fi
fi
project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null)
if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then
echo "$project"
fi
return 0
}
postgres_volume_name() {
local project
project=$(compose_project_name)
if [[ -n "$project" ]]; then
echo "${project}_netbird_postgres"
fi
return 0
}
# Postgres skips initdb when its data directory is non-empty, so a volume left
# behind by an interrupted run would keep the old password and old contents,
# and migrate-store would fail against it.
check_stale_postgres_volume() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
PG_VOLUME_NAME=$(postgres_volume_name)
if [[ -z "$PG_VOLUME_NAME" ]]; then
echo ""
echo " ⚠ Could not determine the Compose project name, so a Postgres volume"
echo " left over from an earlier attempt cannot be checked for. If a"
echo " previous run failed, remove it before continuing:"
echo " docker volume ls | grep netbird_postgres"
return 0
fi
docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0
echo ""
echo " ⚠ A Postgres volume from an earlier attempt already exists:"
echo " $PG_VOLUME_NAME"
echo " Postgres does not re-initialise a non-empty data directory, so the"
echo " migration would run against stale credentials and stale data."
local remove
remove=$(read_yes_no " Remove it and continue?" "y")
if [[ "$remove" != "yes" ]]; then
echo "" > /dev/stderr
echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr
exit 1
fi
docker volume rm "$PG_VOLUME_NAME" > /dev/null
echo " Removed."
}
# Undo whatever this run changed and start the previous deployment again.
rollback() {
ROLLBACK_STATE="done"
echo ""
echo "──────────────────────────────────────────────────────────────────────"
echo " Migration failed — restoring the previous deployment"
echo "──────────────────────────────────────────────────────────────────────"
# Resolve while the override is still present; without it Compose no longer
# knows about the Postgres volume.
local pg_volume="$PG_VOLUME_NAME"
if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
pg_volume=$(postgres_volume_name)
fi
echo ""
echo "Stopping services ..."
$DOCKER_COMPOSE_COMMAND down || true
echo "Removing generated files ..."
rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE"
# Restore .env to exactly what it was, or remove it if this run created it.
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr
elif [[ "$ENV_EXISTED" == "no" ]]; then
rm -f .env || true
fi
# Only ever the volume this run created — never the NetBird data volume.
if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then
echo "Removing Postgres volume $pg_volume ..."
docker volume rm "$pg_volume" &> /dev/null || true
fi
echo "Starting the previous deployment ..."
if ! $DOCKER_COMPOSE_COMMAND up -d; then
echo ""
echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr
echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
fi
echo ""
echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data"
echo "volume were never modified."
if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
echo "The SQLite backup taken during this run is kept at:"
echo " $BACKUP_DIR"
fi
echo "──────────────────────────────────────────────────────────────────────"
}
on_exit() {
local code=$?
trap - EXIT
if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then
rollback
fi
exit $code
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -756,15 +541,9 @@ init_migration() {
ENABLE_FLOW="no"
echo "Step 3 (traffic flow) skipped — requires Postgres."
fi
check_data_directory
check_stale_postgres_volume
}
apply_changes() {
# From here on a failure must roll the deployment back.
ROLLBACK_STATE="armed"
echo ""
echo "Writing $OVERRIDE_FILE ..."
install -m 644 /dev/null "$OVERRIDE_FILE"
@@ -785,14 +564,6 @@ apply_changes() {
# picks it up automatically.
echo "Writing .env additions (mode 600) ..."
local ENV_FILE=".env"
# Snapshot the operator's .env so a rollback can restore it byte for byte.
if [[ -f "$ENV_FILE" ]]; then
ENV_EXISTED="yes"
ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)"
cp -p "$ENV_FILE" "$ENV_BACKUP"
else
ENV_EXISTED="no"
fi
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
{
@@ -821,16 +592,11 @@ apply_changes() {
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
echo ""
# Stop, but keep the containers: the backup reads the store out of one.
echo "Stopping services so the store is quiescent ..."
$DOCKER_COMPOSE_COMMAND stop
echo "Stopping existing services (volumes preserved) ..."
$DOCKER_COMPOSE_COMMAND down
backup_sqlite
echo ""
echo "Removing stopped containers (volumes preserved) ..."
$DOCKER_COMPOSE_COMMAND down
echo ""
echo "Starting Postgres ..."
$DOCKER_COMPOSE_COMMAND up -d postgres
@@ -860,9 +626,6 @@ apply_changes() {
echo ""
echo "Migration complete."
# Nothing left to undo.
ROLLBACK_STATE="disarmed"
}
print_summary() {
@@ -880,7 +643,6 @@ print_summary() {
echo " $OVERRIDE_FILE"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
echo " .env (license key + secrets, mode 600)"
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
echo ""
echo " Tail logs:"
@@ -889,27 +651,19 @@ print_summary() {
echo "──────────────────────────────────────────────────────────────────────"
echo " To revert"
echo "──────────────────────────────────────────────────────────────────────"
echo " $DOCKER_COMPOSE_COMMAND down"
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
# Resolve the project-prefixed volume name now, before the override is gone.
local pg_volume
pg_volume=$(resolve_compose_volume "netbird_postgres")
echo " # Stop, but keep the containers so the store can be copied back in:"
echo " $DOCKER_COMPOSE_COMMAND stop"
echo " # Restore SQLite from the backup created during this run:"
echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
echo " $DOCKER_COMPOSE_COMMAND down"
# Resolve project-prefixed volume names now (before override is removed).
local pg_volume data_volume_actual
pg_volume=$(resolve_data_volume "netbird_postgres")
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
echo " # Remove the Postgres volume FIRST, before deleting the override file:"
echo " docker volume rm $pg_volume"
else
echo " $DOCKER_COMPOSE_COMMAND down"
echo " # Restore SQLite from the backup created during this run:"
echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
fi
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
elif [[ "$ENV_EXISTED" == "no" ]]; then
echo " rm -f .env # created by this run"
else
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
fi
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
echo " $DOCKER_COMPOSE_COMMAND up -d"
echo "──────────────────────────────────────────────────────────────────────"
}
@@ -918,10 +672,6 @@ print_summary() {
# Run
# ---------------------------------------------------------------------------
trap on_exit EXIT
# Turn signals into a normal exit so the EXIT trap can roll back.
trap 'exit 130' INT TERM
init_migration
apply_changes
print_summary

View File

@@ -7,7 +7,6 @@ import (
"slices"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
@@ -746,10 +745,6 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty
return &GroupLinkError{"network router", linkedRouter.ID}
}
if isLinked, linkedService := isGroupLinkedToReverseProxyService(ctx, transaction, group.AccountID, group.ID); isLinked {
return &GroupLinkError{"reverse proxy service", linkedService.Domain}
}
if isLinked, linkedPolicy := isGroupLinkedToAgentNetworkPolicy(ctx, transaction, group.AccountID, group.ID); isLinked {
return &GroupLinkError{"agent network policy", linkedPolicy.Name}
}
@@ -885,26 +880,6 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
return false, nil
}
// isGroupLinkedToReverseProxyService checks if a group is used as an access group
// of a private reverse proxy service or as a bearer-auth distribution group.
func isGroupLinkedToReverseProxyService(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *service.Service) {
services, err := transaction.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
if err != nil {
log.WithContext(ctx).Errorf("error retrieving reverse proxy services while checking group linkage: %v", err)
return false, nil
}
for _, svc := range services {
if svc.Private && slices.Contains(svc.AccessGroups, groupID) {
return true, svc
}
if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled && slices.Contains(svc.Auth.BearerAuth.DistributionGroups, groupID) {
return true, svc
}
}
return false, nil
}
// isGroupLinkedToAgentNetworkPolicy checks if a group is used as a source group by any
// agent network policy in the account.
func isGroupLinkedToAgentNetworkPolicy(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.Policy) {

View File

@@ -19,7 +19,6 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
@@ -132,16 +131,6 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
"grp-for-agent-network-policy",
"agent network policy",
},
{
"reverse proxy private service access group",
"grp-for-rp-private",
"reverse proxy service",
},
{
"reverse proxy bearer distribution group",
"grp-for-rp-bearer",
"reverse proxy service",
},
}
for _, testCase := range testCases {
@@ -240,12 +229,6 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
groupIDs: []string{"grp-for-agent-network-policy"},
expectedReasons: []string{"agent network policy"},
},
{
name: "reverse proxy services",
groupIDs: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
expectedReasons: []string{"reverse proxy service", "reverse proxy service"},
expectedNotDeleted: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
},
{
name: "successfully delete multiple groups",
groupIDs: []string{"group-1", "group-2"},
@@ -313,65 +296,6 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
}
}
func TestDefaultAccountManager_DeleteGroupUnlinkedFromReverseProxyService(t *testing.T) {
am, _, err := createManager(t)
require.NoError(t, err, "Failed to create account manager")
_, account, err := initTestGroupAccount(am)
require.NoError(t, err, "Failed to init testing account")
deletableGroups := []*types.Group{
{
ID: "grp-rp-bearer-disabled",
AccountID: account.Id,
Name: "Group only in a disabled bearer auth",
Issued: types.GroupIssuedAPI,
Peers: make([]string, 0),
},
{
ID: "grp-rp-nonprivate-access",
AccountID: account.Id,
Name: "Group only in a non-private service's access groups",
Issued: types.GroupIssuedAPI,
Peers: make([]string, 0),
},
}
for _, group := range deletableGroups {
require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, group))
}
// Disabled bearer auth and stale access groups on a non-private service
// are inert configuration and must not block group deletion.
services := []*rpservice.Service{
{
ID: "rp-svc-bearer-disabled",
AccountID: account.Id,
Domain: "bearer-disabled.services.example.com",
Auth: rpservice.AuthConfig{
BearerAuth: &rpservice.BearerAuthConfig{
Enabled: false,
DistributionGroups: []string{"grp-rp-bearer-disabled"},
},
},
},
{
ID: "rp-svc-nonprivate-access",
AccountID: account.Id,
Domain: "nonprivate.services.example.com",
Private: false,
AccessGroups: []string{"grp-rp-nonprivate-access"},
},
}
for _, svc := range services {
require.NoError(t, am.Store.CreateService(context.Background(), svc))
}
for _, group := range deletableGroups {
err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, group.ID)
assert.NoError(t, err, "group %s is not referenced by an active reverse proxy gate and should be deletable", group.ID)
}
}
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
am, _, err := createManager(t)
require.NoError(t, err)
@@ -501,22 +425,6 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
Peers: make([]string, 0),
}
groupForRPPrivate := &types.Group{
ID: "grp-for-rp-private",
AccountID: "account-id",
Name: "Group for private reverse proxy service",
Issued: types.GroupIssuedAPI,
Peers: make([]string, 0),
}
groupForRPBearer := &types.Group{
ID: "grp-for-rp-bearer",
AccountID: "account-id",
Name: "Group for bearer reverse proxy service",
Issued: types.GroupIssuedAPI,
Peers: make([]string, 0),
}
routeResource := &route.Route{
ID: "example route",
Groups: []string{groupForRoute.ID},
@@ -573,8 +481,6 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkPolicy)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPPrivate)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPBearer)
agentNetworkPolicy := &agentNetworkTypes.Policy{
ID: "example agent network policy",
@@ -587,52 +493,6 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
return nil, nil, err
}
// The decoy services are created first so the linkage check has to scan
// past services that do not reference the groups under test.
rpServices := []*rpservice.Service{
{
ID: "rp-svc-private-decoy",
AccountID: accountID,
Domain: "private-decoy.services.example.com",
Private: true,
AccessGroups: []string{"unrelated-group"},
},
{
ID: "rp-svc-bearer-decoy",
AccountID: accountID,
Domain: "bearer-decoy.services.example.com",
Auth: rpservice.AuthConfig{
BearerAuth: &rpservice.BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{"unrelated-group"},
},
},
},
{
ID: "rp-svc-private",
AccountID: accountID,
Domain: "private.services.example.com",
Private: true,
AccessGroups: []string{groupForRPPrivate.ID},
},
{
ID: "rp-svc-bearer",
AccountID: accountID,
Domain: "bearer.services.example.com",
Auth: rpservice.AuthConfig{
BearerAuth: &rpservice.BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{groupForRPBearer.ID},
},
},
},
}
for _, svc := range rpServices {
if err := am.Store.CreateService(context.Background(), svc); err != nil {
return nil, nil, err
}
}
acc, err := am.Store.GetAccount(context.Background(), account.Id)
if err != nil {
return nil, nil, err

View File

@@ -4,67 +4,9 @@ set -x
LOG_FILE=/var/log/netbird/client_pre_install.log
AGENT=/usr/local/bin/netbird
UI_PROCESS=netbird-ui
mkdir -p /var/log/netbird/
# wait_for_ui_exit polls for up to $1 seconds, returning 0 as soon as no UI
# process is left and 1 if one is still running when the time is up.
wait_for_ui_exit() {
waited=0
while [ "$waited" -lt "$1" ]; do
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
sleep 1
waited=$((waited + 1))
done
return 1
}
# request_ui_quit asks the UI to quit from inside the console user's session and
# reports whether the request could be sent at all. The installer runs as root
# outside that session, so a quit Apple event sent straight from here always
# fails with -600.
request_ui_quit() {
console_user=$(stat -f%Su /dev/console 2>/dev/null)
case "$console_user" in
""|root|loginwindow|_mbsetupuser)
echo "No active GUI user session (console user: '${console_user:-none}'); skipping the quit request."
return 1
;;
esac
uid=$(id -u "$console_user" 2>/dev/null)
if [ -z "$uid" ]; then
echo "Could not resolve uid for console user '$console_user'; skipping the quit request."
return 1
fi
echo "Asking the NetBird UI to quit as console user $console_user (uid $uid)."
launchctl asuser "$uid" sudo -u "$console_user" -H osascript -e 'quit app "NetBird"' || true
}
# quit_ui stops a running UI so the app bundle can be replaced underneath it. A
# UI process that survives the install keeps serving the old binary until it is
# quit by hand, so anything still running once the quit request is out of the
# way is signalled. Waiting for a graceful exit only makes sense when a quit
# request was actually sent.
quit_ui() {
if request_ui_quit && wait_for_ui_exit 10; then
return 0
fi
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
echo "NetBird UI still running; terminating it."
pkill -x "$UI_PROCESS" || true
if wait_for_ui_exit 3; then
return 0
fi
echo "NetBird UI ignored SIGTERM; killing it."
pkill -KILL -x "$UI_PROCESS" || true
}
{
# check if it was installed with brew
brew list --formula | grep netbird
@@ -73,9 +15,10 @@ quit_ui() {
echo "NetBird has been installed with Brew. Please use Brew to update the package."
exit 1
fi
quit_ui
osascript -e 'quit app "Netbird"' || true
$AGENT service stop || true
echo "Preinstall complete"
exit 0 # all good
} &> $LOG_FILE

View File

@@ -156,11 +156,9 @@ func (g *Guard) notifyReconnected() {
func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
bo := backoff.WithContext(&backoff.ExponentialBackOff{
InitialInterval: 2 * time.Second,
// Spreads the reconnects of every client that lost the same relay server.
RandomizationFactor: backoff.DefaultRandomizationFactor,
Multiplier: 2,
MaxInterval: g.maxBackoffInterval,
Clock: backoff.SystemClock,
Multiplier: 2,
MaxInterval: g.maxBackoffInterval,
Clock: backoff.SystemClock,
}, ctx)
return backoff.NewTicker(bo)

View File

@@ -52,6 +52,11 @@ type CredentialPayload struct {
Credential *Credential
RosenpassPubKey []byte
RosenpassAddr string
// MlkemPayload is the opaque post-quantum KEM handshake message riding this
// OFFER/ANSWER (see Body.mlkemPayload). Nil when not running the PQ exchange.
MlkemPayload []byte
// MlkemPort is the sender's ML-KEM PQ service UDP port (0 when not running).
MlkemPort int
RelaySrvAddress string
RelaySrvIP netip.Addr
SessionID []byte
@@ -89,6 +94,13 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload)
if p.RelaySrvIP.IsValid() {
body.RelayServerIP = p.RelaySrvIP.Unmap().AsSlice()
}
if len(p.MlkemPayload) > 0 {
body.MlkemPayload = p.MlkemPayload
}
if p.MlkemPort > 0 {
port := uint32(p.MlkemPort)
body.MlkemPort = &port
}
return &proto.Message{
Key: myKey.PublicKey().String(),
RemoteKey: remoteKey,

View File

@@ -239,6 +239,16 @@ type Body struct {
// fallback dial target when DNS resolution of relayServerAddress fails.
// SNI/TLS verification still uses relayServerAddress.
RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"`
// mlkemPayload carries a post-quantum X25519MLKEM768 handshake message that
// seeds the WireGuard PSK, riding this Body's OFFER/ANSWER: on an OFFER it is
// the KEM offer, on an ANSWER the KEM answer. It is opaque to signal — the
// pqkem library frames and parses it. Absent when the sender does not run the
// ML-KEM PQ exchange; unknown to older clients, which ignore it.
MlkemPayload []byte `protobuf:"bytes,12,opt,name=mlkemPayload,proto3,oneof" json:"mlkemPayload,omitempty"`
// mlkemPort is the UDP port of the sender's ML-KEM PQ service, bound on its
// WireGuard overlay IP. Peers send subsequent rekey messages there over the
// data path. Zero/absent when the ML-KEM PQ exchange is not running.
MlkemPort *uint32 `protobuf:"varint,13,opt,name=mlkemPort,proto3,oneof" json:"mlkemPort,omitempty"`
}
func (x *Body) Reset() {
@@ -343,6 +353,20 @@ func (x *Body) GetRelayServerIP() []byte {
return nil
}
func (x *Body) GetMlkemPayload() []byte {
if x != nil {
return x.MlkemPayload
}
return nil
}
func (x *Body) GetMlkemPort() uint32 {
if x != nil && x.MlkemPort != nil {
return *x.MlkemPort
}
return 0
}
// Mode indicates a connection mode
type Mode struct {
state protoimpl.MessageState
@@ -466,7 +490,7 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xbd, 0x05, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
@@ -494,39 +518,46 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x6d, 0x6c, 0x6b,
0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x48,
0x03, 0x52, 0x0c, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88,
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f, 0x72, 0x74, 0x18,
0x0d, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x04, 0x52, 0x09, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f,
0x72, 0x74, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a,
0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57,
0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54,
0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, 0x04, 0x12, 0x0b, 0x0a,
0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45,
0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65,
0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x42, 0x10,
0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50,
0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61,
0x64, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f, 0x72, 0x74, 0x4a,
0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a,
0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52,
0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64,
0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65,
0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b,
0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45,
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12,
0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74,
0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61,
0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01,
0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (

View File

@@ -75,6 +75,18 @@ message Body {
// fallback dial target when DNS resolution of relayServerAddress fails.
// SNI/TLS verification still uses relayServerAddress.
optional bytes relayServerIP = 11;
// mlkemPayload carries a post-quantum X25519MLKEM768 handshake message that
// seeds the WireGuard PSK, riding this Body's OFFER/ANSWER: on an OFFER it is
// the KEM offer, on an ANSWER the KEM answer. It is opaque to signal — the
// pqkem library frames and parses it. Absent when the sender does not run the
// ML-KEM PQ exchange; unknown to older clients, which ignore it.
optional bytes mlkemPayload = 12;
// mlkemPort is the UDP port of the sender's ML-KEM PQ service, bound on its
// WireGuard overlay IP. Peers send subsequent rekey messages there over the
// data path. Zero/absent when the ML-KEM PQ exchange is not running.
optional uint32 mlkemPort = 13;
}
// Mode indicates a connection mode