Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	shared/management/proto/management.pb.go
This commit is contained in:
riccardom
2026-10-09 17:12:38 +02:00
140 changed files with 12817 additions and 1661 deletions
+37
View File
@@ -207,6 +207,43 @@ jobs:
# regression test, and need no frontend bundle.
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
test_client_pkcs11:
name: "Client PKCS#11 / Unit"
# The deb and rpm packages ship the client built with the pkcs11 tag, which loads
# PKCS#11 modules through purego. The other jobs build without the tag, so this one
# compiles that driver and signs with a real SoftHSM token, the build release ships.
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Install SoftHSM
run: sudo apt update && sudo apt install -y -q softhsm2
- name: Initialise token
run: |
mkdir -p "$RUNNER_TEMP/softhsm/tokens"
printf 'directories.tokendir = %s\nobjectstore.backend = file\n' "$RUNNER_TEMP/softhsm/tokens" > "$RUNNER_TEMP/softhsm/softhsm2.conf"
echo "SOFTHSM2_CONF=$RUNNER_TEMP/softhsm/softhsm2.conf" >> "$GITHUB_ENV"
SOFTHSM2_CONF="$RUNNER_TEMP/softhsm/softhsm2.conf" softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
- name: Vet
run: CGO_ENABLED=0 go vet -tags pkcs11 ./client/internal/pkcs11/... ./client/internal/certproof/...
- name: Test
env:
NB_TEST_PKCS11_URI: "pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234"
NB_TEST_PKCS11_DISPOSABLE: "1"
run: CGO_ENABLED=0 go test -tags pkcs11 -timeout 5m ./client/internal/pkcs11/... ./client/internal/certproof/...
test_client_on_docker:
name: "Client (Docker) / Unit"
needs: [build-cache]
+3 -1
View File
@@ -363,7 +363,9 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
path: |
dist/netbird_linux**
dist/netbird-pkcs11_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
+27 -2
View File
@@ -40,7 +40,28 @@ builds:
tags:
- load_wgnt_from_rsrc
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch.
# deb and rpm packages target glibc distributions, so they carry the PKCS#11 store, which
# links libc. Tarballs and the Alpine-based images keep the static build above.
- id: netbird-pkcs11
dir: client
binary: netbird
env: [CGO_ENABLED=0]
goos:
- linux
goarch:
- arm
- amd64
- arm64
- 386
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:
- load_wgnt_from_rsrc
- pkcs11
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. They
# carry the PKCS#11 store like the deb build above.
- &netbird_rpm_build
id: netbird-rpm-amd64
dir: client
@@ -53,6 +74,7 @@ builds:
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- load_wgnt_from_rsrc
- pkcs11
- <<: *netbird_rpm_build
id: netbird-rpm-arm64
@@ -268,7 +290,7 @@ nfpms:
id: netbird_deb
bindir: /usr/bin
builds:
- netbird
- netbird-pkcs11
formats:
- deb
scripts:
@@ -311,6 +333,9 @@ nfpms:
- src: release_files/netbird.sysconfig
dst: /etc/sysconfig/netbird
type: config|noreplace
# May hold the PKCS#11 token PIN (NB_TPM_PIN), so readable by root alone.
file_info:
mode: 0600
scripts:
postinstall: "release_files/post_install.sh"
preremove: "release_files/pre_remove.sh"
+30
View File
@@ -0,0 +1,30 @@
package cmd
import (
"github.com/spf13/cobra"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/certproof"
)
var postureCmd = &cobra.Command{
Use: "posture",
Short: "Posture helpers invoked by the NetBird daemon",
Hidden: true,
}
var postureCertProofCmd = &cobra.Command{
Use: "cert-proof",
Short: "Answer certificate posture challenges from the calling user's keychain",
Long: "Reads a certificate challenge set as JSON on stdin and writes the proofs as JSON on stdout.\n" +
"The daemon launches this in the console user's desktop session, because a login keychain\n" +
"cannot be reached from a root daemon. Not intended to be run by hand.",
Hidden: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
// Proofs travel on stdout, so every log line has to go elsewhere.
log.SetOutput(cmd.ErrOrStderr())
return certproof.RunHelper(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout())
},
}
+3
View File
@@ -180,6 +180,9 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(postureCmd)
postureCmd.AddCommand(postureCertProofCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
+194 -153
View File
@@ -4,12 +4,20 @@
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
// fallback dialog.
//
// The deadline is an absolute wall-clock instant, so the watcher compares
// it against the wall clock on a ticker rather than arming a relative
// timer for it. A relative timer runs on the monotonic clock, which does
// not advance while a device is suspended: it fires once that much awake
// time has passed, which can be long after the deadline, and nothing
// re-evaluates when the device wakes up. Polling makes every tick after a
// resume (or after an NTP correction) see the real remaining time.
//
// The watcher is idempotent: Update may be called as often as the network
// map snapshots arrive. Repeating the same deadline is a no-op; a new
// deadline reschedules the timers and arms a fresh warning cycle.
// deadline starts a fresh warning cycle.
//
// Warning firing is edge-detected. Each unique deadline value fires each
// warning callback at most once.
// Warning firing is edge-detected. Each unique deadline value publishes
// each warning at most once.
package sessionwatch
import (
@@ -28,9 +36,15 @@ const (
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
// glitch, and silently arming a 100-year timer would hide the bug.
// glitch, and silently tracking a 100-year deadline would hide the bug.
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
// defaultEvalInterval is how often the tracked deadline is compared
// against the wall clock. The leads are minutes, so a coarse tick costs
// nothing in accuracy and keeps the wakeup cheap on battery-powered
// devices.
defaultEvalInterval = 10 * time.Second
// WarningLead is how far before expiry the first (interactive)
// warning fires. Drives the T-10 OS notification with
// Extend/Dismiss actions.
@@ -92,18 +106,21 @@ type StatusRecorder interface {
type Watcher struct {
lead time.Duration
finalLead time.Duration
deadlineOnly bool
interval time.Duration
deadlineOnly bool // record the deadline, leave the warnings to the caller
mu sync.Mutex
current time.Time
timer *time.Timer
finalTimer *time.Timer
firedAt time.Time // deadline value the T-WarningLead callback last fired against
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
firedAt time.Time // deadline value the T-WarningLead warning last published for
finalFiredAt time.Time // deadline value the T-FinalWarningLead warning last published for
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates the final warning
announcedAt time.Time // deadline value the recorder has been told about; gates publishing
closed bool
recorder StatusRecorder
nowFn func() time.Time
stop chan struct{} // closed to stop the evaluation loop; nil while it is not running
done chan struct{} // closed by the loop on its way out
wake chan struct{} // buffered nudge asking the loop to evaluate before its next tick
}
// New returns a watcher with the package defaults WarningLead and
@@ -115,20 +132,24 @@ func New(recorder StatusRecorder) *Watcher {
}
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
// final must be strictly less than lead; otherwise both timers fire in the
// wrong order or simultaneously and the UI flow breaks. A zero final lead
// disables the final-warning timer entirely (see armTimerLocked) so a
// millisecond-scale deadline doesn't flush both timers in one tick.
// final must be strictly less than lead; otherwise the final warning takes
// over the whole warning window and the interactive notification never
// shows. A zero final lead disables the final warning entirely (see
// evaluate), leaving the interactive one as the only warning.
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
return &Watcher{
lead: lead,
finalLead: final,
interval: defaultEvalInterval,
recorder: recorder,
nowFn: time.Now,
}
}
// NewDeadlineOnly returns a watcher that validates and records deadlines but arms no warning timers.
// NewDeadlineOnly returns a watcher that validates and records deadlines
// but publishes no warnings about them, and runs no evaluation loop to
// decide. Used where the deadline is handed on to something that schedules
// the warnings itself, such as the Android app.
func NewDeadlineOnly(recorder StatusRecorder) *Watcher {
w := New(recorder)
w.deadlineOnly = true
@@ -139,11 +160,12 @@ func NewDeadlineOnly(recorder StatusRecorder) *Watcher {
// a Sync push from the server omits the field because login expiration
// was disabled).
//
// Same-value updates are no-ops. A different non-zero value cancels any
// pending timer, resets the "already fired" guards, and — when the
// deadline lies in the future — arms fresh warning timers. A deadline
// already in the past (within maxPastHorizon) is recorded as-is with no
// timers: the session has expired and consumers render it that way.
// Same-value updates are no-ops. A different non-zero value resets the
// "already fired" guards and starts a fresh warning cycle, evaluated
// immediately so a deadline that already sits inside a warning window
// warns without waiting for the next tick. A deadline already in the past
// (within maxPastHorizon) is recorded as-is and warns nothing: the session
// has expired and consumers render it that way.
//
// Returns one of the sentinel Err* values when the deadline fails the
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
@@ -163,7 +185,7 @@ func (w *Watcher) Update(deadline time.Time) error {
return nil
}
now := time.Now()
now := w.nowFn()
switch {
case deadline.Before(time.Unix(0, 0)):
w.clearLocked()
@@ -181,18 +203,22 @@ func (w *Watcher) Update(deadline time.Time) error {
return nil
}
w.stopTimerLocked()
w.current = deadline
// Reset every per-deadline guard so a refreshed deadline arms a fresh
// Reset every per-deadline guard so a refreshed deadline starts a fresh
// warning cycle: both edge triggers and the user Dismiss decision
// (the user agreed to the old deadline expiring; a new deadline
// restarts the contract).
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
w.announcedAt = time.Time{}
if deadline.After(now) && !w.deadlineOnly {
w.armTimerLocked(deadline)
// Poll every accepted deadline, including one that reads as already
// expired: the clock may be running ahead of real time and get
// corrected later, and evaluate ignores a deadline that has genuinely
// passed anyway.
if !w.deadlineOnly {
w.startPollLocked()
}
recorder := w.recorder
w.mu.Unlock()
@@ -200,6 +226,28 @@ func (w *Watcher) Update(deadline time.Time) error {
recorder.SetSessionExpiresAt(deadline)
}
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
// Open the gate only once the recorder knows the new deadline, so a
// warning that refers to it can never reach consumers before the state
// change itself. A tick landing in between finds the gate shut.
w.mu.Lock()
if w.closed || !w.current.Equal(deadline) {
w.mu.Unlock()
return nil
}
w.announcedAt = deadline
wake := w.wake
w.mu.Unlock()
// Hand the evaluation to the loop rather than running it here, so a
// deadline that already sits inside a warning window is published at
// once without this goroutine ever touching the recorder.
if wake != nil {
select {
case wake <- struct{}{}:
default:
}
}
return nil
}
@@ -212,9 +260,9 @@ func (w *Watcher) Deadline() time.Time {
}
// Dismiss records the user's "Dismiss" action against the current deadline
// and suppresses the upcoming final-warning callback for that deadline.
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
// deadline resets the dismissal so the final-warning cycle re-arms.
// and suppresses the final warning for that deadline. Idempotent: repeated
// calls are no-ops. A subsequent Update with a fresh deadline resets the
// dismissal so the final-warning cycle starts over.
//
// No-op when the watcher holds no deadline or has been closed.
func (w *Watcher) Dismiss() {
@@ -227,35 +275,48 @@ func (w *Watcher) Dismiss() {
return
}
w.dismissedAt = w.current
// Cancel the armed final-warning timer eagerly. fireFinal would also
// gate on dismissedAt, but stopping the timer avoids a wakeup with
// nothing to do and makes the intent visible.
if w.finalTimer != nil {
w.finalTimer.Stop()
w.finalTimer = nil
}
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
}
// Close stops any pending timer. Update calls after Close are ignored.
// The recorder keeps its deadline: the watcher is engine-scoped and closes
// on every engine restart (network change, sleep/wake, stream errors)
// while the SSO deadline stays valid across those, so clearing here would
// blank the UI's "expires in" row on every transient reconnect. The
// client run loop clears the server-scoped recorder when it exits for
// real (Down, profile switch, permanent login failure).
// Close stops the evaluation loop and waits for it to exit. Update calls
// after Close are ignored. The recorder keeps its deadline: the watcher is
// engine-scoped and closes on every engine restart (network change,
// sleep/wake, stream errors) while the SSO deadline stays valid across
// those, so clearing here would blank the UI's "expires in" row on every
// transient reconnect. The client run loop clears the server-scoped
// recorder when it exits for real (Down, profile switch, permanent login
// failure).
func (w *Watcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
// A concurrent Close is already tearing down. w.done outlives it,
// so this caller waits for the same loop rather than returning
// while a warning is still on its way to the recorder.
done := w.done
w.mu.Unlock()
if done != nil {
<-done
}
return
}
w.closed = true
w.stopTimerLocked()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
w.announcedAt = time.Time{}
// Copy the channels out before releasing the lock: the loop takes w.mu
// on every tick, so waiting for it while holding the lock would
// deadlock. w.done stays on the receiver for the branch above.
stop, done := w.stop, w.done
w.stop, w.wake = nil, nil
w.mu.Unlock()
if stop == nil {
return
}
close(stop)
<-done
}
// clearLocked drops the tracked deadline and notifies the recorder so
@@ -267,11 +328,11 @@ func (w *Watcher) clearLocked() {
w.mu.Unlock()
return
}
w.stopTimerLocked()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
w.announcedAt = time.Time{}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil {
@@ -280,133 +341,121 @@ func (w *Watcher) clearLocked() {
log.Infof("auth session deadline cleared")
}
func (w *Watcher) stopTimerLocked() {
if w.timer != nil {
w.timer.Stop()
w.timer = nil
// startPollLocked starts the evaluation loop unless it is already
// running. The loop starts lazily on the first accepted deadline, so a
// client whose server never publishes a session expiry never pays for a
// ticker, and it runs until Close: the watcher is engine-scoped, and a
// cleared deadline is normally followed by a fresh one on the next sync.
// Caller must hold w.mu.
func (w *Watcher) startPollLocked() {
if w.stop != nil {
return
}
if w.finalTimer != nil {
w.finalTimer.Stop()
w.finalTimer = nil
stop := make(chan struct{})
done := make(chan struct{})
wake := make(chan struct{}, 1)
w.stop, w.done, w.wake = stop, done, wake
go w.poll(stop, done, wake, w.interval)
}
// poll re-evaluates the tracked deadline every interval, and as soon as a
// new deadline is announced. It is the only caller of evaluate, so Close
// waiting for it to exit is enough to know no warning is still on its way
// to the recorder. Its channels and interval are passed in rather than
// read off the receiver, so Close can clear them without racing it.
func (w *Watcher) poll(stop <-chan struct{}, done chan<- struct{}, wake <-chan struct{}, interval time.Duration) {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-wake:
w.evaluate()
case <-ticker.C:
w.evaluate()
}
}
}
func (w *Watcher) armTimerLocked(deadline time.Time) {
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
// finalLead <= 0 disables the final-warning timer entirely. Used by
// tests that predate the final-warning fallback so a millisecond-scale
// deadline does not flush both timers at once.
if w.finalLead > 0 {
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
}
}
func (w *Watcher) fire(armedFor time.Time) {
// evaluate compares the tracked deadline against the wall clock and
// publishes whichever warning the remaining time calls for. Inside the
// final-warning window the interactive warning is stale, so the final one
// is published in its place: a device that resumes there never saw the
// T-WarningLead notification.
func (w *Watcher) evaluate() {
w.mu.Lock()
if w.closed || !w.current.Equal(armedFor) {
// Deadline moved while we were waiting (e.g. a successful extend).
// The reschedule path armed a fresh timer; this one is stale.
if w.closed || w.deadlineOnly || w.current.IsZero() {
w.mu.Unlock()
return
}
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
w.mu.Unlock()
return
}
now := w.nowFn()
if isLate(now, armedFor, max(w.finalLead, 0)) {
w.fireLateLocked(armedFor, now)
return
}
w.firedAt = armedFor
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session expiry soon warning fired")
publishWarning(recorder, armedFor, false)
}
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
// dismiss-gate: if the user dismissed the T-WarningLead notification for
// this deadline, the final warning is suppressed entirely.
func (w *Watcher) fireFinal(armedFor time.Time) {
w.mu.Lock()
if w.closed || !w.current.Equal(armedFor) {
deadline := w.current
if !w.announcedAt.Equal(deadline) {
// Update is still on its way to the recorder with this deadline.
w.mu.Unlock()
return
}
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
w.mu.Unlock()
return
}
if w.dismissedAt.Equal(armedFor) {
w.mu.Unlock()
log.Infof("auth session final-warning skipped (dismissed by user)")
return
}
now := w.nowFn()
if isLate(now, armedFor, 0) {
w.finalFiredAt = armedFor
w.mu.Unlock()
log.Infof("auth session final-warning skipped for deadline %s (passed %s ago)",
armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second))
return
}
w.finalFiredAt = armedFor
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session final-warning fired")
publishWarning(recorder, armedFor, true)
}
// Round(0) strips the monotonic reading so the comparison is wall
// clock on both sides, whether the deadline came off the wire or from
// a caller that derived it from time.Now.
remaining := deadline.Round(0).Sub(w.nowFn().Round(0))
// fireLateLocked handles a T-WarningLead callback that fired inside the
// final-warning window: it sends the final warning in its place while the
// deadline has not passed and the user has not dismissed it, so a resume
// with time left still warns. The caller must hold w.mu; this helper
// releases it.
func (w *Watcher) fireLateLocked(armedFor, now time.Time) {
w.firedAt = armedFor
switch {
case w.dismissedAt.Equal(armedFor):
case remaining <= 0:
// Already expired: the post-mortem SessionExpired flow owns it.
w.mu.Unlock()
log.Infof("auth session expiry soon warning skipped (dismissed by user)")
return
case w.finalFiredAt.Equal(armedFor):
case w.finalLead > 0 && remaining <= w.finalLead:
w.publishFinalLocked(deadline, remaining)
case remaining <= w.lead:
w.publishWarningLocked(deadline, remaining)
default:
w.mu.Unlock()
log.Infof("auth session expiry soon warning skipped (final warning already fired)")
return
case isLate(now, armedFor, 0):
}
}
// publishWarningLocked emits the interactive T-WarningLead warning, at
// most once per deadline value. Caller must hold w.mu; this helper
// releases it.
func (w *Watcher) publishWarningLocked(deadline time.Time, remaining time.Duration) {
if w.firedAt.Equal(deadline) {
w.mu.Unlock()
log.Infof("auth session expiry soon warning skipped for deadline %s (passed %s ago)",
armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second))
return
}
w.finalFiredAt = armedFor
w.firedAt = deadline
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session expiry soon warning fired inside the final-warning window, sending final warning for deadline %s",
armedFor.Format(time.RFC3339))
publishWarning(recorder, armedFor, true)
log.Infof("auth session expiry soon warning fired for deadline %s (in %s)",
deadline.Format(time.RFC3339), remaining.Round(time.Second))
publishWarning(recorder, deadline, false)
}
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
// past it dispatches on the next scheduler tick so a state-change recorder
// notification (invoked after w.mu is released) lands first. Caller must
// hold w.mu.
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
delay := time.Until(fireAt)
if delay <= 0 {
return time.AfterFunc(0, cb)
// publishFinalLocked emits the final warning, at most once per deadline
// value and never once the user dismissed that deadline. It marks the
// interactive warning as handled too: the final window is open, so a
// "expires in WarningLead minutes" notification would be wrong. Caller
// must hold w.mu; this helper releases it.
func (w *Watcher) publishFinalLocked(deadline time.Time, remaining time.Duration) {
if w.finalFiredAt.Equal(deadline) || w.dismissedAt.Equal(deadline) {
w.mu.Unlock()
return
}
return time.AfterFunc(delay, cb)
w.firedAt = deadline
w.finalFiredAt = deadline
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session final-warning fired for deadline %s (in %s)",
deadline.Format(time.RFC3339), remaining.Round(time.Second))
publishWarning(recorder, deadline, true)
}
// publishWarning composes the SystemEvent for a watcher-fired warning and
@@ -436,11 +485,3 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
meta,
)
}
// isLate reports whether the wall clock now has already reached armedFor
// minus cutoffLead. The timers run on the monotonic clock, which can stall
// while the host sleeps, so a timer can fire long after the window it was
// armed for.
func isLate(now, armedFor time.Time, cutoffLead time.Duration) bool {
return !now.Round(0).Before(armedFor.Add(-cutoffLead).Round(0))
}
+328 -136
View File
@@ -19,6 +19,9 @@ type fakeRecorder struct {
mu sync.Mutex
events []event
lastDeadline time.Time
// setDelay stalls SetSessionExpiresAt, widening the window in which a
// concurrent evaluation could publish a warning out of order.
setDelay time.Duration
}
type eventKind int
@@ -43,6 +46,12 @@ type event struct {
// is the zero time, so an initial clear before any deadline is set emits
// nothing — matching the real recorder.
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
r.mu.Lock()
delay := r.setDelay
r.mu.Unlock()
// Stall without the lock held, so a concurrent publish can still record.
time.Sleep(delay)
r.mu.Lock()
defer r.mu.Unlock()
if r.lastDeadline.Equal(deadline) {
@@ -116,10 +125,54 @@ func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
return nil
}
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
// testInterval keeps the ticker-driven tests fast; the leads they use are
// in the same millisecond scale.
const testInterval = 2 * time.Millisecond
// newWatcher builds a watcher with the final warning disabled (finalLead=0),
// matching the lead-only behaviour the pre-final-warning tests assume.
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
return NewWithLeads(lead, 0, r)
return newWatcherWithLeads(lead, 0, r)
}
// newWatcherWithLeads builds a watcher that evaluates on testInterval. The
// interval is set before Update, so the evaluation loop does not exist yet
// and the write cannot race it.
func newWatcherWithLeads(lead, final time.Duration, r StatusRecorder) *Watcher {
w := NewWithLeads(lead, final, r)
w.interval = testInterval
return w
}
// fakeClock is the watcher's wall clock under test. Reads come from the
// evaluation goroutine while the test writes, so both go through the mutex.
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func newFakeClock(t time.Time) *fakeClock {
return &fakeClock{t: t}
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
// set jumps the clock, standing in for a resume from suspension or for an
// NTP correction.
func (c *fakeClock) set(t time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = t
}
// settle waits out a handful of evaluation ticks so a test can assert that
// nothing was published.
func settle() {
time.Sleep(20 * testInterval)
}
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
@@ -410,7 +463,7 @@ func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
r := &fakeRecorder{}
// Warning fires at deadline-80ms, final at deadline-30ms.
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(100 * time.Millisecond)
@@ -446,7 +499,7 @@ func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
func TestDismissSuppressesFinalWarning(t *testing.T) {
r := &fakeRecorder{}
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(100 * time.Millisecond)
@@ -477,7 +530,7 @@ func TestDismissSuppressesFinalWarning(t *testing.T) {
func TestDismissResetByNewDeadline(t *testing.T) {
r := &fakeRecorder{}
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
defer w.Close()
first := time.Now().Add(100 * time.Millisecond)
@@ -507,7 +560,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
r := &fakeRecorder{}
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
defer w.Close()
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
@@ -528,157 +581,139 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
}
func TestIsLate(t *testing.T) {
armedFor := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
lead := 2 * time.Minute
tests := []struct {
name string
now time.Time
cutoffLead time.Duration
want bool
}{
{"before cutoff", armedFor.Add(-3 * time.Minute), lead, false},
{"at cutoff", armedFor.Add(-lead), lead, true},
{"after cutoff", armedFor.Add(-time.Minute), lead, true},
{"zero lead before deadline", armedFor.Add(-time.Second), 0, false},
{"zero lead at deadline", armedFor, 0, true},
{"zero lead after deadline", armedFor.Add(time.Second), 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isLate(tt.now, armedFor, tt.cutoffLead); got != tt.want {
t.Fatalf("isLate(%s, %s, %s) = %v, want %v", tt.now, armedFor, tt.cutoffLead, got, tt.want)
}
})
}
}
// The tests below drive the watcher's wall clock directly. The deadline sits
// an hour out in real time and the fake clock jumps, standing in for a device
// that was suspended and resumed somewhere inside — or past — the warning
// windows. The evaluation loop is what reacts to the jump, so these exercise
// the same path production takes on a resume.
func newResumeWatcher(t *testing.T, r *fakeRecorder) (*Watcher, *fakeClock, time.Time) {
t.Helper()
func TestIsLateIgnoresMonotonicReading(t *testing.T) {
now := time.Now()
wallOnly := now.Round(0)
if isLate(now, wallOnly.Add(time.Second), 0) {
t.Fatalf("now with monotonic reading must compare as wall clock before a later wall-only deadline")
}
if !isLate(now, wallOnly, 0) {
t.Fatalf("now with monotonic reading must compare as wall clock at an equal wall-only deadline")
}
}
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
start := time.Now()
clock := newFakeClock(start)
// Set before Update: the evaluation loop does not exist yet.
w.nowFn = clock.now
t.Cleanup(w.Close)
func TestLateTimerFiring(t *testing.T) {
tests := []struct {
name string
final bool
beforeDl time.Duration
wantWarns int
wantFinals int
}{
{"warning on resume inside window", false, 3 * time.Minute, 1, 0},
{"warning promoted to final inside final window", false, time.Minute, 0, 1},
{"warning skipped past deadline", false, -time.Minute, 0, 0},
{"final on resume before deadline", true, time.Minute, 0, 1},
{"final skipped past deadline", true, -time.Minute, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &fakeRecorder{}
w := New(r)
defer w.Close()
// The deadline is an hour out so the real timers never fire
// during the test; the late callback is invoked directly with an
// injected clock that simulates a resume near the deadline.
d := time.Now().Add(time.Hour).Round(0)
w.nowFn = func() time.Time { return d.Add(-tt.beforeDl) }
if err := w.Update(d); err != nil {
t.Fatalf("Update: %v", err)
}
if tt.final {
w.fireFinal(d)
} else {
w.fire(d)
}
events := r.snapshot()
if got := countWhere(events, event.isWarning); got != tt.wantWarns {
t.Fatalf("expected %d warning publishes, got %d: %+v", tt.wantWarns, got, events)
}
if got := countWhere(events, event.isFinalWarning); got != tt.wantFinals {
t.Fatalf("expected %d final-warning publishes, got %d: %+v", tt.wantFinals, got, events)
}
})
}
}
func TestPromotedFinalWarningIsNotRepeated(t *testing.T) {
r := &fakeRecorder{}
w := New(r)
defer w.Close()
d := time.Now().Add(time.Hour).Round(0)
now := d.Add(-time.Minute)
w.nowFn = func() time.Time { return now }
if err := w.Update(d); err != nil {
deadline := start.Add(time.Hour).Round(0)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
return w, clock, deadline
}
w.fire(d)
// The final timer was suspended too, so it fires even later than the
// warning timer, here still just before the deadline.
now = d.Add(-30 * time.Second)
w.fireFinal(d)
func TestResumeInsideWarningWindowWarns(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
events := r.snapshot()
if got := countWhere(events, event.isFinalWarning); got != 1 {
t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events)
clock.set(deadline.Add(-5 * time.Minute))
events := waitForEvents(t, r, 2)
if !events[1].isWarning() {
t.Fatalf("expected the interactive warning after the resume, got %+v", events[1])
}
if got := countWhere(events, event.isWarning); got != 0 {
t.Fatalf("expected no regular warning publish, got %d: %+v", got, events)
if n := countWhere(events, event.isFinalWarning); n != 0 {
t.Fatalf("final-warning must wait for its own window, got %d: %+v", n, events)
}
}
func TestPromotionRespectsDismiss(t *testing.T) {
func TestResumeInsideFinalWindowSendsFinalWarningOnly(t *testing.T) {
r := &fakeRecorder{}
w := New(r)
defer w.Close()
_, clock, deadline := newResumeWatcher(t, r)
d := time.Now().Add(time.Hour).Round(0)
w.nowFn = func() time.Time { return d.Add(-time.Minute) }
if err := w.Update(d); err != nil {
t.Fatalf("Update: %v", err)
clock.set(deadline.Add(-time.Minute))
events := waitForEvents(t, r, 2)
if !events[1].isFinalWarning() {
t.Fatalf("expected the final warning after the resume, got %+v", events[1])
}
settle()
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
t.Fatalf("the interactive warning is stale inside the final window, got %d: %+v", n, r.snapshot())
}
}
func TestResumePastDeadlinePublishesNothing(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
clock.set(deadline.Add(time.Minute))
settle()
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("an expired session must not warn, got %d publishes: %+v", n, r.snapshot())
}
}
func TestWarningPublishesOncePerDeadline(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
clock.set(deadline.Add(-5 * time.Minute))
waitForEvents(t, r, 2)
// Many ticks pass inside the same window.
settle()
if n := countWhere(r.snapshot(), event.isWarning); n != 1 {
t.Fatalf("expected exactly 1 warning publish across ticks, got %d: %+v", n, r.snapshot())
}
}
// TestWarningRecoversFromAClockRunningAhead covers the device that boots
// before NTP has corrected it: the deadline looks long gone, nothing is
// published, and the warning still arrives once the clock is fixed.
func TestWarningRecoversFromAClockRunningAhead(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
clock.set(deadline.Add(2 * time.Hour))
settle()
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("a deadline that looks expired must not warn, got %d publishes: %+v", n, r.snapshot())
}
clock.set(deadline.Add(-5 * time.Minute))
events := waitForEvents(t, r, 2)
if !events[1].isWarning() {
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
}
}
func TestResumeInsideFinalWindowRespectsDismiss(t *testing.T) {
r := &fakeRecorder{}
w, clock, deadline := newResumeWatcher(t, r)
// The user dismissed the warning for this deadline before the device
// was suspended; resuming inside the final window must not reopen it.
w.Dismiss()
w.fire(d)
clock.set(deadline.Add(-time.Minute))
events := r.snapshot()
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
t.Fatalf("expected no publish after dismiss, got %d: %+v", got, events)
settle()
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("a dismissed deadline must not warn on resume, got %d: %+v", n, r.snapshot())
}
}
func TestPromotionSkippedWhenFinalAlreadyFired(t *testing.T) {
func TestCloseStopsTheEvaluationLoop(t *testing.T) {
r := &fakeRecorder{}
w := New(r)
defer w.Close()
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
start := time.Now()
clock := newFakeClock(start)
w.nowFn = clock.now
// Both timers fall in the past after a long suspend and are dispatched
// with a zero delay, so the final callback can run before the warning one.
d := time.Now().Add(time.Hour).Round(0)
w.nowFn = func() time.Time { return d.Add(-time.Minute) }
if err := w.Update(d); err != nil {
deadline := start.Add(time.Hour).Round(0)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
w.Close()
w.fireFinal(d)
w.fire(d)
clock.set(deadline.Add(-5 * time.Minute))
settle()
events := r.snapshot()
if got := countWhere(events, event.isFinalWarning); got != 1 {
t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events)
}
if got := countWhere(events, event.isWarning); got != 0 {
t.Fatalf("expected no regular warning publish, got %d: %+v", got, events)
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("a closed watcher must not publish, got %d: %+v", n, r.snapshot())
}
}
@@ -687,8 +722,8 @@ func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
w := NewDeadlineOnly(r)
defer w.Close()
// With the default leads this deadline would otherwise fire both
// timers on the next tick.
// With the default leads this deadline sits inside the final-warning
// window, so a watcher that warns at all would publish on the spot.
d := time.Now().Add(50 * time.Millisecond).Round(0)
if err := w.Update(d); err != nil {
t.Fatalf("Update: %v", err)
@@ -703,8 +738,11 @@ func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events)
}
if w.timer != nil || w.finalTimer != nil {
t.Fatal("expected no timers armed in deadline-only mode")
w.mu.Lock()
polling := w.stop != nil
w.mu.Unlock()
if polling {
t.Fatal("expected no evaluation loop in deadline-only mode")
}
}
@@ -725,3 +763,157 @@ func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) {
t.Fatalf("expected recorder cleared after rejection, got %v", got)
}
}
// TestClockAheadAtUpdateStillWarnsOnceCorrected covers a client that connects
// while its clock runs ahead of real time: the deadline reads as already
// expired when it arrives, so nothing publishes, and the warning must still
// come once NTP pulls the clock back.
func TestClockAheadAtUpdateStillWarnsOnceCorrected(t *testing.T) {
r := &fakeRecorder{}
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
deadline := time.Now().Add(time.Hour).Round(0)
// Two hours past the deadline from the device's point of view, well
// inside maxPastHorizon, so Update accepts and records it.
clock := newFakeClock(deadline.Add(2 * time.Hour))
w.nowFn = clock.now
t.Cleanup(w.Close)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
settle()
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("a deadline that reads as expired must not warn, got %d: %+v", n, r.snapshot())
}
clock.set(deadline.Add(-5 * time.Minute))
events := waitForEvents(t, r, 2)
if !events[1].isWarning() {
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
}
}
// TestWarningNeverPrecedesTheDeadlineStateChange pins the ordering Update
// documents: consumers learn the new deadline before they see a warning that
// refers to it. The deadline here already sits inside the warning window, and
// the recorder stalls, so the evaluation loop gets many chances to publish
// while Update is still announcing.
func TestWarningNeverPrecedesTheDeadlineStateChange(t *testing.T) {
r := &fakeRecorder{setDelay: 50 * time.Millisecond}
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
deadline := time.Now().Add(time.Hour).Round(0)
clock := newFakeClock(deadline.Add(-5 * time.Minute))
w.nowFn = clock.now
t.Cleanup(w.Close)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
events := waitForEvents(t, r, 2)
if events[0].kind != stateChange {
t.Fatalf("event[0] should be the deadline state change, got %+v", events)
}
if !events[1].isWarning() {
t.Fatalf("event[1] should be the warning, got %+v", events)
}
}
// TestUpdateWakesTheLoopWithoutWaitingForATick pins the hand-off: Update
// publishes nothing itself, it nudges the evaluation loop, so a deadline that
// already sits inside a warning window is warned about at once even though the
// ticker here would not fire for an hour.
func TestUpdateWakesTheLoopWithoutWaitingForATick(t *testing.T) {
r := &fakeRecorder{}
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
w.interval = time.Hour
t.Cleanup(w.Close)
d := time.Now().Add(5 * time.Minute).Round(0)
if err := w.Update(d); err != nil {
t.Fatalf("Update: %v", err)
}
events := waitForEvents(t, r, 2)
if !events[1].isWarning() {
t.Fatalf("expected the warning on the wake-up, got %+v", events[1])
}
}
// blockingRecorder holds a publish open until the test releases it, so the
// evaluation loop can be parked mid-publish while Close is called.
type blockingRecorder struct {
fakeRecorder
entered chan struct{}
release chan struct{}
}
func (r *blockingRecorder) PublishEvent(
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
userMessage string,
metadata map[string]string,
) {
select {
case r.entered <- struct{}{}:
default:
}
<-r.release
r.fakeRecorder.PublishEvent(severity, category, message, userMessage, metadata)
}
// TestConcurrentCloseWaitsForTheLoop pins the contract for the caller that
// loses the race: Close returns only once the loop is done, whichever of the
// two calls got there first.
func TestConcurrentCloseWaitsForTheLoop(t *testing.T) {
r := &blockingRecorder{entered: make(chan struct{}, 1), release: make(chan struct{})}
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(r.release) }) }
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
w.interval = time.Hour
// Order matters: Close waits for the loop, which stays parked in the
// publish until the release, so a t.Fatal below would hang the cleanup
// instead of reporting the failure.
t.Cleanup(func() {
release()
w.Close()
})
d := time.Now().Add(5 * time.Minute).Round(0)
if err := w.Update(d); err != nil {
t.Fatalf("Update: %v", err)
}
// The loop is now parked inside the warning publish.
select {
case <-r.entered:
case <-time.After(2 * time.Second):
t.Fatal("the loop never reached the publish")
}
first := make(chan struct{})
second := make(chan struct{})
go func() { w.Close(); close(first) }()
go func() { w.Close(); close(second) }()
select {
case <-first:
t.Fatal("Close returned while the loop was still publishing")
case <-second:
t.Fatal("Close returned while the loop was still publishing")
case <-time.After(100 * time.Millisecond):
}
release()
for _, done := range []chan struct{}{first, second} {
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Close did not return after the publish completed")
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// Collect answers the certificate challenges in checks: for each challenge it picks a
// stored certificate that chains to the challenge's CAs and signs the nonce with its
// key. The same certificate is proven once even if several checks accept it.
func Collect(ctx context.Context, store Store, checks []*proto.Checks, peerKey []byte) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
return CollectChallenges(ctx, store, challenges, peerKey)
}
func logNoChallenges(checks []*proto.Checks) {
if len(checks) > 0 {
log.Debugf("certificate posture: %d posture checks received, none carries a certificate challenge", len(checks))
}
}
// CollectChallenges answers challenges already extracted from the posture checks, so a
// caller that ships them across a process boundary reuses the same matching and signing.
// Only nonces of the size management issues are signed, for a peer key of the size of
// ours, so the keys behind the store never sign arbitrary caller-chosen data.
func CollectChallenges(ctx context.Context, store Store, challenges []*proto.CertificateChallenge, peerKey []byte) []certposture.Proof {
if len(peerKey) != wgtypes.KeyLen {
log.Warnf("certificate posture: refusing to sign for a %d byte peer key", len(peerKey))
return nil
}
challenges = wellFormed(challenges)
if len(challenges) == 0 {
return nil
}
log.Debugf("certificate posture: answering %d certificate challenges from store %T", len(challenges), store)
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("failed loading certificates for posture checks: %v", err)
return nil
}
if len(candidates) == 0 {
log.Debug("certificate posture: certificate store holds no candidates, no proof will be sent")
return nil
}
log.Debugf("certificate posture: store holds %d candidate certificates", len(candidates))
now := time.Now()
proven := make(map[[sha256.Size]byte]struct{})
var proofs []certposture.Proof
for i, challenge := range challenges {
roots, err := certposture.ParseCAs(challenge.GetCaCertificates())
if err != nil {
log.Warnf("skipping certificate challenge with invalid CA certificates: %v", err)
continue
}
log.Debugf("certificate posture: challenge %d accepts %d CA certificates, nonce is %d bytes", i, len(challenge.GetCaCertificates()), len(challenge.GetNonce()))
matched := false
for _, candidate := range candidates {
if len(candidate.Chain) == 0 {
continue
}
leaf := candidate.Chain[0]
chain, err := certposture.VerifiedChain(leaf, candidate.issuers(), roots, now)
if err != nil {
log.Debugf("certificate posture: challenge %d rejected %q issued by %q: %v", i, leaf.Subject, leaf.Issuer, err)
continue
}
matched = true
// The same leaf can chain to different CAs for different challenges, and
// management checks each chain against each check's CAs, so a proof is
// deduplicated by its whole chain rather than by its leaf.
fingerprint := chainFingerprint(chain)
if _, done := proven[fingerprint]; done {
log.Debugf("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
break
}
proof, err := prove(candidate.Signer, chain, challenge.GetNonce(), peerKey)
if err != nil {
log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err)
continue
}
log.Debugf("certificate posture: challenge %d proven by %q with %s, signature %d bytes, chain of %d", i, leaf.Subject, proof.SigAlg, len(proof.Signature), len(proof.Chain))
proven[fingerprint] = struct{}{}
proofs = append(proofs, proof)
break
}
if !matched {
log.Debugf("certificate posture: challenge %d matched none of the %d candidates", i, len(candidates))
}
}
log.Debugf("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs))
return proofs
}
// HasChallenges reports whether any of checks asks for a certificate proof.
func HasChallenges(checks []*proto.Checks) bool {
return len(certificateChallenges(checks)) > 0
}
func certificateChallenges(checks []*proto.Checks) []*proto.CertificateChallenge {
var challenges []*proto.CertificateChallenge
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil {
challenges = append(challenges, challenge)
}
}
return wellFormed(challenges)
}
// wellFormed drops challenges whose nonce is not one management could have issued.
func wellFormed(challenges []*proto.CertificateChallenge) []*proto.CertificateChallenge {
var kept []*proto.CertificateChallenge
for _, challenge := range challenges {
if len(challenge.GetNonce()) != certposture.NonceSize {
log.Debugf("certificate posture: skipping challenge with a %d byte nonce", len(challenge.GetNonce()))
continue
}
kept = append(kept, challenge)
}
return kept
}
func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(signer, nonce, peerKey)
if err != nil {
return certposture.Proof{}, err
}
der := make([][]byte, 0, len(chain))
for _, cert := range chain {
der = append(der, cert.Raw)
}
return certposture.Proof{Nonce: nonce, Chain: der, SigAlg: sigAlg, Signature: sig}, nil
}
func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte {
buf := make([]byte, 0, len(chain)*sha256.Size)
for _, cert := range chain {
certHash := sha256.Sum256(cert.Raw)
buf = append(buf, certHash[:]...)
}
return sha256.Sum256(buf)
}
+137
View File
@@ -0,0 +1,137 @@
//go:build !ios
package certproof
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// userHelperBackoff holds off asking a user's keychain again after it proved nothing.
var userHelperBackoff = newHelperBackoff()
// CollectProofs answers the certificate challenges in checks from every store this Mac
// can reach. The root daemon reads the System keychain itself, which is where MDM
// installs device identities, and reaches the console user's login keychain only by
// launching a helper into that user's session. A Mac sitting at the login window
// therefore yields device proofs alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
// A helper already runs inside the user's session, so it reads its own keychain
// directly and must never launch another one.
if os.Geteuid() != 0 {
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
if cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsConsoleUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Debugf("certificate posture: console user keychain unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the user whose keychain a collection would include: the console
// user when it owns the active profile, or empty when no user keychain would be asked. A
// change means a collection made earlier no longer reflects what this Mac can prove.
func UserContext(cfg Config) string {
if os.Geteuid() != 0 || cfg.OwnerUnknown {
return ""
}
user, ok := CurrentConsoleUser()
if !ok || !user.isOwner(cfg.ProfileOwner) {
return ""
}
return strconv.FormatUint(uint64(user.UID), 10) + ":" + user.Name
}
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
// which is what launchctl asuser does.
func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentConsoleUser()
if !ok {
return nil, nil
}
if !user.isOwner(owner) {
log.Debugf("certificate posture: console user %s does not own the active profile, no user keychain is asked", user.Name)
return nil, nil
}
uid := strconv.FormatUint(uint64(user.UID), 10)
backoffKey := helperBackoffKey(uid, challenges)
if !userHelperBackoff.allow(backoffKey, time.Now()) {
log.Debugf("certificate posture: the keychain of uid %s proved nothing recently, not asking again yet", uid)
return nil, nil
}
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
parent := ctx
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
// Absolute paths, because the daemon's PATH is configurable through the service
// environment, and sudo selects the user by uid so the name never has to round-trip.
cmd := exec.CommandContext(ctx, "/bin/launchctl", "asuser", uid, "/usr/bin/sudo", "-u", "#"+uid, "-H", "--", binary, "posture", "cert-proof")
killHelperGroupOnCancel(cmd)
log.Debugf("certificate posture: asking the desktop session of uid %s to answer %d challenges", uid, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
// A run that completed, or ran into the timeout waiting on a prompt nobody answered,
// tells whether the keychain proves anything. A launch or output failure, or a run the
// caller cut short, says nothing about it and must not hold off the next one.
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) && parent.Err() == nil
if err == nil || timedOut {
userHelperBackoff.record(backoffKey, err == nil && len(proofs) > 0, time.Now())
}
if err != nil {
return nil, fmt.Errorf("run helper as uid %s: %w", uid, err)
}
log.Debugf("certificate posture: desktop session of uid %s returned %d proofs", uid, len(proofs))
return proofs, nil
}
// helperStore is the store the helper reads: the user's login keychain alone. The
// session's search list also holds the System keychain, which the daemon reads itself,
// and using a System keychain key from the user's session would ask for an
// administrator's approval.
func helperStore() Store {
home, err := os.UserHomeDir()
if err != nil {
log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err)
return NewKeychainStore()
}
login := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
if _, err := os.Stat(login); err != nil {
// Keychains created before macOS 10.12 keep the old file name.
login = filepath.Join(home, "Library", "Keychains", "login.keychain")
}
return NewKeychainStore(login)
}
+31
View File
@@ -0,0 +1,31 @@
//go:build js
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
// store to sign with, so the stores stay out of the WebAssembly build.
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
return nil
}
// UserContext identifies the user whose certificates a collection would include. A
// browser has no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// DefaultStore is empty in a browser.
func DefaultStore() Store {
return Stores{}
}
func helperStore() Store {
return DefaultStore()
}
@@ -0,0 +1,31 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
// per-user certificates out of reach of a privileged daemon, so every other platform
// reads its store in the daemon itself.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
}
// UserContext identifies the user whose certificates a collection would include. These
// platforms have no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// helperStore is the store the helper reads. Nothing launches a helper on these
// platforms, so it is the store the daemon reads, configured from the same environment,
// which lets an administrator check a setup by running the helper by hand.
func helperStore() Store {
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
}
+258
View File
@@ -0,0 +1,258 @@
package certproof
import (
"context"
"crypto/rand"
"crypto/x509"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
corpCA := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := storeDir(t)
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
otherKey := certtest.RSAKey(t)
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
writeFile(t, dir, "notes.txt", "ignored")
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
challenge := func(cas ...string) *proto.Checks {
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
}
checks := []*proto.Checks{
{Files: []string{"/usr/bin/agent"}},
challenge(corpCA.PEM),
challenge(corpCA.PEM),
challenge(otherCA.PEM),
challenge(unrelatedCA.PEM),
challenge("not a pem"),
}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 2)
var subjects []string
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, time.Now())
require.NoError(t, err)
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
}
func TestCollect_NothingToProve(t *testing.T) {
dir := storeDir(t)
key := certtest.ECDSAKey(t)
ca := certtest.NewCA(t, "root")
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
tests := []struct {
name string
store Store
checks []*proto.Checks
}{
{"no checks", NewFileStore(dir), nil},
{"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
{"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
})
}
}
func TestFileStore_ChainWithIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1)
require.Len(t, candidates[0].Chain, 2)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
// leaves the directory group-writable on systems with a user-private group umask.
func storeDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
return dir
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
store := NewFileStore(dir)
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
tests := []struct {
name string
nonce []byte
peerKey []byte
want int
}{
{"issued nonce and peer key are signed", nonce, peerKey, 1},
{"short nonce is not signed", nonce[:8], peerKey, 0},
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
{"short peer key is not signed", nonce, peerKey[:16], 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
"the device key signs only what management could have issued")
})
}
}
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
// A stale key next to a renewed certificate, sorted before the good pair, must not
// produce a proof that management rejects and stop the search there.
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
goodKey := certtest.ECDSAKey(t)
good := ca.Issue(t, goodKey, "good")
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
proofs := CollectChallenges(context.Background(), NewFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
require.Len(t, proofs, 1)
_, err = challenger.Verify(proofs[0], peerKey, now)
assert.NoError(t, err, "the proof sent is one management accepts")
}
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
type staticStore []Candidate
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
// Renewing a CA with the same key pair leaves two certificates with the same
// subject and key in the store. The expired one sorts first here.
expiredTmpl := *intermediate.Cert
expiredTmpl.SerialNumber = big.NewInt(1)
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
require.NoError(t, err)
expired, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
pool := []*x509.Certificate{expired, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Len(t, chain, 2)
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
verified, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
"management's own check accepts the chain the proof carries")
}
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
rootA := certtest.NewCA(t, "root-a")
rootB := certtest.NewCA(t, "root-b")
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
crossTmpl := *issuer.Cert
crossTmpl.SerialNumber = big.NewInt(2)
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
require.NoError(t, err)
cross, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := issuer.Issue(t, key, "device")
pool := []*x509.Certificate{issuer.Cert, cross}
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
challenges := []*proto.CertificateChallenge{
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
}
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
for _, root := range []*certtest.CA{rootA, rootB} {
matched := false
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, now)
require.NoError(t, err)
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
}
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
}
}
@@ -0,0 +1,108 @@
package certproof
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this
// machine can reach. The service reads the local machine store itself, where AD and
// Intune enrol device certificates, and reaches the signed-in user's store by launching
// a helper with that session's token. A machine at the sign-in screen therefore proves
// device certificates alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
// The helper already runs as the signed-in user, and an ordinary process has no
// right to a session token, so only the service goes looking for one.
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the session whose store a collection would include: a session
// of the profile owner, or empty when no user store would be asked. A change means a
// collection made earlier no longer reflects what this machine can prove.
func UserContext(cfg Config) string {
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return ""
}
user, ok := CurrentDesktopUser(cfg.ProfileOwner)
if !ok {
return ""
}
defer user.Close()
return fmt.Sprintf("%d:%s", user.Session, user.Name)
}
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
// that user's store rather than the machine store the service already read.
func helperStore() Store {
return NewUserStore()
}
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
// directly, so the session token goes straight into the child process.
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentDesktopUser(owner)
if !ok {
return nil, nil
}
defer user.Close()
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
// The user's own environment, not the service's: the service environment may carry
// secrets such as a setup key that the signed-in user must not be able to read.
env, err := user.Token.Environ(false)
if err != nil {
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(user.Token),
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
}
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
return proofs, nil
}
+189
View File
@@ -0,0 +1,189 @@
package certproof
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
collectTimeout = 45 * time.Second
// lostAfter is how many deadlines an abandoned collection is waited for before a new
// one may start beside it. A store that is merely slow finishes well within that and
// releases its slot; only one wedged for good is worked around.
lostAfter = 10
// maxInFlight bounds how many abandoned collections may pile up. A store that never
// answers would otherwise leak a goroutine on every collection for the life of the
// daemon.
maxInFlight = 2
)
// collectSlots tracks the collections running in the process. Collectors share one by
// default: a collection abandoned in a token, TPM or keychain call keeps running after
// the engine that started it has stopped, and the next engine must account for it.
type collectSlots struct {
mu sync.Mutex
next uint64
// running holds the start time of each collection still running, by slot id.
running map[uint64]time.Time
}
var processSlots collectSlots
// Collector runs CollectProofs with a deadline and, in the normal case, one collection
// at a time in the process. Token, TPM and keychain calls cannot be interrupted, and on
// Linux there is not even a process to kill, so a collection that overruns is abandoned
// rather than awaited. A single wedged call would then refuse every later collection for
// the life of the daemon, so once an abandoned collection has been running for lostAfter
// deadlines another may start beside it, up to maxInFlight. The zero value is ready to
// use.
type Collector struct {
// slots overrides the process-wide slots when set.
slots *collectSlots
// timeout overrides collectTimeout when set.
timeout time.Duration
// now overrides the clock when set.
now func() time.Time
}
// Collect answers the certificate challenges in checks, returning no proofs when there
// are no challenges, when previous collections still hold the slots, or when this one
// does not finish in time. Missing proofs fail the certificate check on management.
func (c *Collector) Collect(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return c.collect(ctx, checks, func(ctx context.Context) []certposture.Proof {
return CollectProofs(ctx, checks, peerKey, cfg)
})
}
func (c *Collector) collect(ctx context.Context, checks []*proto.Checks, run func(context.Context) []certposture.Proof) []certposture.Proof {
if len(certificateChallenges(checks)) == 0 {
return nil
}
slots := c.slotsInUse()
slot, ok := slots.start(c.clock(), time.Duration(lostAfter)*c.deadline())
if !ok {
return nil
}
ctx, cancel := context.WithTimeout(ctx, c.deadline())
defer cancel()
done := make(chan []certposture.Proof, 1)
go func() {
// The slot is freed before the result is delivered, so a caller that starts the
// next collection right after this one returned is not turned away.
proofs := run(ctx)
slots.finish(slot)
done <- proofs
}()
select {
case proofs := <-done:
return proofs
case <-ctx.Done():
log.Warnf("certificate posture: proof collection did not finish within %s, sending no proofs", c.deadline())
return nil
}
}
// Stuck reports whether lost collections hold every slot. No collection starts again
// until one of them returns, which for a store that never answers means a restart.
func (c *Collector) Stuck() bool {
return c.slotsInUse().allLost(c.clock(), time.Duration(lostAfter)*c.deadline())
}
func (c *Collector) slotsInUse() *collectSlots {
if c.slots != nil {
return c.slots
}
return &processSlots
}
func (c *Collector) clock() time.Time {
if c.now != nil {
return c.now()
}
return time.Now()
}
func (c *Collector) deadline() time.Duration {
if c.timeout > 0 {
return c.timeout
}
return collectTimeout
}
// start claims a slot at now, reporting its id and whether the caller may collect. A
// collection that has held a slot for lost is taken to be wedged.
func (s *collectSlots) start(now time.Time, lost time.Duration) (uint64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.running) > 0 {
oldest := s.oldest()
switch {
case len(s.running) >= maxInFlight:
log.Warnf("certificate posture: %d proof collections are wedged, sending no proofs", len(s.running))
return 0, false
case now.Sub(oldest) < lost:
log.Warnf("certificate posture: previous proof collection is still running, sending no proofs")
return 0, false
default:
log.Warnf("certificate posture: a proof collection has been stuck since %s, starting another", oldest.Format(time.RFC3339))
}
}
if s.running == nil {
s.running = make(map[uint64]time.Time, maxInFlight)
}
s.next++
s.running[s.next] = now
return s.next, true
}
// finish releases the slot with the given id.
func (s *collectSlots) finish(slot uint64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.running, slot)
}
// allLost reports whether every slot is held by a collection that has run for lost.
func (s *collectSlots) allLost(now time.Time, lost time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.running) < maxInFlight {
return false
}
for _, started := range s.running {
if now.Sub(started) < lost {
return false
}
}
return true
}
// idle reports whether no collection is running.
func (s *collectSlots) idle() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.running) == 0
}
// oldest returns the start time of the longest-running collection. The caller holds mu
// and has checked that one is running.
func (s *collectSlots) oldest() time.Time {
var oldest time.Time
for _, started := range s.running {
if oldest.IsZero() || started.Before(oldest) {
oldest = started
}
}
return oldest
}
+207
View File
@@ -0,0 +1,207 @@
package certproof
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
}}}
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
var c Collector
called := false
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no store is touched when no check carries a challenge")
}
func TestCollector_ReturnsProofs(t *testing.T) {
var c Collector
want := []certposture.Proof{{Nonce: []byte("nonce")}}
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
}
func TestCollector_AbandonsStuckCollection(t *testing.T) {
c := Collector{timeout: 50 * time.Millisecond, slots: &collectSlots{}}
release := make(chan struct{})
finished := make(chan struct{})
// A token or keychain call that ignores its context and blocks well past the deadline.
stuck := func(context.Context) []certposture.Proof {
defer close(finished)
<-release
return []certposture.Proof{{Nonce: []byte("late")}}
}
start := time.Now()
proofs := c.collect(context.Background(), challengeChecks, stuck)
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
called := false
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no second collection starts while the first is still running")
close(release)
<-finished
require.Eventually(t, func() bool { return c.slots.idle() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
want := []certposture.Proof{{Nonce: []byte("nonce")}}
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
"collection works again after the stuck call returned")
}
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
c := Collector{timeout: 20 * time.Millisecond, slots: &collectSlots{}}
cancelled := make(chan struct{})
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
<-ctx.Done()
close(cancelled)
return nil
})
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
}
}
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
// collection stuck in a token call outlives the engine that started it, so the next
// engine's Collector must not start another one until it has finished.
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
shared := &collectSlots{}
first := Collector{timeout: 20 * time.Millisecond, slots: shared}
second := Collector{timeout: time.Second, slots: shared}
release := make(chan struct{})
stuck := func(context.Context) []certposture.Proof {
<-release
return nil
}
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
called := false
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return []certposture.Proof{{}}
})
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
assert.False(t, called, "a second collection does not start on top of the abandoned one")
close(release)
require.Eventually(t, func() bool { return shared.idle() }, time.Second, 5*time.Millisecond)
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
"collections resume once the abandoned one finished")
}
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
var a, b Collector
assert.Same(t, a.slotsInUse(), b.slotsInUse(), "separate Collectors share the process slots")
}
// TestCollector_LostCollectionDoesNotBlockForever: a store call that never answers, such
// as a wedged token on Linux where there is no process to kill, must not refuse every
// later collection. Once it has run for lostAfter deadlines another starts beside it,
// and no more than maxInFlight ever run.
func TestCollector_LostCollectionDoesNotBlockForever(t *testing.T) {
now := time.Now()
slots := &collectSlots{}
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
release := make(chan struct{})
defer close(release)
var started atomic.Int32
wedged := func(context.Context) []certposture.Proof {
started.Add(1)
<-release
return nil
}
c.collect(context.Background(), challengeChecks, wedged)
waitStarted(t, &started, 1)
c.collect(context.Background(), challengeChecks, wedged)
assert.Equal(t, int32(1), started.Load(), "a second collection is refused while the first may still finish")
assert.False(t, c.Stuck(), "one slow collection is not stuck yet")
now = now.Add(time.Duration(lostAfter) * c.deadline())
c.collect(context.Background(), challengeChecks, wedged)
waitStarted(t, &started, 2)
assert.False(t, c.Stuck(), "the second collection may still return, so the collector is not stuck yet")
now = now.Add(time.Duration(lostAfter) * c.deadline())
c.collect(context.Background(), challengeChecks, wedged)
assert.Equal(t, int32(2), started.Load(), "no more than maxInFlight collections run")
assert.True(t, c.Stuck(), "with every slot held by a lost collection the collector reports itself stuck")
}
// TestCollector_LostWindowFollowsTheOldestRunningCollection: A wedges, B starts beside it
// once A counts as lost, then A returns. B is recent, so a third collection must wait for
// B to be lost in its own right rather than inherit A's start time.
func TestCollector_LostWindowFollowsTheOldestRunningCollection(t *testing.T) {
now := time.Now()
slots := &collectSlots{}
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
lost := time.Duration(lostAfter) * c.deadline()
releaseA := make(chan struct{})
releaseB := make(chan struct{})
defer close(releaseB)
var started atomic.Int32
blockOn := func(release chan struct{}) func(context.Context) []certposture.Proof {
return func(context.Context) []certposture.Proof {
started.Add(1)
<-release
return nil
}
}
c.collect(context.Background(), challengeChecks, blockOn(releaseA))
waitStarted(t, &started, 1)
now = now.Add(lost)
c.collect(context.Background(), challengeChecks, blockOn(releaseB))
waitStarted(t, &started, 2)
close(releaseA)
require.Eventually(t, func() bool {
slots.mu.Lock()
defer slots.mu.Unlock()
return len(slots.running) == 1
}, time.Second, 5*time.Millisecond, "A frees its slot once it returns")
c.collect(context.Background(), challengeChecks, blockOn(make(chan struct{})))
assert.Equal(t, int32(2), started.Load(), "a third collection waits while B is recent")
}
// waitStarted waits for the collections the test launched to have started. collect may
// return at its deadline before the goroutine running the store call was scheduled.
func waitStarted(t *testing.T, started *atomic.Int32, n int32) {
t.Helper()
require.Eventually(t, func() bool { return started.Load() == n }, time.Second, time.Millisecond,
"%d collections should have started", n)
}
@@ -0,0 +1,123 @@
//go:build !ios
package certproof
import (
"bytes"
"fmt"
"strconv"
"sync"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
encodingUTF8 = 0x08000100
consoleNameBufSize = 256
)
var (
consoleOnce sync.Once
consoleErr error
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
)
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
type ConsoleUser struct {
Name string
UID uint32
GID uint32
}
// CurrentConsoleUser reports the user sitting at the desktop. The second return value is
// false when nobody is: at the login window macOS either reports no console user at all
// or attributes the session to root, and neither has a login keychain to offer.
func CurrentConsoleUser() (ConsoleUser, bool) {
if err := loadConsoleUser(); err != nil {
log.Debugf("console user lookup unavailable: %v", err)
return ConsoleUser{}, false
}
var uid, gid uint32
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
if name == 0 {
log.Debug("no console user is logged in, no login keychain is reachable")
return ConsoleUser{}, false
}
defer release(name)
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
if !user.hasDesktop() {
log.Debugf("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
return ConsoleUser{}, false
}
return user, true
}
// hasDesktop reports whether the console session is a real user desktop. The login
// window runs as root and some macOS releases name it "loginwindow" instead.
func (u ConsoleUser) hasDesktop() bool {
switch u.Name {
case "", "root", "loginwindow":
return false
}
return u.UID != 0
}
// isOwner reports whether the console user is owner, the account of the active profile,
// which is recorded as a short user name or, for an account without one, a numeric uid.
// With no owner the console user counts, as macOS has a single console user.
func (u ConsoleUser) isOwner(owner string) bool {
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
}
func cfString(str uintptr) string {
buf := make([]byte, consoleNameBufSize)
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
return ""
}
if end := bytes.IndexByte(buf, 0); end >= 0 {
return string(buf[:end])
}
return string(buf)
}
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
// first because CFRelease is resolved there and released strings depend on it.
func loadConsoleUser() error {
if err := loadKeychain(); err != nil {
return err
}
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
return consoleErr
}
func resolveConsoleUser() error {
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
if err != nil {
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
}
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
if err != nil {
return fmt.Errorf("resolve CFStringGetCString: %w", err)
}
purego.RegisterFunc(&cfStringGetCString, symbol)
return nil
}
@@ -0,0 +1,51 @@
//go:build !ios
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
tests := []struct {
name string
user ConsoleUser
desktop bool
}{
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
{"no console user", ConsoleUser{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
})
}
}
// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with
// a desktop open must report a non-root user; a headless runner must report none.
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
user, ok := CurrentConsoleUser()
if !ok {
t.Log("no console user, running headless")
return
}
assert.NotEmpty(t, user.Name, "a console user must have a name")
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
}
func TestConsoleUser_IsOwner(t *testing.T) {
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
assert.True(t, user.isOwner("maycon"), "the owner by short name")
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
assert.False(t, user.isOwner("502"), "another uid is another account")
}
@@ -0,0 +1,186 @@
package certproof
import (
"fmt"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
noActiveSession = 0xFFFFFFFF
servicesSession = 0
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE; wtsActive and wtsDisconnected are
// WTSActive and WTSDisconnected of WTS_CONNECTSTATE_CLASS. None is exported by
// x/sys/windows.
wtsCurrentServer = windows.Handle(0)
wtsActive = 0
wtsDisconnected = 4
)
// DesktopUser is an interactive session and the account signed into it. The user's
// certificate store is readable only from a process running as that account, because
// its private keys are protected against the user profile rather than the machine.
type DesktopUser struct {
Session uint32
Name string
Token windows.Token
}
// Close releases the session token.
func (u DesktopUser) Close() {
if err := u.Token.Close(); err != nil {
log.Debugf("failed closing desktop session token: %v", err)
}
}
// CurrentDesktopUser returns a token for the session whose certificate store should be
// asked: a session of owner, the account the active profile belongs to, with the
// physical console preferred over remote sessions. With no owner only the console user
// counts. Picking any signed-in user instead would let whoever else is logged in to a
// terminal server or VDI host decide the result. The second return is false when no
// such session exists, and only machine certificates can then be proven.
//
// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an
// ordinary process does not.
func CurrentDesktopUser(owner string) (DesktopUser, bool) {
console := windows.WTSGetActiveConsoleSessionId()
if owner == "" {
return consoleUser(console)
}
sessions, err := userSessions(console)
if err != nil {
log.Debugf("cannot enumerate terminal sessions: %v", err)
return DesktopUser{}, false
}
var found DesktopUser
var ok bool
for _, session := range sessions {
user, signedIn := desktopUser(session)
if !signedIn {
continue
}
switch {
case !sameAccountName(user.Name, owner):
user.Close()
case !ok:
found, ok = user, true
case strings.EqualFold(user.Name, found.Name):
// Another session of the same account; the first one in preference order wins.
user.Close()
default:
// An owner recorded without a domain matches accounts of several domains here.
// Picking one would let another domain's user answer for the owner.
log.Debugf("certificate posture: profile owner %s matches both %s and %s, no user certificate store is used", owner, found.Name, user.Name)
user.Close()
found.Close()
return DesktopUser{}, false
}
}
if !ok {
log.Debugf("certificate posture: profile owner %s has no signed-in session, no user certificate store is reachable", owner)
}
return found, ok
}
func consoleUser(console uint32) (DesktopUser, bool) {
if console == noActiveSession || console == servicesSession {
log.Debug("no console session, no user certificate store is reachable")
return DesktopUser{}, false
}
user, ok := desktopUser(console)
if !ok {
log.Debugf("console session %d has nobody signed in, no user certificate store is reachable", console)
}
return user, ok
}
// sameAccountName compares DOMAIN\account names case-insensitively, as Windows does, and an
// owner given without a domain against the account part alone. The session side comes from
// the session token's own SID, which Windows resolves from its cache of signed-in users.
// Resolving the owner name to a SID instead would ask the domain controller, which on a
// laptop that cannot reach it yet blocks for tens of seconds, past the collection deadline.
func sameAccountName(sessionName, owner string) bool {
if strings.EqualFold(sessionName, owner) {
return true
}
if strings.Contains(owner, `\`) {
return false
}
_, account, found := strings.Cut(sessionName, `\`)
return found && strings.EqualFold(account, owner)
}
func desktopUser(session uint32) (DesktopUser, bool) {
var token windows.Token
if err := windows.WTSQueryUserToken(session, &token); err != nil {
log.Debugf("no user token for session %d: %v", session, err)
return DesktopUser{}, false
}
name, err := tokenAccount(token)
if err != nil {
log.Debugf("session %d token has no readable account: %v", session, err)
if closeErr := token.Close(); closeErr != nil {
log.Debugf("failed closing session token: %v", closeErr)
}
return DesktopUser{}, false
}
return DesktopUser{Session: session, Name: name, Token: token}, true
}
func tokenAccount(token windows.Token) (string, error) {
user, err := token.GetTokenUser()
if err != nil {
return "", fmt.Errorf("read token user: %w", err)
}
account, domain, _, err := user.User.Sid.LookupAccount("")
if err != nil {
return "", fmt.Errorf("look up account: %w", err)
}
if domain == "" {
return account, nil
}
return domain + `\` + account, nil
}
// userSessions lists the sessions a user can be signed in to, console first, then
// active remote sessions, then disconnected ones, whose user is still signed in. Session
// 0 is skipped: it hosts services and never belongs to an interactive user.
func userSessions(console uint32) ([]uint32, error) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
return nil, fmt.Errorf("enumerate sessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
var sessions []uint32
if console != noActiveSession && console != servicesSession {
sessions = append(sessions, console)
}
for _, state := range []uint32{wtsActive, wtsDisconnected} {
for _, session := range unsafe.Slice(info, count) {
if session.SessionID == servicesSession || session.SessionID == console || session.State != state {
continue
}
sessions = append(sessions, session.SessionID)
}
}
return sessions, nil
}
// runningAsLocalSystem reports whether this process is the service. The helper runs as
// the signed-in user and must read its own store rather than launching another helper.
func runningAsLocalSystem() bool {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
log.Debugf("failed reading own token user: %v", err)
return false
}
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
}
@@ -0,0 +1,61 @@
package certproof
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSameAccountName(t *testing.T) {
tests := []struct {
session, owner string
want bool
}{
{`CORP\alice`, `CORP\alice`, true},
{`CORP\alice`, `corp\ALICE`, true},
{`CORP\alice`, `alice`, true},
{`CORP\alice`, `OTHER\alice`, false},
{`CORP\alice`, `bob`, false},
{`alice`, `alice`, true},
{`CORP\alice`, `CORP\alic`, false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, sameAccountName(tt.session, tt.owner), "session %q, owner %q", tt.session, tt.owner)
}
}
// CurrentDesktopUser against the real session manager: an owner no session belongs to
// must never yield a session, whoever else is signed in.
func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) {
user, ok := CurrentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`)
if ok {
user.Close()
}
assert.False(t, ok, "a profile owner without a session must not borrow another user's store")
}
// TestCurrentDesktopUser_FindsTheOwnersSession runs against a real machine as LocalSystem,
// with NB_TEST_DESKTOP_OWNER naming an account that is signed in. That account's session
// must be found, and only that account's.
func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) {
owner := os.Getenv("NB_TEST_DESKTOP_OWNER")
if owner == "" {
t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem")
}
user, ok := CurrentDesktopUser(owner)
require.True(t, ok, "the signed-in owner %s must have a session", owner)
defer user.Close()
t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name)
assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name)
assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner")
console, consoleOK := CurrentDesktopUser("")
if consoleOK {
defer console.Close()
t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name)
} else {
t.Log("without an owner nobody counts: no user at the console")
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package certproof answers certificate posture challenges: it finds the certificates a
// peer holds a private key for and signs the challenge nonce, bound to the peer's
// WireGuard key, with that key. Management verifies the signature and the chain against
// the CAs of the check, so only a holder of the key passes and a proof made for one peer
// cannot be replayed by another. Keys in the OS stores, the TPM and a PKCS#11 token are
// used through the platform, which signs; a plain PEM key file is the exception, parsed
// and used in the daemon's memory.
//
// Machine stores are read by the daemon itself: the Windows LocalMachine store, the macOS
// System keychain, and on Linux a directory of PEM files, TSS2 key files the TPM signs
// with, and a PKCS#11 token. On Linux the directory and the token URI come from the
// daemon's environment (NB_CERT_STORE_DIR, NB_CERT_PKCS11_URI), as does the token PIN
// (NB_TPM_PIN); none of them is read from the profile config.
//
// User stores cannot be read by a privileged daemon, so it starts this binary as
// "netbird posture cert-proof" inside the user's session and receives only signatures
// and chains on its stdout. On macOS uid is not what unlocks a login keychain, the
// session's bootstrap namespace is, which is why the helper enters it through launchctl
// asuser. On Windows a service opening CURRENT_USER silently reads its own empty hive, so
// the helper runs with the session's token instead. Its output is untrusted: sizes are
// capped and only proofs for requested nonces are kept.
//
// A user proof shows that some process in that user's session could use a key whose
// certificate chains to the CA. It does not show which binary signed, or that the key is
// hardware-bound; device stores are the stronger signal.
package certproof
+66
View File
@@ -0,0 +1,66 @@
package certproof
import (
"context"
"encoding/json"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// HelperRequest is the work the daemon hands to a helper running in a user session. The
// peer key binds every signature to this machine, so a proof cannot be replayed onto
// another peer.
type HelperRequest struct {
PeerKey []byte `json:"peerKey"`
Challenges []HelperChallenge `json:"challenges"`
}
// HelperChallenge is one certificate challenge in the form the helper needs.
type HelperChallenge struct {
Nonce []byte `json:"nonce"`
CACertificates []string `json:"caCertificates"`
}
// HelperResponse carries the proofs the helper produced from its own keychain.
type HelperResponse struct {
Proofs []certposture.Proof `json:"proofs"`
}
// RunHelper answers the challenges on in from the store of the user running this
// process and writes the proofs to out. It is the child half of the console user
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
// session instead. Only the signature crosses back, never the private key.
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
return runHelper(ctx, helperStore(), in, out)
}
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
var req HelperRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode helper request: %w", err)
}
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
for _, challenge := range req.Challenges {
challenges = append(challenges, &proto.CertificateChallenge{
Nonce: challenge.Nonce,
CaCertificates: challenge.CACertificates,
})
}
var proofs []certposture.Proof
if len(challenges) > 0 {
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
}
log.Debugf("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
return fmt.Errorf("encode helper response: %w", err)
}
return nil
}
@@ -0,0 +1,78 @@
package certproof
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"sync"
"time"
"github.com/netbirdio/netbird/shared/management/proto"
)
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
// every collection would put up a new one each time the user ignored or denied the last.
const helperQuietPeriod = time.Hour
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
// is not launched again because its last run proved nothing.
type helperBackoff struct {
mu sync.Mutex
until map[string]time.Time
}
func newHelperBackoff() *helperBackoff {
return &helperBackoff{until: map[string]time.Time{}}
}
// allow reports whether the helper may be launched for key now.
func (b *helperBackoff) allow(key string, now time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
return !now.Before(b.until[key])
}
// record stores the outcome of a helper run for key: one that proved something clears
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
// map holds only users currently held off.
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
for other, until := range b.until {
if !now.Before(until) {
delete(b.until, other)
}
}
if proven {
delete(b.until, key)
return
}
b.until[key] = now.Add(helperQuietPeriod)
}
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
// order, leaving out the nonces, which rotate without changing what the user is asked to
// prove.
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
sets := make([]string, 0, len(challenges))
for _, challenge := range challenges {
cas := make([]string, 0, len(challenge.GetCaCertificates()))
for _, ca := range challenge.GetCaCertificates() {
sum := sha256.Sum256([]byte(ca))
cas = append(cas, hex.EncodeToString(sum[:]))
}
slices.Sort(cas)
sets = append(sets, strings.Join(cas, ","))
}
slices.Sort(sets)
h := sha256.New()
h.Write([]byte(user))
for _, set := range sets {
h.Write([]byte{0})
h.Write([]byte(set))
}
return hex.EncodeToString(h.Sum(nil))
}
@@ -0,0 +1,69 @@
package certproof
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/proto"
)
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
// success or other CAs are asked at once.
func TestHelperBackoff(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
assert.True(t, b.allow(key, now), "a user never asked is asked")
b.record(key, false, now)
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
b.record(key, true, now)
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
}
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
// the user is asked to prove, so they do not reset the quiet period.
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
assert.Equal(t, a, b, "the key does not depend on the nonce")
}
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
// challenges reordered, are the same question to the user.
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-1", "ca-2"}},
{CaCertificates: []string{"ca-3"}},
})
b := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-3"}},
{CaCertificates: []string{"ca-2", "ca-1"}},
})
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
}
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
// next outcome is recorded, so the map does not grow with every user and CA set seen.
func TestHelperBackoff_PrunesExpired(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
b.record("old", false, now)
b.record("new", false, now.Add(helperQuietPeriod))
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
assert.Contains(t, b.until, "new", "a current entry is kept")
}
@@ -0,0 +1,10 @@
//go:build !unix && !windows
package certproof
import "os/exec"
// startHelper starts cmd.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,35 @@
//go:build unix
package certproof
import (
"errors"
"os/exec"
"syscall"
)
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
// group when cmd's context ends. The helper may run below launchers such as launchctl
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
// only the direct child would leave the helper running and Wait blocked until the
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
func killHelperGroupOnCancel(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.WaitDelay = helperWaitDelay
}
// startHelper starts cmd. Killing the helper with everything below it is arranged by
// killHelperGroupOnCancel where the helper runs under a launcher.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,77 @@
//go:build windows
package certproof
import (
"fmt"
"os/exec"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
// only the helper on Windows leaves its descendants running, and one that holds the
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
func startHelper(cmd *exec.Cmd) (func(), error) {
job, err := newKillOnCloseJob()
if err != nil {
return nil, err
}
closeJob := func() {
if err := windows.CloseHandle(job); err != nil {
log.Debugf("failed to close certificate proof helper job: %v", err)
}
}
cmd.Cancel = func() error {
return windows.TerminateJobObject(job, 1)
}
cmd.WaitDelay = helperWaitDelay
if err := cmd.Start(); err != nil {
closeJob()
return nil, err
}
// A helper outside the job is still killed directly and bounded by WaitDelay; only
// its descendants would outlive it.
if err := assignToJob(job, cmd.Process.Pid); err != nil {
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
cmd.Cancel = func() error { return cmd.Process.Kill() }
}
return closeJob, nil
}
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
// is closed.
func newKillOnCloseJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, fmt.Errorf("create job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return 0, fmt.Errorf("set job object limits: %w", err)
}
return job, nil
}
// assignToJob puts the process pid into job.
func assignToJob(job windows.Handle, pid int) error {
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
if err != nil {
return fmt.Errorf("open process: %w", err)
}
defer func() {
_ = windows.CloseHandle(process)
}()
return windows.AssignProcessToJobObject(job, process)
}
@@ -0,0 +1,29 @@
//go:build windows
package certproof
import (
"context"
"os/exec"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
}
+126
View File
@@ -0,0 +1,126 @@
package certproof
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
)
const (
maxHelperStdout = 1 << 20
maxHelperStderr = 4 << 10
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
helperWaitDelay = 2 * time.Second
)
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
// The helper runs as an unprivileged user who can control its output, so both streams
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
stdout := &cappedBuffer{limit: maxHelperStdout}
stderr := &cappedBuffer{limit: maxHelperStderr}
cmd.Stdin = bytes.NewReader(payload)
cmd.Stdout = stdout
cmd.Stderr = stderr
release, err := startHelper(cmd)
if err != nil {
return nil, fmt.Errorf("start helper: %w", err)
}
defer release()
if err := cmd.Wait(); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
logHelperStderr(stderr.String())
if stdout.truncated {
return nil, errHelperOutputTooLarge
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
return requestedProofs(req, resp.Proofs), nil
}
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
// certificate it could not sign with, which is otherwise only visible in the user's
// session. Each line is quoted because the helper's user controls its content.
func logHelperStderr(stderr string) {
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
if line = strings.TrimSpace(line); line != "" {
log.Debugf("certificate posture helper: %q", line)
}
}
}
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
// most as many as req has challenges.
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
var kept []certposture.Proof
for _, proof := range proofs {
if len(kept) == len(req.Challenges) {
break
}
if !req.asked(proof.Nonce) {
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
continue
}
kept = append(kept, proof)
}
return kept
}
func (r HelperRequest) asked(nonce []byte) bool {
for _, challenge := range r.Challenges {
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
return true
}
}
return false
}
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
// which io.Copy prefers over Write, bypassing the cap.
type cappedBuffer struct {
buf bytes.Buffer
limit int
truncated bool
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if room := b.limit - b.buf.Len(); room < len(p) {
b.truncated = true
if room > 0 {
b.buf.Write(p[:room])
}
return len(p), nil
}
return b.buf.Write(p)
}
func (b *cappedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *cappedBuffer) String() string {
return b.buf.String()
}
@@ -0,0 +1,119 @@
//go:build !windows && !js
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// fakeHelper is a child process that drains its stdin and then prints script's output,
// standing in for a helper running in a user session the daemon cannot trust.
func fakeHelper(t *testing.T, script string) *exec.Cmd {
t.Helper()
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
}
func printJSON(t *testing.T, v any) string {
t.Helper()
out, err := json.Marshal(v)
require.NoError(t, err)
return fmt.Sprintf("printf '%%s' '%s'", out)
}
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
resp := HelperResponse{Proofs: []certposture.Proof{
{Nonce: []byte("injected"), Signature: []byte("x")},
{Nonce: []byte("asked"), Signature: []byte("first")},
{Nonce: []byte("asked"), Signature: []byte("second")},
}}
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
require.NoError(t, err)
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
}
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
_, err := runHelperCmd(fakeHelper(t, script), req)
assert.ErrorIs(t, err, errHelperOutputTooLarge)
}
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
_, err := runHelperCmd(fakeHelper(t, script), req)
require.Error(t, err)
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
}
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
// for one about a certificate it could not sign with: the warning reaches the daemon's
// log, quoted so a helper cannot forge log lines with embedded newlines.
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
var logged bytes.Buffer
log.SetOutput(&logged)
level := log.GetLevel()
log.SetLevel(log.DebugLevel)
t.Cleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(level)
})
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
_, err := runHelperCmd(fakeHelper(t, script), req)
require.NoError(t, err)
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
}
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
// grandchild.
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
killHelperGroupOnCancel(cmd)
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
}
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
assert.ErrorContains(t, err, "decode helper response")
}
+63
View File
@@ -0,0 +1,63 @@
//go:build (darwin && !ios) || windows
package certproof
import (
"crypto/sha256"
"crypto/x509"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest {
req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))}
for _, challenge := range challenges {
req.Challenges = append(req.Challenges, HelperChallenge{
Nonce: challenge.GetNonce(),
CACertificates: challenge.GetCaCertificates(),
})
}
return req
}
// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that
// both stores hold so the same certificate is proven once.
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
if len(user) == 0 {
return device
}
seen := make(map[[sha256.Size]byte]struct{}, len(device))
for _, proof := range device {
if len(proof.Chain) > 0 {
seen[sha256.Sum256(proof.Chain[0])] = struct{}{}
}
}
merged := device
for _, proof := range user {
if len(proof.Chain) == 0 {
continue
}
fingerprint := sha256.Sum256(proof.Chain[0])
if _, done := seen[fingerprint]; done {
continue
}
seen[fingerprint] = struct{}{}
merged = append(merged, proof)
logUserProof(proof)
}
return merged
}
func logUserProof(proof certposture.Proof) {
leaf, err := x509.ParseCertificate(proof.Chain[0])
if err != nil {
log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err)
return
}
log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
}
@@ -0,0 +1,56 @@
//go:build (darwin && !ios) || windows
package certproof
import (
"crypto/sha256"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
shared := ca.Issue(t, certtest.ECDSAKey(t), "shared")
userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only")
device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}}
user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}}
merged := mergeProofs(device, user)
require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped")
assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place")
assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended")
}
func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}}
merged := mergeProofs(device, nil)
require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof")
assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged")
}
func TestHelperRequest_CarriesEveryChallenge(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
challenges := []*proto.CertificateChallenge{
{Nonce: []byte("first"), CaCertificates: []string{ca.PEM}},
{Nonce: []byte("second")},
}
req := helperRequest(challenges, peerKey)
require.Len(t, req.Challenges, 2, "every challenge must reach the helper")
assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine")
assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged")
assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged")
assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs")
}
+63
View File
@@ -0,0 +1,63 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
request, err := json.Marshal(HelperRequest{
PeerKey: peerKey,
Challenges: []HelperChallenge{{Nonce: nonce, CACertificates: []string{ca.PEM}}},
})
require.NoError(t, err, "request must encode")
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON")
require.Len(t, resp.Proofs, 1, "the matching certificate should produce one proof")
// Verify exactly as management does, so the proof is proven to survive the encode,
// the process boundary and the decode intact.
chain, err := challenger.Verify(resp.Proofs[0], peerKey, time.Now())
require.NoError(t, err, "the decoded proof must verify against the issued nonce")
assert.Equal(t, "device", chain[0].Subject.CommonName, "the proven leaf should be the device certificate")
}
func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
request, err := json.Marshal(HelperRequest{PeerKey: peerKey})
require.NoError(t, err)
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
assert.Empty(t, resp.Proofs, "no challenges should produce no proofs")
}
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
var stdout bytes.Buffer
err := runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout)
require.Error(t, err, "a malformed request must fail rather than emit an empty proof set")
assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure")
}
@@ -0,0 +1,483 @@
//go:build !ios
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"errors"
"fmt"
"io"
"os"
"sync"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
errSecItemNotFound = -25300
errSecInteractionNotAllowed = -25308
)
// errKeyNeedsApproval reports a key whose access list does not include netbird, so using
// it needs the user's approval, which a daemon has no UI to ask for.
var errKeyNeedsApproval = errors.New("the key's access control requires user approval for netbird; " +
"import the identity with netbird allowed (security import <identity.p12> -k <keychain> " +
"-T /Applications/NetBird.app/Contents/MacOS/netbird), " +
"or set AllowAllAppsAccess in the MDM certificate payload, which allows every application")
var (
keychainOnce sync.Once
keychainErr error
secItemCopyMatching func(query uintptr, result *uintptr) int32
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
secCertificateCopyData func(cert uintptr) uintptr
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
secKeychainOpen func(path *byte, keychain *uintptr) int32
secKeychainCopySearchList func(searchList *uintptr) int32
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
cfArrayCreate func(alloc uintptr, values *uintptr, count int, callBacks uintptr) uintptr
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
cfArrayGetCount func(array uintptr) int
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
cfDataGetLength func(data uintptr) int
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
cfErrorGetCode func(err uintptr) int
cfRelease func(ref uintptr)
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
kSecMatchSearchList uintptr
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, kCFTypeArrayCallBacks uintptr
)
// systemKeychain is where MDM installs device identities.
const systemKeychain = "/Library/Keychains/System.keychain"
// DefaultStore is the System keychain for the root daemon, so it never touches a user's
// keychain, whose keys would ask for approval in a session the daemon has no UI in. Run
// by an ordinary user, it is that user's keychain search list.
func DefaultStore() Store {
if os.Geteuid() == 0 {
return NewKeychainStore(systemKeychain)
}
return NewKeychainStore()
}
// KeychainStore yields the identities of a set of keychains, or of the process's keychain
// search list when none is named, reached through purego so the client keeps building
// with CGO_ENABLED=0.
type KeychainStore struct {
keychains []string
}
// NewKeychainStore returns a store that searches the keychain files at paths, or the
// process's keychain search list when no path is given.
func NewKeychainStore(paths ...string) *KeychainStore {
return &KeychainStore{keychains: paths}
}
func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
if err := loadKeychain(); err != nil {
return nil, err
}
searchList, done, err := searchListOf(s.keychains)
if err != nil {
return nil, err
}
defer done()
var leaves []*x509.Certificate
err = eachIdentity(searchList, func(_ uintptr, der []byte) (bool, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping keychain identity: %v", err)
return false, nil
}
log.Debugf("keychain identity: subject=%q issuer=%q serial=%s expires=%s", cert.Subject, cert.Issuer, cert.SerialNumber, cert.NotAfter)
leaves = append(leaves, cert)
return false, nil
})
if err != nil {
return nil, err
}
// The certificate query runs even without identities: it separates a keychain that is
// readable but holds no identity from one the process cannot read at all.
pool, err := keychainCertificates(searchList)
if err != nil {
return nil, err
}
if len(leaves) == 0 {
log.Debugf("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
return nil, nil
}
log.Debugf("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Debugf("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
log.Debugf("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
}
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf, keychains: s.keychains}, Intermediates: pool})
}
return candidates, nil
}
// keychainSigner holds only the certificate; the identity is looked up again at signing
// time so no keychain references outlive a call.
type keychainSigner struct {
leaf *x509.Certificate
keychains []string
}
func (s *keychainSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
log.Debugf("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
searchList, done, err := searchListOf(s.keychains)
if err != nil {
return nil, err
}
defer done()
algorithm := keychainAlgorithm(scheme)
var signature []byte
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
if !bytes.Equal(der, s.leaf.Raw) {
return false, nil
}
signature, err = signWithIdentity(identity, algorithm, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the keychain")
}
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
return signature, nil
}
func keychainAlgorithm(scheme sigScheme) uintptr {
switch scheme {
case schemeECDSASHA384:
return kSecKeyAlgorithmECDSASHA384
case schemeRSAPSSSHA256:
return kSecKeyAlgorithmRSAPSSSHA256
default:
return kSecKeyAlgorithmECDSASHA256
}
}
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
var key uintptr
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
}
defer release(key)
data := cfDataCreate(0, &digest[0], len(digest))
if data == 0 {
return nil, errors.New("CFDataCreate returned NULL")
}
defer release(data)
var cfErr uintptr
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
if signature == 0 {
if cfErr == 0 {
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
}
defer release(cfErr)
code := cfErrorGetCode(cfErr)
if code == errSecInteractionNotAllowed {
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
}
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
}
defer release(signature)
return dataBytes(signature), nil
}
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
// or yields 0, the process's own search list, when paths is empty. The caller calls done
// once it no longer uses the list.
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
if len(paths) == 0 {
return 0, func() {}, nil
}
list, err := openSearchList(paths)
if err != nil {
return 0, nil, err
}
return list, func() { release(list) }, nil
}
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
// caller releases the array.
func openSearchList(paths []string) (uintptr, error) {
refs := make([]uintptr, 0, len(paths))
defer func() {
for _, ref := range refs {
release(ref)
}
}()
for _, path := range paths {
cpath := append([]byte(path), 0)
var keychain uintptr
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
}
refs = append(refs, keychain)
}
// The array retains the keychains, so the references opened here are released.
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
if list == 0 {
return 0, errors.New("CFArrayCreate returned NULL")
}
return list, nil
}
// eachIdentity calls fn with every identity in searchList, or in the process's search
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
// skipped rather than ending the walk.
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
var cert uintptr
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
return false, nil
}
der := certificateDER(cert)
release(cert)
if der == nil {
log.Debug("skipping keychain identity whose certificate has no DER data")
return false, nil
}
return fn(identity, der)
})
}
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var unparsable int
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
certs = append(certs, cert)
return false, nil
}
unparsable++
return false, nil
})
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
return certs, err
}
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
if searchList != 0 {
keys = append(keys, kSecMatchSearchList)
values = append(values, searchList)
}
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
defer release(query)
var items uintptr
switch status := secItemCopyMatching(query, &items); status {
case 0:
case errSecItemNotFound:
log.Debugf("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
return nil
default:
log.Debugf("keychain %s query returned OSStatus %d", name, status)
return fmt.Errorf("SecItemCopyMatching: %d", status)
}
defer release(items)
n := cfArrayGetCount(items)
log.Debugf("keychain %s query returned %d items", name, n)
for i := 0; i < n; i++ {
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
return err
}
}
return nil
}
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
// returns NULL, which it does for an object that is not a valid certificate.
func certificateDER(cert uintptr) []byte {
data := secCertificateCopyData(cert)
if data == 0 {
return nil
}
defer release(data)
return dataBytes(data)
}
func dataBytes(data uintptr) []byte {
n := cfDataGetLength(data)
if n <= 0 {
return nil
}
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
}
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
// several Security calls return NULL on failure, so every release goes through here.
func release(ref uintptr) {
if ref != 0 {
cfRelease(ref)
}
}
func loadKeychain() error {
keychainOnce.Do(func() {
if keychainErr = resolveKeychain(); keychainErr != nil {
log.Debugf("macOS keychain unavailable for certificate posture: %v", keychainErr)
return
}
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
logSearchList()
})
return keychainErr
}
// logSearchList reports the keychains the process searches. The root daemon sees the
// System keychain and System Roots, never a user's login keychain.
func logSearchList() {
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
log.Debug("keychain search list diagnostics unavailable on this macOS version")
return
}
var list uintptr
if status := secKeychainCopySearchList(&list); status != 0 {
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
return
}
defer release(list)
n := cfArrayGetCount(list)
log.Debugf("keychain search list contains %d keychains", n)
for i := 0; i < n; i++ {
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
}
}
func keychainPath(keychain uintptr) string {
path := make([]byte, 1024)
length := uint32(len(path))
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
}
return string(path[:length])
}
func resolveKeychain() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
for _, fn := range []struct {
ptr any
lib uintptr
name string
}{
{&secItemCopyMatching, security, "SecItemCopyMatching"},
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
{&secCertificateCopyData, security, "SecCertificateCopyData"},
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
{&secKeychainOpen, security, "SecKeychainOpen"},
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
{&cfDataCreate, coreFoundation, "CFDataCreate"},
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
{&cfRelease, coreFoundation, "CFRelease"},
} {
symbol, err := purego.Dlsym(fn.lib, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
purego.RegisterFunc(fn.ptr, symbol)
}
for _, global := range []struct {
ptr *uintptr
lib uintptr
name string
deref bool
}{
{&kSecClass, security, "kSecClass", true},
{&kSecClassIdentity, security, "kSecClassIdentity", true},
{&kSecClassCertificate, security, "kSecClassCertificate", true},
{&kSecMatchLimit, security, "kSecMatchLimit", true},
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
{&kSecReturnRef, security, "kSecReturnRef", true},
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
} {
addr, err := purego.Dlsym(global.lib, global.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", global.name, err)
}
if global.deref {
addr = **(**uintptr)(unsafe.Pointer(&addr))
}
*global.ptr = addr
}
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
return nil
}
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
// longer exports it so keychain lookups keep working without it.
func resolveOptional(lib uintptr, name string, ptr any) {
symbol, err := purego.Dlsym(lib, name)
if err != nil {
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
return
}
purego.RegisterFunc(ptr, symbol)
}
+77
View File
@@ -0,0 +1,77 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"slices"
)
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
type sigScheme int
const (
schemeECDSASHA256 sigScheme = iota + 1
schemeECDSASHA384
schemeRSAPSSSHA256
)
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
switch pub.(type) {
case *ecdsa.PublicKey:
switch opts.HashFunc() {
case crypto.SHA256:
return schemeECDSASHA256, nil
case crypto.SHA384:
return schemeECDSASHA384, nil
}
case *rsa.PublicKey:
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
return schemeRSAPSSSHA256, nil
}
}
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
}
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
chain := []*x509.Certificate{leaf}
current := leaf
for current.CheckSignatureFrom(current) != nil {
issuer := issuerIn(current, pool, chain)
if issuer == nil {
break
}
chain = append(chain, issuer)
current = issuer
}
return chain
}
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
for _, candidate := range pool {
if slices.ContainsFunc(seen, candidate.Equal) {
continue
}
if cert.CheckSignatureFrom(candidate) == nil {
return candidate
}
}
return nil
}
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
if len(raw) == 0 || len(raw)%2 != 0 {
return nil, errors.New("malformed raw ECDSA signature")
}
half := len(raw) / 2
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
}
@@ -0,0 +1,87 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestBuildChain_FollowsIssuersThroughThePool(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
unrelated := certtest.NewCA(t, "unrelated")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
pool := []*x509.Certificate{unrelated.Cert, root.Cert, leaf, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Equal(t, []*x509.Certificate{leaf, intermediate.Cert, root.Cert}, chain)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(chain, roots, time.Now()))
}
func TestBuildChain_StopsWhereThePoolEnds(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
assert.Equal(t, []*x509.Certificate{leaf}, buildChain(leaf, nil))
assert.Equal(t, []*x509.Certificate{leaf, intermediate.Cert}, buildChain(leaf, []*x509.Certificate{intermediate.Cert}))
}
func TestSchemeFor(t *testing.T) {
ecKey := certtest.ECDSAKey(t)
rsaKey := certtest.RSAKey(t)
pss := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
tests := []struct {
name string
pub crypto.PublicKey
opts crypto.SignerOpts
want sigScheme
}{
{"ecdsa sha256", ecKey.Public(), crypto.SHA256, schemeECDSASHA256},
{"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384},
{"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256},
{"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0},
{"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := schemeFor(tc.pub, tc.opts)
if tc.want == 0 {
assert.ErrorIs(t, err, errUnsupportedScheme)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestECDSASignatureASN1(t *testing.T) {
key := certtest.ECDSAKey(t).(*ecdsa.PrivateKey)
digest := sha256.Sum256([]byte("nonce"))
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
require.NoError(t, err)
raw := append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
der, err := ecdsaSignatureASN1(raw)
require.NoError(t, err)
assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der))
_, err = ecdsaSignatureASN1(raw[:63])
assert.Error(t, err)
}
+54
View File
@@ -0,0 +1,54 @@
package certproof
import (
"crypto/sha256"
"encoding/binary"
"errors"
"sync"
)
// errPINRejectedBefore is returned instead of logging in with a PIN the token already
// refused: every failed login counts towards the token's lockout, which for a TPM is
// shared with everything else on the machine, and proofs are collected on every sync.
var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again")
// errPINNeedsToken refuses a PIN that names no token to log in to.
var errPINNeedsToken = errors.New("a PKCS#11 PIN needs the token named in NB_CERT_PKCS11_URI, as token=<label>")
// rejectedPINs outlives a single store, since a store is built for each collection.
var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}}
// pinLoginMu serializes logging in to a PKCS#11 token with a PIN, from checking the latch
// to recording a rejection.
var pinLoginMu sync.Mutex
// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not
// kept in memory any longer than the store that read it.
type pinLatch struct {
mu sync.Mutex
keys map[[sha256.Size]byte]struct{}
}
func (l *pinLatch) has(key [sha256.Size]byte) bool {
l.mu.Lock()
defer l.mu.Unlock()
_, ok := l.keys[key]
return ok
}
func (l *pinLatch) add(key [sha256.Size]byte) {
l.mu.Lock()
defer l.mu.Unlock()
l.keys[key] = struct{}{}
}
// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token
// rejected is still tried on the token it belongs to.
func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte {
var buf []byte
for _, part := range [][]byte{[]byte(module), []byte(token), pin} {
buf = binary.BigEndian.AppendUint64(buf, uint64(len(part)))
buf = append(buf, part...)
}
return sha256.Sum256(buf)
}
@@ -0,0 +1,29 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) {
base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another")
assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another")
assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")),
"field boundaries are part of the key, so shifted fields do not collide")
}
func TestPINLatch_RemembersRejectedKeys(t *testing.T) {
latch := &pinLatch{keys: map[[32]byte]struct{}{}}
rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000"))
other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.False(t, latch.has(rejected), "nothing is rejected before a login fails")
latch.add(rejected)
assert.True(t, latch.has(rejected), "a rejected PIN is not tried again")
assert.False(t, latch.has(other), "other PINs are unaffected")
}
+333
View File
@@ -0,0 +1,333 @@
package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"os"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
const (
// PINEnv carries the user PIN of the PKCS#11 token. It is read from the daemon's
// environment only, so the PIN never lands in the profile config or on a command line.
PINEnv = "NB_TPM_PIN"
// PKCS11URIEnv carries the RFC 7512 URI of the token. Like the PIN it lives in the
// daemon's environment rather than the profile config, since it may carry pin-value.
PKCS11URIEnv = "NB_CERT_PKCS11_URI"
)
// PKCS11Config names the token whose certificates the store yields. URI is an RFC 7512
// PKCS#11 URI, or empty for the first token the p11-kit proxy exposes. PIN is the user
// PIN, and takes precedence over a pin-value or pin-source the URI carries.
type PKCS11Config struct {
URI string
PIN string
}
// PKCS11FromEnv returns the token named by NB_CERT_PKCS11_URI with the PIN set in
// NB_TPM_PIN, either empty when unset.
func PKCS11FromEnv() PKCS11Config {
return PKCS11Config{URI: os.Getenv(PKCS11URIEnv), PIN: os.Getenv(PINEnv)}
}
// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes
// TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the
// convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM
// directory by public key. Every signature happens on the token.
type PKCS11Store struct {
uri *pkcs11.URI
pin string
certDir string
}
// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in
// certDir without a key of their own are paired with the token's keys by public key.
//
// A PIN is only accepted together with a token label: without one the PIN would go to
// whichever token the module lists first, which on a machine with a smartcard plugged
// in may be the card, and every wrong PIN counts towards that card's lockout.
func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) {
store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir}
if cfg.URI != "" {
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
}
store.uri = parsed
}
if (cfg.PIN != "" || store.uri.HasPIN()) && store.uri.Token == "" {
return nil, errPINNeedsToken
}
return store, nil
}
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
session, err := s.open()
if err != nil {
return nil, err
}
defer session.Close()
certs, err := tokenCertificates(session)
if err != nil {
return nil, err
}
fileChains, err := s.fileChains()
if err != nil {
return nil, err
}
log.Debugf("%s holds %d certificates, %d certificate files without a key wait for its keys", s, len(certs), len(fileChains))
pool := make([]*x509.Certificate, 0, len(certs))
for _, cert := range certs {
pool = append(pool, cert.cert)
}
for _, chain := range fileChains {
pool = append(pool, chain...)
}
var candidates []Candidate
for _, cert := range certs {
if _, err := privateKey(session, cert.id); err != nil {
log.Debugf("%s certificate %q has no usable private key: %v", s, cert.cert.Subject, err)
continue
}
candidates = append(candidates, s.candidate(cert.cert, cert.id, pool))
}
if len(fileChains) == 0 {
return candidates, nil
}
keys, err := tokenPublicKeys(session)
if err != nil {
return nil, err
}
for _, chain := range fileChains {
leaf := chain[0]
id, ok := keys.idFor(leaf.PublicKey)
if !ok {
log.Debugf("%s holds no key for certificate %q from %s", s, leaf.Subject, s.certDir)
continue
}
candidates = append(candidates, s.candidate(leaf, id, pool))
}
return candidates, nil
}
func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate {
chain := buildChain(leaf, pool)
log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool}
}
// fileChains reads the certificate files in the PEM directory that carry no key of their
// own; the file store answers for the ones that do.
func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) {
if s.certDir == "" {
return nil, nil
}
paths, err := certFiles(s.certDir)
if err != nil {
return nil, err
}
var chains [][]*x509.Certificate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil || signer != nil {
continue
}
chains = append(chains, chain)
}
return chains, nil
}
func (s *PKCS11Store) String() string {
if s.uri.Token == "" {
return "PKCS#11 token"
}
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
}
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
module, err := pkcs11.Load(s.uri.Module())
if err != nil {
return nil, err
}
pin, err := s.userPIN()
if err != nil {
return nil, err
}
if pin == nil {
return module.OpenSession(s.uri.Token, nil)
}
// The check, the login and recording a rejection happen under one lock, so two
// collections running at once cannot both send a PIN the token is about to refuse.
pinLoginMu.Lock()
defer pinLoginMu.Unlock()
key := rejectedPINKey(s.uri.Module(), s.uri.Token, pin)
if rejectedPINs.has(key) {
return nil, errPINRejectedBefore
}
session, err := module.OpenSession(s.uri.Token, pin)
if pkcs11.PINRejected(err) {
rejectedPINs.add(key)
return nil, fmt.Errorf("%s rejected the PIN, not retrying it until the daemon restarts: %w", s, err)
}
return session, err
}
func (s *PKCS11Store) userPIN() ([]byte, error) {
if s.pin != "" {
return []byte(s.pin), nil
}
return s.uri.PIN()
}
type tokenKey struct {
id []byte
public crypto.PublicKey
}
type tokenKeys []tokenKey
func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) {
objects, err := session.FindObjects(pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPublicKey)})
if err != nil {
return nil, err
}
keys := make(tokenKeys, 0, len(objects))
for _, object := range objects {
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
public, err := session.PublicKey(object)
if err != nil {
log.Debugf("skipping public key on PKCS#11 token: %v", err)
continue
}
keys = append(keys, tokenKey{id: id, public: public})
}
return keys, nil
}
// idFor finds the token key whose public half is pub, so a certificate kept outside the
// token is still signed for by the key inside it.
func (k tokenKeys) idFor(pub crypto.PublicKey) ([]byte, bool) {
for _, key := range k {
equaler, ok := key.public.(interface{ Equal(crypto.PublicKey) bool })
if ok && len(key.id) > 0 && equaler.Equal(pub) {
return key.id, true
}
}
return nil, false
}
type tokenCertificate struct {
cert *x509.Certificate
id []byte
}
func tokenCertificates(session *pkcs11.Session) ([]tokenCertificate, error) {
objects, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassCertificate)},
pkcs11.Attribute{Type: pkcs11.AttrCertificateType, Value: pkcs11.ULong(pkcs11.CertificateX509)},
)
if err != nil {
return nil, err
}
certs := make([]tokenCertificate, 0, len(objects))
for _, object := range objects {
der, err := session.Attribute(object, pkcs11.AttrValue)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping unparsable certificate on PKCS#11 token: %v", err)
continue
}
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
certs = append(certs, tokenCertificate{cert: cert, id: id})
}
return certs, nil
}
var errNoPrivateKey = errors.New("no private key shares the certificate's CKA_ID")
func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) {
if len(id) == 0 {
return 0, errNoPrivateKey
}
keys, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPrivateKey)},
pkcs11.Attribute{Type: pkcs11.AttrID, Value: id},
)
if err != nil {
return 0, err
}
if len(keys) == 0 {
return 0, errNoPrivateKey
}
return keys[0], nil
}
// pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh
// session at signing time so no token handle outlives a call.
type pkcs11Signer struct {
store *PKCS11Store
leaf *x509.Certificate
id []byte
}
func (s *pkcs11Signer) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *pkcs11Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
session, err := s.store.open()
if err != nil {
return nil, err
}
defer session.Close()
key, err := privateKey(session, s.id)
if err != nil {
return nil, err
}
signature, err := session.Sign(pkcs11Mechanism(scheme), key, digest)
if err != nil {
return nil, err
}
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// pkcs11Mechanism maps a signature scheme onto the token mechanism that consumes a digest.
func pkcs11Mechanism(scheme sigScheme) pkcs11.Mechanism {
if scheme == schemeRSAPSSSHA256 {
return pkcs11.Mechanism{
Type: pkcs11.MechRSAPKCSPSS,
PSS: &pkcs11.PSSParams{Hash: pkcs11.MechSHA256, MGF: pkcs11.MGF1SHA256, SaltLen: sha256.Size},
}
}
return pkcs11.Mechanism{Type: pkcs11.MechECDSA}
}
@@ -0,0 +1,394 @@
package certproof
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"math/big"
"os"
"runtime"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
// testPKCS11DisposableEnv marks the token as disposable, allowing tests that spend
// attempts of its PIN lockout counter.
testPKCS11DisposableEnv = "NB_TEST_PKCS11_DISPOSABLE"
)
type failingStore struct{}
func (failingStore) Candidates(context.Context) ([]Candidate, error) {
return nil, errors.New("token unplugged")
}
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key := certtest.ECDSAKey(t)
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background())
require.NoError(t, err)
assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token")
}
// TestCollect_PKCS11TokenEndToEnd needs an initialised token with a user PIN, named by
// NB_TEST_PKCS11_URI. With SoftHSM:
//
// softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
// NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
// go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
//
// It imports a key and its certificate as token objects, then proves the certificate
// through the store the way the daemon would. Every run adds one more identity to the token.
func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
store, uri := pkcs11TestStore(t, "")
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-"+name)
leaf := ca.Issue(t, key, "device-"+name)
importIdentity(t, uri, key, leaf)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token-held key must prove exactly this run's certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the imported certificate")
})
}
}
// Attribute types the import needs and the store does not.
const (
attrPrivate = 0x2
attrIssuer = 0x81
attrSerialNumber = 0x82
attrSensitive = 0x103
attrSign = 0x108
attrVerify = 0x10a
attrPrivateExponent = 0x123
attrPrime1 = 0x124
attrPrime2 = 0x125
attrExponent1 = 0x126
attrExponent2 = 0x127
attrCoefficient = 0x128
)
var (
ckTrue = []byte{1}
ckFalse = []byte{0}
// The P-256 named curve OID in DER, which is what CKA_EC_PARAMS carries.
oidP256 = []byte{0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}
)
// importIdentity stores key and leaf on the token the way tpm2_ptool import and addcert
// do: private and public key objects plus the certificate, all under one CKA_ID.
func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Certificate) {
t.Helper()
id := importKey(t, uri, key, leaf.Subject.CommonName)
importCertificate(t, uri, leaf, id)
}
func importKey(t *testing.T, uri string, key crypto.Signer, label string) []byte {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
id := make([]byte, 8)
_, err := rand.Read(id)
require.NoError(t, err)
private := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPrivateKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckTrue),
attr(attrSensitive, ckTrue),
attr(attrSign, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(private, privateKeyAttributes(t, key)...)...)
require.NoError(t, err, "import private key")
public := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPublicKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(attrVerify, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(public, publicKeyAttributes(t, key)...)...)
require.NoError(t, err, "import public key")
return id
}
func importCertificate(t *testing.T, uri string, leaf *x509.Certificate, id []byte) {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
serial, err := asn1.Marshal(leaf.SerialNumber)
require.NoError(t, err)
_, err = session.CreateObject(
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassCertificate)),
attr(pkcs11.AttrCertificateType, pkcs11.ULong(pkcs11.CertificateX509)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(pkcs11.AttrLabel, []byte(leaf.Subject.CommonName)),
attr(pkcs11.AttrID, id),
attr(pkcs11.AttrSubject, leaf.RawSubject),
attr(attrIssuer, leaf.RawIssuer),
attr(attrSerialNumber, serial),
attr(pkcs11.AttrValue, leaf.Raw),
)
require.NoError(t, err, "import certificate")
}
func readWriteSession(t *testing.T, uri string) *pkcs11.Session {
t.Helper()
parsed, err := pkcs11.ParseURI(uri)
require.NoError(t, err)
module, err := pkcs11.Load(parsed.Module())
require.NoError(t, err)
pin, err := parsed.PIN()
require.NoError(t, err)
session, err := module.OpenReadWriteSession(parsed.Token, pin)
require.NoError(t, err)
return session
}
func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrValue, ecPrivateScalar(t, k)),
}
case *rsa.PrivateKey:
k.Precompute()
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
attr(attrPrivateExponent, k.D.Bytes()),
attr(attrPrime1, k.Primes[0].Bytes()),
attr(attrPrime2, k.Primes[1].Bytes()),
attr(attrExponent1, k.Precomputed.Dp.Bytes()),
attr(attrExponent2, k.Precomputed.Dq.Bytes()),
attr(attrCoefficient, k.Precomputed.Qinv.Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
// publicKeyAttributes describes the CKO_PUBLIC_KEY object tokens keep next to a private
// key, which is what the store reads to pair a certificate file with its key.
func publicKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
point := append([]byte{4}, k.X.FillBytes(make([]byte, 32))...)
point = append(point, k.Y.FillBytes(make([]byte, 32))...)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrECPoint, wrapped),
}
case *rsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
func attr(typ uint, value []byte) pkcs11.Attribute {
return pkcs11.Attribute{Type: typ, Value: value}
}
// pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when
// no token is configured or this build lacks PKCS#11 support.
func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) {
t.Helper()
uri := os.Getenv(testPKCS11URIEnv)
if uri == "" {
t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv)
}
store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir)
require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err)
}
return store, uri
}
// TestCollect_PKCS11KeyWithFileCertificate covers the split layout: the key lives on the
// token, the certificate is a PEM file in the directory, and the two are paired by public
// key because nothing on the token carries the certificate's CKA_ID.
func TestCollect_PKCS11KeyWithFileCertificate(t *testing.T) {
dir := storeDir(t)
store, uri := pkcs11TestStore(t, dir)
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-file-"+name)
leaf := ca.Issue(t, key, "device-file-"+name)
importKey(t, uri, key, "device-file-"+name)
writeFile(t, dir, "device-"+name+".pem", certtest.CertPEM(leaf))
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token key must prove the certificate kept on disk")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the certificate from the directory")
})
}
}
func TestPKCS11Store_FileChains(t *testing.T) {
ca := certtest.NewCA(t, "corp")
dir := storeDir(t)
// Only certificate files without a key of their own belong to the token; the file
// store answers for the others, and non-certificate files are ignored.
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "device")))
writeFile(t, dir, "ca.crt", ca.PEM)
keyed := certtest.ECDSAKey(t)
writeFile(t, dir, "inline.pem", certtest.CertPEM(ca.Issue(t, keyed, "inline"))+certtest.KeyPEM(t, keyed))
writeFile(t, dir, "sibling.crt", certtest.CertPEM(ca.Issue(t, keyed, "sibling")))
writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed))
writeFile(t, dir, "notes.txt", "not a certificate")
chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains()
require.NoError(t, err)
var subjects []string
for _, chain := range chains {
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token")
chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains()
require.NoError(t, err)
assert.Empty(t, chains, "no directory configured means no file certificates")
}
func TestNewPKCS11Store_PIN(t *testing.T) {
tests := []struct {
name string
cfg PKCS11Config
wantPIN []byte
wantModule string
}{
{"pin with a token label opens that token through p11-kit", PKCS11Config{URI: "pkcs11:token=netbird", PIN: "1234"}, []byte("1234"), pkcs11.DefaultModule},
{"pin field wins over pin-value", PKCS11Config{URI: "pkcs11:token=netbird?module-path=" + absModule("x.so") + "&pin-value=0000", PIN: "1234"}, []byte("1234"), absModule("x.so")},
{"uri pin-value stands in for a missing field", PKCS11Config{URI: "pkcs11:token=netbird?pin-value=0000"}, []byte("0000"), pkcs11.DefaultModule},
{"no pin at all means no login", PKCS11Config{URI: "pkcs11:token=netbird"}, nil, pkcs11.DefaultModule},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store, err := NewPKCS11Store(tt.cfg, "")
require.NoError(t, err)
pin, err := store.userPIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
assert.Equal(t, tt.wantModule, store.uri.Module(), "module to load")
})
}
_, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "")
assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults")
for name, cfg := range map[string]PKCS11Config{
"env pin without uri": {PIN: "1234"},
"env pin, uri lacks token": {URI: "pkcs11:?module-path=" + absModule("x.so"), PIN: "1234"},
"inline pin-value only": {URI: "pkcs11:?pin-value=0000"},
"pin-source only": {URI: "pkcs11:?pin-source=file:/etc/netbird/pkcs11.pin"},
} {
_, err := NewPKCS11Store(cfg, "")
assert.ErrorIs(t, err, errPINNeedsToken, "%s: a PIN must not go to whichever token is listed first", name)
}
}
// TestPKCS11Store_WrongPINIsTriedOnce logs in to the real token with a wrong PIN: the
// token refuses it, and the next collection refuses to send the same PIN again rather
// than spending another attempt of the token's lockout counter. The one attempt it does
// spend counts against a real token's lockout, so it runs only on a token marked
// disposable through NB_TEST_PKCS11_DISPOSABLE.
func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) {
disposable, _ := strconv.ParseBool(os.Getenv(testPKCS11DisposableEnv))
if !disposable {
t.Skipf("set %s=1 to spend a wrong-PIN attempt on the token", testPKCS11DisposableEnv)
}
_, uri := pkcs11TestStore(t, "")
wrongPIN := "wrong-pin-" + t.Name()
store, err := NewPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "")
require.NoError(t, err)
_, err = store.Candidates(context.Background())
require.Error(t, err)
assert.True(t, pkcs11.PINRejected(err), "the token itself rejects the PIN: %v", err)
_, err = store.Candidates(context.Background())
assert.ErrorIs(t, err, errPINRejectedBefore, "the rejected PIN is not sent to the token again")
good, err := NewPKCS11Store(PKCS11Config{URI: uri}, "")
require.NoError(t, err)
_, err = good.Candidates(context.Background())
assert.NoError(t, err, "the correct PIN for the same token is unaffected")
}
// absModule is an absolute module path on the platform the test runs on, as module-path
// must be absolute.
func absModule(name string) string {
if runtime.GOOS == "windows" {
return `C:\lib\` + name
}
return "/lib/" + name
}
// ecPrivateScalar returns the raw private scalar the token stores in CKA_VALUE.
func ecPrivateScalar(t *testing.T, k *ecdsa.PrivateKey) []byte {
t.Helper()
raw, err := k.Bytes()
require.NoError(t, err)
return raw
}
+266
View File
@@ -0,0 +1,266 @@
package certproof
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
log "github.com/sirupsen/logrus"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
// maxStoreFileSize bounds a certificate or key file of the PEM directory. A chain with
// its key is a few kilobytes.
maxStoreFileSize = 1 << 20
)
// errKeyMismatch rejects a key that does not belong to the certificate it sits with: it
// would sign a proof management can only reject, in place of a usable later candidate.
var (
errKeyMismatch = errors.New("private key does not match the certificate")
errNoSiblingKey = errors.New("no key file next to the certificate")
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
// path to a challenge's CAs can be found even where Chain followed a different issuer,
// such as an expired copy of a renewed intermediate.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
Intermediates []*x509.Certificate
}
// issuers is every certificate other than the leaf that a path may run through.
func (c Candidate) issuers() []*x509.Certificate {
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
type Store interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// Config selects where the daemon looks for certificates. PKCS11 names a token whose keys
// sign for certificates on the token or in the PEM directory, which NB_CERT_STORE_DIR
// names on Linux, /etc/netbird/certs by default.
//
// ProfileOwner is the OS account the active profile belongs to. On macOS and Windows only
// that account's certificate store is consulted for user certificates, so on a machine
// with several people signed in the result does not depend on who else is logged in.
// Empty means the profile has no owner, and only the user at the physical console counts.
// OwnerUnknown means the owner could not be determined, and no user store is consulted:
// guessing would let whoever sits at the console answer for the profile.
type Config struct {
PKCS11 PKCS11Config
ProfileOwner string
OwnerUnknown bool
}
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
type FileStore struct {
dir string
}
func NewFileStore(dir string) *FileStore {
return &FileStore{dir: dir}
}
func StoreDir() string {
if dir := os.Getenv(StoreDirEnv); dir != "" {
return dir
}
return defaultStoreDir
}
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
paths, err := certFiles(s.dir)
if err != nil {
return nil, err
}
var candidates []Candidate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
if signer == nil {
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
continue
}
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
}
return candidates, nil
}
// certFiles lists the certificate files in dir, none when the directory does not exist.
// The directory is checked before it is listed, so one that others can write to is
// refused before its entries are read.
func certFiles(dir string) ([]string, error) {
err := checkStoreDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("refusing certificate store: %w", err)
}
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
}
var paths []string
for _, entry := range entries {
if !entry.IsDir() && isCertFile(entry.Name()) {
paths = append(paths, filepath.Join(dir, entry.Name()))
}
}
return paths, nil
}
// loadPEM reads a certificate file and its private key, held in the file itself or in
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
data, err := readStoreFile(path)
if err != nil {
return nil, nil, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return nil, nil, err
}
if len(chain) == 0 {
return nil, nil, errors.New("no certificate")
}
if signer == nil {
signer, err = siblingKey(path)
switch {
case errors.Is(err, errNoSiblingKey):
// A certificate with no key of its own: the token store pairs it later.
return chain, nil, nil
case err != nil:
return nil, nil, err
}
}
if !samePublicKey(signer.Public(), chain[0].PublicKey) {
return nil, nil, errKeyMismatch
}
return chain, signer, nil
}
// siblingKey reads the private key from the "<name>.key" file next to a certificate
// file, reporting errNoSiblingKey when there is none.
func siblingKey(path string) (crypto.Signer, error) {
keyData, err := readStoreFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if errors.Is(err, os.ErrNotExist) {
return nil, errNoSiblingKey
}
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
_, signer, err := parsePEM(keyData)
if err != nil {
return nil, err
}
if signer == nil {
return nil, errors.New("no private key in key file")
}
return signer, nil
}
func samePublicKey(a, b crypto.PublicKey) bool {
equaler, ok := a.(interface{ Equal(crypto.PublicKey) bool })
return ok && equaler.Equal(b)
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
return chain, signer, nil
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tss2KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
}
signer = key
}
}
}
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case tss2KeyPEMType:
return parseTSS2Key(block.Bytes)
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("private key cannot sign")
}
return signer, nil
}
func isCertFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".pem", ".crt", ".cer":
return true
}
return false
}
// Stores queries several stores and carries on when one fails, so a broken token cannot
// hide the certificates a directory holds. A failure is logged instead of returned
// because Collect treats a store error as "no proofs at all".
type Stores []Store
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
var all []Candidate
for _, store := range s {
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("certificate store %T unavailable: %v", store, err)
continue
}
all = append(all, candidates...)
}
return all, nil
}
+39
View File
@@ -0,0 +1,39 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
var unsupportedTokenOnce sync.Once
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
func DefaultStore() Store {
return NewFileStore(StoreDir())
}
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
// names one. The token pairs the directory's key-less certificates with its own keys.
func storeWithToken(cfg Config) Store {
files := NewFileStore(StoreDir())
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
return files
}
if !pkcs11.Supported() {
unsupportedTokenOnce.Do(func() {
log.Warnf("ignoring the configured PKCS#11 token: %v", pkcs11.ErrUnsupported)
})
return files
}
token, err := NewPKCS11Store(cfg.PKCS11, StoreDir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
}
return Stores{files, token}
}
@@ -0,0 +1,44 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
func TestStoreWithToken(t *testing.T) {
dir := storeDir(t)
t.Setenv(StoreDirEnv, dir)
files, ok := storeWithToken(Config{}).(*FileStore)
require.True(t, ok, "no token configured reads the PEM directory alone")
assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory")
configured := map[string]PKCS11Config{
"env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"},
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
}
if !pkcs11.Supported() {
for name, cfg := range configured {
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: cfg}),
"%s: a build without PKCS#11 support reads the PEM directory alone", name)
}
return
}
for name, cfg := range configured {
store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores)
require.True(t, ok, "%s joins the token to the PEM directory", name)
require.Len(t, store, 2, name)
token, ok := store[1].(*PKCS11Store)
require.True(t, ok, name)
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
}
}
@@ -0,0 +1,96 @@
package certproof
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestFileStore_TPMKeyFile(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
// A key that needs a password can never be used silently, so its certificate is skipped.
locked := certtest.ECDSAKey(t)
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth()))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
}
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
// swtpm started with:
//
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
//
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
// hierarchy, and proves the certificate for it through the regular file store.
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
if os.Getenv(tpm.DeviceEnv) == "" {
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
}
public, private := createTPMKey(t)
keyPEM := tpmtest.EncodePEM(t, public, private)
block, _ := pem.Decode([]byte(keyPEM))
require.NotNil(t, block)
signer, err := tpm.ParseKey(block.Bytes)
require.NoError(t, err)
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
}
func createTPMKey(t *testing.T) (public, private []byte) {
t.Helper()
rwc, err := tpm.Open()
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
require.NoError(t, err)
return public, private
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !js
package certproof
import (
"crypto"
"github.com/netbirdio/netbird/client/internal/tpm"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file.
const tss2KeyPEMType = tpm.KeyPEMType
// parseTSS2Key returns a signer for a TPM-held key file that signs inside the TPM.
func parseTSS2Key(der []byte) (crypto.Signer, error) {
return tpm.ParseKey(der)
}
@@ -0,0 +1,16 @@
//go:build js
package certproof
import (
"crypto"
"errors"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file. A browser has no TPM, so
// the TPM library stays out of the WebAssembly build and such a key is refused.
const tss2KeyPEMType = "TSS2 PRIVATE KEY"
func parseTSS2Key([]byte) (crypto.Signer, error) {
return nil, errors.New("TPM keys are not supported in this build")
}
@@ -0,0 +1,34 @@
//go:build !unix
package certproof
import (
"fmt"
"io"
"os"
)
// readStoreFile reads a certificate or key file of the PEM directory, up to the size
// limit. Ownership is checked on Unix only; elsewhere the PEM directory is not the store
// the daemon reads by default.
func readStoreFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s is over the %d byte limit", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir accepts any directory where the PEM directory is not a default store.
func checkStoreDir(string) error {
return nil
}
+101
View File
@@ -0,0 +1,101 @@
//go:build unix
package certproof
import (
"errors"
"fmt"
"io"
"os"
"syscall"
)
// readStoreFile reads a certificate or key file of the PEM directory as the daemon may
// trust it: the path must not be a symlink and must be a regular file that only root or
// this process's own user can write. Otherwise a user able to place or redirect a file
// could make the daemon sign with a key of their choosing, or with a root-only key kept
// elsewhere on the machine. The file is checked through the descriptor it is read from,
// so it cannot be swapped between the check and the read.
func readStoreFile(path string) ([]byte, error) {
// O_NONBLOCK keeps a FIFO planted in the directory from blocking the open; the
// regular-file check below then refuses it.
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", path)
}
if err := checkTrustedOwner(info); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if info.Size() > maxStoreFileSize {
return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", path, info.Size(), maxStoreFileSize)
}
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s grew past the %d byte limit while being read", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir refuses a PEM directory that someone other than root or this process's
// user could add files to or rename files in. The configured path may be a symlink, as
// distributions place certificate directories behind them, but only one root or this
// process's user owns: otherwise whoever owns the link could point it at any directory.
func checkStoreDir(dir string) error {
link, err := os.Lstat(dir)
if err != nil {
return err
}
if link.Mode()&os.ModeSymlink != 0 {
if err := checkTrustedUID(link); err != nil {
return fmt.Errorf("symlink %s: %w", dir, err)
}
}
info, err := os.Stat(dir)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", dir)
}
if err := checkTrustedOwner(info); err != nil {
return fmt.Errorf("%s: %w", dir, err)
}
return nil
}
// checkTrustedOwner requires info to belong to root or to this process's user and to be
// writable by nobody else. Group write is refused even for root's group, which ordinary
// users may be members of. An owner that cannot be read is refused.
func checkTrustedOwner(info os.FileInfo) error {
if err := checkTrustedUID(info); err != nil {
return err
}
if perm := info.Mode().Perm(); perm&0o022 != 0 {
return fmt.Errorf("writable by group or other users (mode %#o)", perm)
}
return nil
}
// checkTrustedUID requires info to belong to root or to this process's user.
func checkTrustedUID(info os.FileInfo) error {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("owner cannot be determined")
}
if st.Uid != 0 && int(st.Uid) != os.Geteuid() {
return fmt.Errorf("owned by uid %d, not by root", st.Uid)
}
return nil
}
@@ -0,0 +1,136 @@
//go:build unix
package certproof
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
// devicePair writes a certificate and its key as separate files into dir.
func devicePair(t *testing.T, dir string) {
t.Helper()
ca := certtest.NewCA(t, "corp-root")
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.crt", certtest.CertPEM(ca.Issue(t, key, "device")))
writeFile(t, dir, "device.key", certtest.KeyPEM(t, key))
}
func candidates(t *testing.T, dir string) ([]Candidate, error) {
t.Helper()
return NewFileStore(dir).Candidates(context.Background())
}
func TestFileStore_AcceptsAPrivatePair(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate and key only their owner can write are used")
}
func TestFileStore_RefusesASymlinkedKey(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
// The real key moves elsewhere and the directory only links to it, as a user able
// to write the directory would do to point the daemon at a key it should not use.
elsewhere := filepath.Join(storeDir(t), "other.key")
require.NoError(t, os.Rename(filepath.Join(dir, "device.key"), elsewhere))
require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key reached through a symlink is never used")
}
func TestFileStore_RefusesASymlinkedCertificateFile(t *testing.T) {
dir := storeDir(t)
other := storeDir(t)
devicePair(t, other)
require.NoError(t, os.Symlink(filepath.Join(other, "device.crt"), filepath.Join(dir, "device.crt")))
require.NoError(t, os.Symlink(filepath.Join(other, "device.key"), filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a certificate file that is a symlink is skipped")
}
func TestFileStore_RefusesFilesOthersCanWrite(t *testing.T) {
for name, mode := range map[string]os.FileMode{"world-writable": 0o602, "group-writable": 0o620} {
t.Run(name, func(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(filepath.Join(dir, "device.key"), mode))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key %s may have been replaced by someone else, even when the group is root's", name)
})
}
}
func TestFileStore_RefusesADirectoryOthersCanWrite(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
_, err := candidates(t, dir)
assert.ErrorContains(t, err, "refusing certificate store", "files in a directory anyone can write are not trusted")
}
func TestFileStore_SkipsAFIFOWithoutBlocking(t *testing.T) {
dir := storeDir(t)
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "trap.pem"), 0o600))
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a FIFO is skipped and the real pair still loads")
}
func TestReadStoreFile_RefusesOversizedFiles(t *testing.T) {
dir := storeDir(t)
path := filepath.Join(dir, "huge.pem")
require.NoError(t, os.WriteFile(path, make([]byte, maxStoreFileSize+1), 0o600))
_, err := readStoreFile(path)
assert.ErrorContains(t, err, "limit")
}
func TestFileStore_AcceptsASymlinkedDirectoryItsOwnerControls(t *testing.T) {
target := storeDir(t)
devicePair(t, target)
link := filepath.Join(storeDir(t), "certs")
require.NoError(t, os.Symlink(target, link))
// The link belongs to this process's user, as one root creates belongs to root.
got, err := candidates(t, link)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate directory behind a symlink the owner controls is read")
}
func TestFileStore_ChecksTheDirectoryBeforeListingIt(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
paths, err := certFiles(dir)
assert.ErrorContains(t, err, "refusing certificate store")
assert.Nil(t, paths, "nothing in a directory anyone can write to is listed")
}
func TestFileStore_MissingDirectoryIsEmpty(t *testing.T) {
paths, err := certFiles(filepath.Join(storeDir(t), "missing"))
assert.NoError(t, err, "a store directory that does not exist yet is not an error")
assert.Empty(t, paths)
}
@@ -0,0 +1,270 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"slices"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
personalStore = "MY"
intermediateStore = "CA"
cryptAcquireSilentFlag = 0x00000040
cryptAcquirePreferNCryptKeyFlag = 0x00020000
certNCryptKeySpec = 0xFFFFFFFF
atKeyExchange = 1
atSignature = 2
bcryptPadPSS = 0x00000008
)
// errLegacyKey reports a private key CNG cannot open: it lives in a legacy CryptoAPI
// provider, which only offers PKCS#1 v1.5 and no RSA-PSS or ECDSA.
var errLegacyKey = errors.New("private key is held by a legacy CryptoAPI provider, which certificate posture cannot sign with")
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
)
type bcryptPSSPaddingInfo struct {
algID *uint16
salt uint32
}
// DefaultStore is the local machine's personal certificate store, where device
// certificates enrolled through AD or Intune are kept.
func DefaultStore() Store {
return NewSystemStore()
}
// SystemStore yields the identities of a personal certificate store, completing their
// chains from the matching intermediate CA store. Keys are used through CNG and never
// exported.
//
// The location decides whose certificates these are. The local machine store is the one
// a service reads; the current user store lives in the signed-in user's registry hive
// with keys protected against their profile, so it is only readable while running as
// that user.
type SystemStore struct {
location uint32
}
// NewSystemStore reads the local machine store, which is what the daemon uses.
func NewSystemStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
}
// NewUserStore reads the calling user's personal store. It is only useful in a process
// already running as that user, which is what the posture helper is.
func NewUserStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
}
func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
leaves, err := storeCertificates(s.location, personalStore)
if err != nil {
return nil, err
}
intermediates, err := storeCertificates(s.location, intermediateStore)
if err != nil {
return nil, err
}
log.Debugf("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
if len(leaves) == 0 {
return nil, nil
}
pool := slices.Concat(intermediates, leaves)
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Debugf("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}, Intermediates: pool})
}
return candidates, nil
}
// String names the store location the way the Windows documentation does.
func (s *SystemStore) String() string {
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
return "CurrentUser"
}
return "LocalMachine"
}
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
// at signing time so no handles outlive a call.
type systemStoreSigner struct {
leaf *x509.Certificate
location uint32
}
func (s *systemStoreSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
store, err := openStore(s.location, personalStore)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var signature []byte
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
return false, nil
}
signature, err = signWithContext(ctx, scheme, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the personal store")
}
return signature, nil
}
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
var key uintptr
var keySpec uint32
var callerFree int32
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
if ok == 0 {
return nil, fmt.Errorf("acquire private key: %w", err)
}
if keySpec != certNCryptKeySpec {
if callerFree != 0 {
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
}
return nil, legacyKeyError(keySpec)
}
if callerFree != 0 {
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
}
var padding unsafe.Pointer
var flags uintptr
if scheme == schemeRSAPSSSHA256 {
algID, _ := windows.UTF16PtrFromString("SHA256")
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
}
size, err := ncryptSignHash(key, padding, digest, nil, flags)
if err != nil {
return nil, err
}
signature := make([]byte, size)
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
return nil, err
}
signature = signature[:size]
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// legacyKeyError describes a key acquired through CryptoAPI rather than CNG, naming its
// key spec and the remedy, since the certificate otherwise looks usable in the store.
func legacyKeyError(keySpec uint32) error {
spec := fmt.Sprintf("key spec %d", keySpec)
switch keySpec {
case atKeyExchange:
spec = "AT_KEYEXCHANGE"
case atSignature:
spec = "AT_SIGNATURE"
}
return fmt.Errorf("%w (%s); re-enrol it with a certificate template whose provider is a CNG key storage provider", errLegacyKey, spec)
}
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
var result uint32
var signaturePtr uintptr
if len(signature) > 0 {
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
}
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
if uint32(status) != 0 {
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
}
return result, nil
}
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
store, err := openStore(location, name)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var certs []*x509.Certificate
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
if err != nil {
log.Warnf("skipping certificate in %s store: %v", name, err)
return false, nil
}
certs = append(certs, cert)
return false, nil
})
return certs, err
}
func openStore(location uint32, name string) (windows.Handle, error) {
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return 0, err
}
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
if err != nil {
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
}
return store, nil
}
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
var ctx *windows.CertContext
for {
next, err := windows.CertEnumCertificatesInStore(store, ctx)
if next == nil {
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil
}
return fmt.Errorf("enumerate certificates: %w", err)
}
ctx = next
if stop, err := fn(ctx); stop || err != nil {
_ = windows.CertFreeCertificateContext(ctx)
return err
}
}
}
func encodedCert(ctx *windows.CertContext) []byte {
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
}
@@ -0,0 +1,16 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLegacyKeyError_NamesTheKeySpecAndTheRemedy(t *testing.T) {
for spec, want := range map[uint32]string{atKeyExchange: "AT_KEYEXCHANGE", atSignature: "AT_SIGNATURE", 7: "key spec 7"} {
err := legacyKeyError(spec)
assert.ErrorIs(t, err, errLegacyKey, "callers can tell a legacy key from other failures")
assert.ErrorContains(t, err, want, "the key spec found is named")
assert.ErrorContains(t, err, "key storage provider", "the error says how to fix the enrolment")
}
}
+36
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -75,6 +76,9 @@ type ConnectClient struct {
// netMgr gates every reconnection loop on OS-reported network
// availability and sweeps connections on network change.
netMgr *netevents.Manager
profileOwner string
profileOwnerUnknown bool
}
// ConnectClientOption configures optional ConnectClient behavior.
@@ -85,6 +89,18 @@ func WithNetEvents(events *netevents.Manager) ConnectClientOption {
return func(c *ConnectClient) { c.netMgr = events }
}
// WithProfileOwner names the OS account the active profile belongs to, whose own
// certificate store answers user certificate posture checks.
func WithProfileOwner(username string) ConnectClientOption {
return func(c *ConnectClient) { c.profileOwner = username }
}
// WithUnknownProfileOwner records that the active profile's owner could not be
// determined, so no user's certificate store answers certificate posture checks.
func WithUnknownProfileOwner() ConnectClientOption {
return func(c *ConnectClient) { c.profileOwnerUnknown = true }
}
func NewConnectClient(
ctx context.Context,
config *profilemanager.Config,
@@ -420,6 +436,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
engineConfig.CertStore.ProfileOwner = c.profileOwner
engineConfig.CertStore.OwnerUnknown = c.profileOwnerUnknown
// Leave StateDir empty when there is no state path so a disk-backed
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
if path != "" {
@@ -675,6 +693,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
LazyConnection: lazyconn.ParseState(config.LazyConnection),
CertStore: certStoreConfig(config),
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
@@ -701,6 +721,22 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
return engineConf, nil
}
// certStoreConfig reads where certificate posture finds certificates from the daemon's
// environment, NB_CERT_STORE_DIR and NB_CERT_PKCS11_URI with NB_TPM_PIN. The profile
// config fields that once held them are ignored, and a value left there is reported
// once, so a setup relying on it does not silently stop proving.
func certStoreConfig(config *profilemanager.Config) certproof.Config {
if config.CertStoreDir != "" || config.CertPKCS11URI != "" {
legacyCertConfigOnce.Do(func() {
log.Warnf("certificate posture: CertStoreDir and CertPKCS11URI in the profile config are ignored, set %s and %s in the daemon's environment instead",
certproof.StoreDirEnv, certproof.PKCS11URIEnv)
})
}
return certproof.Config{PKCS11: certproof.PKCS11FromEnv()}
}
var legacyCertConfigOnce sync.Once
func selectMTU(localMTU uint16, peerMTU int32) uint16 {
var finalMTU uint16 = iface.DefaultMTU
if localMTU > 0 {
+26
View File
@@ -0,0 +1,26 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// TestCertStoreConfig_ReadsEnvironmentNotProfile: the token URI may carry the PIN, so it
// and the store directory come from the daemon's environment, and values left in the
// profile config from an older version are ignored.
func TestCertStoreConfig_ReadsEnvironmentNotProfile(t *testing.T) {
t.Setenv(certproof.PKCS11URIEnv, "pkcs11:token=env")
t.Setenv(certproof.PINEnv, "1234")
cfg := certStoreConfig(&profilemanager.Config{
CertStoreDir: "/from/profile",
CertPKCS11URI: "pkcs11:token=profile?pin-value=9999",
})
assert.Equal(t, "pkcs11:token=env", cfg.PKCS11.URI, "the URI comes from the environment")
assert.Equal(t, "1234", cfg.PKCS11.PIN, "the PIN comes from the environment")
}
+1 -1
View File
@@ -647,7 +647,7 @@ const (
jsonKeyServiceEnv = "service_env_vars"
)
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential"}
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential", "pin", "pkcs11"}
// addServiceParams reads the service.json file and adds a sanitized version to the bundle.
// Non-NB_ env vars and vars with sensitive names are masked. Other NB_ values are anonymized.
+10
View File
@@ -458,6 +458,9 @@ func TestIsSensitiveEnvVar(t *testing.T) {
{"NB_CLIENT_SECRET", true},
{"NB_PASSWORD", true},
{"NB_CREDENTIAL", true},
// The token PIN, and the token URI, which may carry the PIN as pin-value.
{"NB_TPM_PIN", true},
{"NB_CERT_PKCS11_URI", true},
{"NB_LOG_LEVEL", false},
{"NB_MANAGEMENT_URL", false},
{"NB_HOSTNAME", false},
@@ -848,6 +851,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
"probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with",
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
"CertPKCS11URI": "deprecated and ignored; sensitive: the URI may carry the token PIN as pin-value",
"CertStoreDir": "deprecated and ignored: the directory comes from NB_CERT_STORE_DIR",
}
mURL, _ := url.Parse("https://api.example.com:443")
@@ -891,6 +896,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
CertPKCS11URI: "pkcs11:token=netbird?pin-value=pin-secret",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
@@ -915,6 +921,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")
// A leftover CertPKCS11URI may carry the token PIN as pin-value.
assert.NotContains(t, rendered, "CertPKCS11URI", "PKCS#11 URI field must not be serialized into the debug bundle")
assert.NotContains(t, rendered, "pin-secret", "PKCS#11 PIN must not leak into the debug bundle")
val := reflect.ValueOf(cfg).Elem()
typ := val.Type()
var missing []string
+80 -14
View File
@@ -37,6 +37,7 @@ import (
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
@@ -170,6 +171,8 @@ type EngineConfig struct {
MTU uint16
CertStore certproof.Config
// for debug bundle generation
ProfileConfig *profilemanager.Config
@@ -279,8 +282,26 @@ type Engine struct {
dnsServer dns.Server
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks
// checks are the client-applied posture checks that need to be evaluated on the client.
// Writers hold syncMsgMux and checksMu; readers hold either, see appliedChecks.
checks []*mgmProto.Checks
checksMu sync.RWMutex
// pendingChecks are the newest received checks whose meta sync failed; the posture
// watcher retries them. Both are guarded by syncMsgMux, and
// hasPendingChecks lets the watcher skip the lock when nothing is pending.
pendingChecks []*mgmProto.Checks
hasPendingChecks atomic.Bool
// infoTimeout overrides systemInfoTimeout when set.
infoTimeout time.Duration
// certProofs answers the certificate challenges in checks within a bounded time, and
// certState remembers what it last proved.
certProofs certproof.Collector
certState certPostureState
// certWake wakes the posture watcher, which owns proof collection; certSendMu orders
// the meta syncs that carry proofs.
certWake chan struct{}
certSendMu sync.Mutex
infoSource system.InfoSource
@@ -385,6 +406,7 @@ func NewEngine(
stateManager: services.StateManager,
portForwardManager: portforward.NewManager(),
checks: services.Checks,
certWake: make(chan struct{}, 1),
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
@@ -679,6 +701,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, peerConfig *mgmPro
e.portForwardManager.Start(e.ctx, uint16(e.config.WgPort))
}()
e.shutdownWg.Add(1)
go func(ctx context.Context) {
defer e.shutdownWg.Done()
e.watchCertificatePosture(ctx)
}(e.ctx)
// Set the WireGuard interface for rosenpass after interface is up
if e.rpManager != nil {
e.rpManager.SetInterface(e.wgInterface)
@@ -1274,23 +1302,54 @@ func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error
func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
// if checks are equal, we skip the update
if isChecksEqual(e.checks, checks) {
e.clearPendingChecks()
return nil
}
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
return nil
if err := e.syncChecksMeta(checks); err != nil {
// The newest checks become the pending ones whatever failed, so the watcher
// never retries a set they superseded.
e.pendingChecks = checks
e.hasPendingChecks.Store(true)
if errors.Is(err, errSystemInfoTimeout) {
return nil
}
return err
}
e.applyInfoFlags(info)
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
}
e.checks = checks
e.setAppliedChecks(checks)
e.clearPendingChecks()
return nil
}
// appliedChecks returns the posture checks in effect, for callers that do not hold
// syncMsgMux. The slice is replaced, never modified, so it may be read after return.
func (e *Engine) appliedChecks() []*mgmProto.Checks {
e.checksMu.RLock()
defer e.checksMu.RUnlock()
return e.checks
}
// setAppliedChecks replaces the posture checks in effect. The caller holds syncMsgMux.
func (e *Engine) setAppliedChecks(checks []*mgmProto.Checks) {
e.checksMu.Lock()
e.checks = checks
e.checksMu.Unlock()
e.wakeCertificatePosture()
}
// clearPendingChecks drops checks whose meta sync was still owed. The caller holds
// syncMsgMux.
func (e *Engine) clearPendingChecks() {
e.pendingChecks = nil
e.hasPendingChecks.Store(false)
}
func (e *Engine) infoGatherTimeout() time.Duration {
if e.infoTimeout > 0 {
return e.infoTimeout
}
return systemInfoTimeout
}
// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
func (e *Engine) applyInfoFlags(info *system.Info) {
info.SetFlags(
@@ -1317,6 +1376,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.appliedChecks())
return info
}
@@ -1332,6 +1392,7 @@ func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context)
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.appliedChecks())
return info
}
}
@@ -1534,7 +1595,7 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), e.appliedChecks(), e.overlayAddresses()...)
if !ok {
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
}
@@ -2796,6 +2857,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
sortedFiles := slices.Clone(check.Files)
sort.Strings(sortedFiles)
normalized[i] = strings.Join(sortedFiles, "|")
if challenge := check.GetCertificateChallenge(); challenge != nil {
sortedCAs := slices.Clone(challenge.GetCaCertificates())
sort.Strings(sortedCAs)
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
}
}
sort.Strings(normalized)
+1 -1
View File
@@ -88,7 +88,7 @@ func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.T
return time.Time{}, errors.New("management client is not initialised")
}
info, err := system.GetInfoWithChecks(ctx, e.checks)
info, err := system.GetInfoWithChecks(ctx, e.appliedChecks())
if err != nil {
log.Warnf("failed to collect system info for session extend: %v", err)
info = system.GetInfo(ctx)
+386
View File
@@ -0,0 +1,386 @@
package internal
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/certproof"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
const (
// certContextPollInterval is how often the engine checks whether the user who can
// answer certificate challenges has changed, such as a login after an autostart.
certContextPollInterval = time.Minute
// certRetryInterval is how long after a collection that proved nothing it is tried
// again with the same user, for stores that come up late: a keychain unlocked after
// login, or a TPM resource manager started after the daemon.
certRetryInterval = 5 * time.Minute
)
// undeliveredContext never matches a real user context, so a collection whose meta sync
// failed is seen as stale on the next poll.
const undeliveredContext = "\x00undelivered"
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
var errSystemInfoTimeout = errors.New("system info gathering timed out")
// certPostureState remembers what the last certificate proof collection saw, so the
// engine can collect again when it went stale and tell the user when proofs go missing.
//
// Collection runs only on the posture watcher, never on the sync path, because a token,
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
// last collection cached. delivered identifies the chains management last received, so
// a collection that proves the same chains is not sent again: every meta sync makes
// management recompute and push network maps to the peer and its neighbours.
type certPostureState struct {
mu sync.Mutex
attempted bool
attemptedAt time.Time
userContext string
proven bool
delivered string
hasDelivery bool
cached []certposture.Proof
cachedFor string
// stuck is whether the collector was last seen with every slot held by a lost
// collection.
stuck bool
}
// record stores the outcome of a collection for the challenges identified by
// challengesKey and reports whether it changed from proven to unproven or back. The first
// collection only counts as a change when it proved nothing.
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
s.mu.Lock()
defer s.mu.Unlock()
proven := len(proofs) > 0
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
s.attempted = true
s.attemptedAt = now
s.userContext = userContext
s.proven = proven
s.cached = proofs
s.cachedFor = challengesKey
return changed
}
// undelivered marks the last collection as not having reached management, so the next
// poll collects and sends again instead of treating it as current.
func (s *certPostureState) undelivered() {
s.mu.Lock()
defer s.mu.Unlock()
if s.attempted {
s.userContext = undeliveredContext
}
s.hasDelivery = false
}
// markDelivered records the chains of proofs as the ones management holds.
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
s.mu.Lock()
defer s.mu.Unlock()
s.delivered = provenChainsKey(proofs)
s.hasDelivery = true
}
// sameAsDelivered reports whether proofs prove exactly the chains management already
// holds. Nonces and signatures are left out: they differ on every signing while the
// result management stores, the verified chains, stays the same.
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
}
// needsCollection reports whether the cached proofs no longer reflect what the device
// can prove for the challenges identified by challengesKey: they answer other
// challenges, the user who can answer has changed, or nothing was proven and the retry
// interval has passed.
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case !s.attempted || s.cachedFor != challengesKey:
return true
case userContext != s.userContext:
return true
default:
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
}
}
// setStuck records whether the collector is stuck and reports whether it just became so.
func (s *certPostureState) setStuck(stuck bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
became := stuck && !s.stuck
s.stuck = stuck
return became
}
// cachedProofs returns the cached proofs management still accepts for the challenges in
// checks: those whose nonce is current, or from an adjacent window, for one of them. Proofs
// signed for the previous nonce bridge the time until the watcher has signed the new one.
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
s.mu.Lock()
defer s.mu.Unlock()
var nonces [][]byte
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil {
nonces = append(nonces, challenge.GetNonce())
}
}
var accepted []certposture.Proof
for _, proof := range s.cached {
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
}) {
accepted = append(accepted, proof)
}
}
return accepted
}
// provenChainsKey identifies the set of chains in proofs, independent of their order.
func provenChainsKey(proofs []certposture.Proof) string {
keys := make([]string, 0, len(proofs))
for _, proof := range proofs {
h := sha256.New()
for _, cert := range proof.Chain {
writeLengthPrefixed(h, cert)
}
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
}
slices.Sort(keys)
return strings.Join(keys, ",")
}
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
func challengesKey(checks []*mgmProto.Checks) string {
h := sha256.New()
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
writeLengthPrefixed(h, challenge.GetNonce())
for _, ca := range challenge.GetCaCertificates() {
writeLengthPrefixed(h, []byte(ca))
}
writeLengthPrefixed(h, nil)
}
return hex.EncodeToString(h.Sum(nil))
}
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
_, _ = h.Write(size[:])
_, _ = h.Write(data)
}
// attachCertificateProofs attaches the cached proofs management accepts for the
// challenges in checks, without collecting: the caller is on the sync path. The info is
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
// count as delivered. The watcher is woken to collect when the cache is out of date.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
if !certproof.HasChallenges(checks) {
info.CertificateProofs = nil
return
}
info.CertificateProofs = e.certState.cachedProofs(checks)
e.certState.markDelivered(info.CertificateProofs)
e.wakeCertificatePosture()
}
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
// its next tick. It never blocks.
func (e *Engine) wakeCertificatePosture() {
if e.certWake == nil {
return
}
select {
case e.certWake <- struct{}{}:
default:
}
}
// publishCertificatePostureEvent tells the user when the device stops proving any
// certificate, which management treats as failing every certificate posture check, and
// when it proves one again. Without it the loss of access would have no visible cause.
func (e *Engine) publishCertificatePostureEvent(proven bool) {
if e.statusRecorder == nil {
return
}
if proven {
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
"certificate posture: a certificate is proven again",
"A certificate required by your organization's device policy is available again.", nil)
return
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
"certificate posture: no certificate could be proven",
"NetBird could not use a certificate required by your organization's device policy. "+
"Access to some resources may be blocked until one is available.", nil)
}
// publishCertificateStoreStuckEvent tells the user that reading the certificate store
// stopped answering. Collection resumes only once a stuck call returns, which a store
// that hangs for good never does short of a restart.
func (e *Engine) publishCertificateStoreStuckEvent() {
if e.statusRecorder == nil {
return
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
"certificate posture: the certificate store stopped responding",
"NetBird cannot read the certificates required by your organization's device policy because "+
"the certificate store stopped responding. If this persists, restart the NetBird service.", nil)
}
// watchCertificatePosture owns certificate proof collection until ctx is done. It
// collects when woken by new checks or a sync, and on every tick when the cached proofs
// went stale, such as after a login following an autostart.
func (e *Engine) watchCertificatePosture(ctx context.Context) {
ticker := time.NewTicker(certContextPollInterval)
defer ticker.Stop()
for {
// A pending update that times out again stays pending; the applied checks'
// proofs are still refreshed so a lost certificate is reported meanwhile.
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to sync posture checks that timed out before: %v", err)
}
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to refresh certificate posture proofs: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-e.certWake:
}
}
}
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
// gathering the system info, and applies them once it succeeds. Without it they would
// wait for the next network map that changes the checks.
func (e *Engine) retryPendingChecks() error {
if !e.hasPendingChecks.Load() {
return nil
}
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.stopping() || !e.hasPendingChecks.Load() {
return nil
}
checks := e.pendingChecks
log.Debugf("posture checks: retrying the meta sync that timed out")
if err := e.syncChecksMeta(checks); err != nil {
return err
}
e.setAppliedChecks(checks)
e.clearPendingChecks()
return nil
}
// refreshCertificateProofs collects the proofs for the applied checks when the cached
// ones are out of date, and sends them to management when they prove other chains than
// it holds. It holds no engine lock while collecting.
func (e *Engine) refreshCertificateProofs() error {
checks := e.appliedChecks()
if e.stopping() || !certproof.HasChallenges(checks) {
return nil
}
key := challengesKey(checks)
userContext := certproof.UserContext(e.config.CertStore)
if !e.certState.needsCollection(key, userContext, time.Now()) {
return nil
}
log.Debugf("certificate posture: collecting proofs")
peerKey := e.config.WgPrivateKey.PublicKey()
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
// A collection cut short by the engine stopping proved nothing about the device, and
// must not be reported to the user as a lost certificate.
if e.stopping() {
return nil
}
if e.certState.record(key, userContext, proofs, time.Now()) {
e.publishCertificatePostureEvent(len(proofs) > 0)
}
if e.certState.setStuck(e.certProofs.Stuck()) {
e.publishCertificateStoreStuckEvent()
}
if e.certState.sameAsDelivered(proofs) {
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
return nil
}
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// stopping reports whether the engine is shutting down.
func (e *Engine) stopping() bool {
select {
case <-e.ctx.Done():
return true
default:
return false
}
}
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
// with the cached certificate proofs. The caller holds syncMsgMux.
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
// are serialized and read the cache under that lock, so a send from the sync path cannot
// overwrite newer proofs the watcher sent in the meantime.
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
e.certSendMu.Lock()
defer e.certSendMu.Unlock()
info.CertificateProofs = e.certState.cachedProofs(checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
e.certState.undelivered()
return fmt.Errorf("sync meta: %w", err)
}
e.certState.markDelivered(info.CertificateProofs)
return nil
}
+224
View File
@@ -0,0 +1,224 @@
//go:build !windows && !darwin
package internal
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
mgmt "github.com/netbirdio/netbird/shared/management/client"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertPostureState_Record(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var proven certPostureState
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
var unproven certPostureState
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
}
// TestCertPostureState_ReportsStuckOnce: a store that stopped answering is reported to
// the user once when it wedges the collector, not on every collection that follows, and
// again if it wedges anew after recovering.
func TestCertPostureState_ReportsStuckOnce(t *testing.T) {
var s certPostureState
assert.True(t, s.setStuck(true), "becoming stuck is reported")
assert.False(t, s.setStuck(true), "staying stuck is not reported again")
assert.False(t, s.setStuck(false), "recovering is not a stuck report")
assert.True(t, s.setStuck(true), "getting stuck again is reported again")
}
func TestCertPostureState_NeedsCollection(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var s certPostureState
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
s.record("k", "501:alice", one, now)
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
s.record("k", "", nil, now)
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
s.record("k", "501:alice", one, now)
s.undelivered()
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
}
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
// window and accepts the previous one, so a cached proof keeps being attached across one
// rotation, while the watcher signs the new nonce, and not across two.
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
challenger := certposture.NewChallenger([]byte("secret"))
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
now := time.Now()
checksAt := func(at time.Time) []*mgmProto.Checks {
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
}
var s certPostureState
current := checksAt(now)
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
}
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
// whose management client records the proofs of every meta sync.
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
t.Helper()
// The store refuses a group-writable directory, which t.TempDir yields under a
// user-private-group umask.
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
t.Setenv(certproof.StoreDirEnv, dir)
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
e := &Engine{
ctx: context.Background(),
syncMsgMux: &sync.Mutex{},
config: &EngineConfig{WgPrivateKey: key},
statusRecorder: recorder,
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
}
ca := certtest.NewCA(t, "corp-root")
peerKey := key.PublicKey()
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
return e, dir, ca
}
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
t.Helper()
deviceKey := certtest.ECDSAKey(t)
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
return pem
}
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
// watcher collected, so a certificate that appears in the store is not proven on the sync
// path until the watcher ran.
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
writeDeviceCert(t, dir, ca)
info := &system.Info{}
e.attachCertificateProofs(info, e.checks)
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
require.NoError(t, e.refreshCertificateProofs())
e.attachCertificateProofs(info, e.checks)
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
}
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
// certificate gets one warning, and one notice when it can again.
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
recorder := peer.NewRecorder("")
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
warnings := func() int {
n := 0
for _, ev := range recorder.GetEventHistory() {
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
n++
}
}
return n
}
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
events := recorder.GetEventHistory()
require.NotEmpty(t, events)
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
}
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
// and push network maps, so a collection that proves the same chains as before must not
// send, while a new certificate or a failed delivery must.
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
var sent [][]certposture.Proof
failSync := false
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
if failSync {
return errors.New("management unavailable")
}
sent = append(sent, info.CertificateProofs)
return nil
})
// expire makes the last collection due for the unproven retry.
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
// switchUser makes the last collection stale as if another user signed in.
switchUser := func() { e.certState.userContext = "someone else" }
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 1, "the first collection is sent")
assert.Empty(t, sent[0], "nothing to prove yet")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 1, "still proving nothing is not sent again")
pem := writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 2, "a newly proven certificate is sent")
assert.Len(t, sent[1], 1, "the new proof is attached")
switchUser()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
failSync = true
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
switchUser()
require.Error(t, e.refreshCertificateProofs())
failSync = false
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
// Management missed the empty proof set, so it still holds the chain the device proves
// again now; after a failed delivery the state is unknown, and it is sent regardless.
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
}
+131
View File
@@ -359,6 +359,111 @@ func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
assert.Equal(t, 2, syncMetaCalls)
}
// TestEngine_FailedUpdateReplacesOlderPendingChecks: checks A time out and stay pending,
// then checks B fail to sync for another reason. B must replace A as pending, or the
// watcher would later apply the superseded A.
func TestEngine_FailedUpdateReplacesOlderPendingChecks(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun107",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33102,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: &mgmt.MockClient{SyncMetaFunc: func(*system.Info) error { return errors.New("management unavailable") }},
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checksA := []*mgmtProto.Checks{{Files: []string{"/checks/a"}}}
checksB := []*mgmtProto.Checks{{Files: []string{"/checks/b"}}}
engine.infoTimeout = time.Nanosecond
require.NoError(t, engine.updateChecksIfNew(checksA))
require.Equal(t, checksA, engine.pendingChecks, "precondition: the timed-out checks are pending")
// Let the abandoned gathering finish so B gets as far as the meta sync.
engine.infoTimeout = 0
require.Eventually(t, func() bool {
_, ok := engine.infoSource.Refresh(ctx, 10*time.Second, nil)
return ok
}, 10*time.Second, 10*time.Millisecond)
require.Error(t, engine.updateChecksIfNew(checksB), "the meta sync of B fails")
assert.Equal(t, checksB, engine.pendingChecks, "the newest checks replace the older pending ones")
}
// TestEngine_PendingChecksRetriedAfterInfoTimeout covers a check update whose system info
// gathering times out: the update is kept pending rather than dropped, and the posture
// watcher's retry sends it and applies the checks, without waiting for management to
// send different checks.
func TestEngine_PendingChecksRetriedAfterInfoTimeout(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
var synced []*system.Info
mgmClient := &mgmt.MockClient{
SyncMetaFunc: func(info *system.Info) error {
synced = append(synced, info)
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun106",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33101,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
// Gathering cannot finish within a nanosecond, so the sync times out.
engine.infoTimeout = time.Nanosecond
require.NoError(t, engine.updateChecksIfNew(checks), "a timed-out gathering is not an error")
assert.Empty(t, synced, "nothing is sent when gathering timed out")
assert.Nil(t, engine.checks, "timed-out checks are not applied")
require.True(t, engine.hasPendingChecks.Load(), "timed-out checks are kept pending")
// The timed-out gathering keeps running in the background, and no new one starts
// until it exits, so the retry may time out again before it goes through.
engine.infoTimeout = 0
var retryErr error
require.Eventually(t, func() bool {
retryErr = engine.retryPendingChecks()
return retryErr == nil || !errors.Is(retryErr, errSystemInfoTimeout)
}, 10*time.Second, 10*time.Millisecond, "the pending checks are sent once the earlier gathering exits")
require.NoError(t, retryErr)
require.Len(t, synced, 1, "the retry sends the meta sync")
assert.Len(t, synced[0].Files, 1, "the retry evaluates the pending checks")
assert.Equal(t, checks, engine.checks, "the retried checks are applied")
assert.False(t, engine.hasPendingChecks.Load(), "nothing is pending after the retry")
require.NoError(t, engine.retryPendingChecks())
require.NoError(t, engine.updateChecksIfNew(checks))
assert.Len(t, synced, 1, "applied checks are not sent again")
}
func TestEngine_UpdateNetworkMap(t *testing.T) {
// test setup
key, err := wgtypes.GeneratePrivateKey()
@@ -1179,6 +1284,32 @@ func Test_CheckFilesEqual(t *testing.T) {
},
expectedBool: true,
},
{
name: "Same files with rotated certificate challenge nonce should return false",
inputChecks1: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a"}},
},
},
inputChecks2: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{2}, CaCertificates: []string{"ca-a"}},
},
},
expectedBool: false,
},
{
name: "Same certificate challenge with CA certificates in different order should return true",
inputChecks1: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a", "ca-b"}}},
},
inputChecks2: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-b", "ca-a"}}},
},
expectedBool: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
+372
View File
@@ -0,0 +1,372 @@
//go:build pkcs11 && linux && !android && (amd64 || arm64)
package pkcs11
import (
"errors"
"fmt"
"runtime"
"strings"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
// this file builds for. The struct layouts below assume that width and natural alignment.
type ulong = uintptr
const (
unavailableInformation = ^ulong(0)
flagOSLockingOK = 0x2
flagRWSession = 0x2
flagSerialSession = 0x4
userTypeUser = 0x1
findBatch = 32
// Upper bounds on sizes a module reports, so a buggy module cannot make the daemon
// allocate without limit. Certificates and keys are far smaller; an RSA-16384
// signature is 2 KiB.
maxAttributeLen = 1 << 20
maxSignatureLen = 1 << 16
)
type version struct {
major byte
minor byte
}
type attribute struct {
typ ulong
value unsafe.Pointer
len ulong
}
type mechanism struct {
typ ulong
parameter unsafe.Pointer
len ulong
}
type pssParams struct {
hashAlg ulong
mgf ulong
saltLen ulong
}
type tokenInfo struct {
label [32]byte
manufacturerID [32]byte
model [16]byte
serialNumber [16]byte
flags ulong
counters [10]ulong
hardware version
firmware version
utcTime [16]byte
}
type initializeArgs struct {
createMutex uintptr
destroyMutex uintptr
lockMutex uintptr
unlockMutex uintptr
flags ulong
reserved unsafe.Pointer
}
// functionList mirrors CK_FUNCTION_LIST: a CK_VERSION padded to pointer alignment, then
// the PKCS#11 v2.40 entry points in specification order.
type functionList struct {
version version
_ [6]byte
fn [68]uintptr
}
const (
fnInitialize = 0
fnGetSlotList = 4
fnGetTokenInfo = 6
fnOpenSession = 12
fnCloseSession = 13
fnLogin = 18
fnLogout = 19
fnCreateObject = 20
fnGetAttributeValue = 24
fnFindObjectsInit = 26
fnFindObjects = 27
fnFindObjectsFinal = 28
fnSignInit = 42
fnSign = 43
)
// module holds the entry points of one loaded library, bound straight from its
// CK_FUNCTION_LIST.
type module struct {
cInitialize func(args *initializeArgs) ulong
cGetSlotList func(tokenPresent byte, slots *ulong, count *ulong) ulong
cGetTokenInfo func(slot ulong, info *tokenInfo) ulong
cOpenSession func(slot ulong, flags ulong, application unsafe.Pointer, notify uintptr, session *ulong) ulong
cCloseSession func(session ulong) ulong
cLogin func(session ulong, userType ulong, pin *byte, pinLen ulong) ulong
cLogout func(session ulong) ulong
cCreateObject func(session ulong, template *attribute, count ulong, object *ulong) ulong
cGetAttributeValue func(session ulong, object ulong, template *attribute, count ulong) ulong
cFindObjectsInit func(session ulong, template *attribute, count ulong) ulong
cFindObjects func(session ulong, objects *ulong, max ulong, count *ulong) ulong
cFindObjectsFinal func(session ulong) ulong
cSignInit func(session ulong, mech *mechanism, key ulong) ulong
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
}
// Supported reports whether this build can load PKCS#11 modules.
func Supported() bool {
return true
}
func load(path string) (_ driver, err error) {
lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
if err != nil {
return nil, fmt.Errorf("open PKCS#11 module %s: %w", path, err)
}
// A module that loads but cannot be used is not cached, so each later attempt opens
// it again; close it here or every attempt keeps another reference to the library.
defer func() {
if err != nil {
if closeErr := purego.Dlclose(lib); closeErr != nil {
log.Debugf("failed closing PKCS#11 module %s: %v", path, closeErr)
}
}
}()
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
if err != nil {
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
}
var getFunctionList func(list **functionList) ulong
purego.RegisterFunc(&getFunctionList, symbol)
var list *functionList
if rv := getFunctionList(&list); rv != rvOK || list == nil {
return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)}
}
m := &module{}
for _, entry := range []struct {
fn any
index int
}{
{&m.cInitialize, fnInitialize},
{&m.cGetSlotList, fnGetSlotList},
{&m.cGetTokenInfo, fnGetTokenInfo},
{&m.cOpenSession, fnOpenSession},
{&m.cCloseSession, fnCloseSession},
{&m.cLogin, fnLogin},
{&m.cLogout, fnLogout},
{&m.cCreateObject, fnCreateObject},
{&m.cGetAttributeValue, fnGetAttributeValue},
{&m.cFindObjectsInit, fnFindObjectsInit},
{&m.cFindObjects, fnFindObjects},
{&m.cFindObjectsFinal, fnFindObjectsFinal},
{&m.cSignInit, fnSignInit},
{&m.cSign, fnSign},
} {
if list.fn[entry.index] == 0 {
return nil, fmt.Errorf("%s lacks PKCS#11 entry point %d", path, entry.index)
}
purego.RegisterFunc(entry.fn, list.fn[entry.index])
}
args := &initializeArgs{flags: flagOSLockingOK}
if rv := m.cInitialize(args); rv != rvOK && rv != rvAlreadyInitialized {
return nil, Error{Op: "C_Initialize", Code: uint(rv)}
}
return m, nil
}
func (m *module) tokens() ([]Token, error) {
var count ulong
if rv := m.cGetSlotList(1, nil, &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
if count == 0 {
return nil, nil
}
slots := make([]ulong, count)
if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
tokens := make([]Token, 0, count)
for _, slot := range slots[:count] {
var info tokenInfo
if rv := m.cGetTokenInfo(slot, &info); rv != rvOK {
continue
}
tokens = append(tokens, Token{Slot: uint(slot), Label: strings.TrimRight(string(info.label[:]), " \x00")})
}
return tokens, nil
}
func (m *module) openSession(slot uint, readWrite bool) (uint, error) {
flags := ulong(flagSerialSession)
if readWrite {
flags |= flagRWSession
}
var session ulong
if rv := m.cOpenSession(ulong(slot), flags, nil, 0, &session); rv != rvOK {
return 0, Error{Op: "C_OpenSession", Code: uint(rv)}
}
return uint(session), nil
}
func (m *module) closeSession(session uint) {
m.cCloseSession(ulong(session))
}
func (m *module) login(session uint, pin []byte) error {
var pinPtr *byte
if len(pin) > 0 {
pinPtr = &pin[0]
}
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
runtime.KeepAlive(pin)
if rv != rvOK {
return Error{Op: "C_Login", Code: uint(rv)}
}
return nil
}
func (m *module) logout(session uint) {
m.cLogout(ulong(session))
}
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
var pinner runtime.Pinner
attrs := toAttributes(template, &pinner)
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
}
defer m.cFindObjectsFinal(ulong(session))
var objects []Object
for {
var batch [findBatch]ulong
var count ulong
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
}
if count > findBatch {
return nil, fmt.Errorf("C_FindObjects reported %d handles for a batch of %d", count, findBatch)
}
for _, handle := range batch[:count] {
objects = append(objects, Object(handle))
}
if count < findBatch {
return objects, nil
}
}
}
func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
attr := attribute{typ: ulong(typ)}
if rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1); rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len == unavailableInformation {
return nil, fmt.Errorf("attribute 0x%x is unavailable", typ)
}
if attr.len == 0 {
return nil, nil
}
if attr.len > maxAttributeLen {
return nil, fmt.Errorf("attribute 0x%x reports %d bytes, over the %d byte limit", typ, attr.len, maxAttributeLen)
}
value := make([]byte, attr.len)
var pinner runtime.Pinner
pinner.Pin(&value[0])
attr.value = unsafe.Pointer(&value[0])
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len > ulong(len(value)) {
return nil, fmt.Errorf("attribute 0x%x grew from %d to %d bytes between calls", typ, len(value), attr.len)
}
return value[:attr.len], nil
}
func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("nothing to sign")
}
native := mechanism{typ: ulong(mech.Type)}
var pinner runtime.Pinner
if mech.PSS != nil {
params := &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
pinner.Pin(params)
native.parameter = unsafe.Pointer(params)
native.len = ulong(unsafe.Sizeof(*params))
}
rv := m.cSignInit(ulong(session), &native, ulong(key))
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
}
var size ulong
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
if size == 0 || size > maxSignatureLen {
return nil, fmt.Errorf("C_Sign reports a %d byte signature", size)
}
signature := make([]byte, size)
rv = m.cSign(ulong(session), &data[0], ulong(len(data)), &signature[0], &size)
runtime.KeepAlive(data)
if rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
if size > ulong(len(signature)) {
return nil, fmt.Errorf("C_Sign wrote %d bytes into a %d byte buffer", size, len(signature))
}
return signature[:size], nil
}
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
var pinner runtime.Pinner
attrs := toAttributes(template, &pinner)
var object ulong
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
pinner.Unpin()
if rv != rvOK {
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
}
return Object(object), nil
}
// toAttributes builds the C template for template. The values stay Go memory referenced
// from Go memory passed to C, so each is pinned; the caller unpins after the call.
func toAttributes(template []Attribute, pinner *runtime.Pinner) []attribute {
attrs := make([]attribute, len(template))
for i, a := range template {
attrs[i].typ = ulong(a.Type)
if len(a.Value) > 0 {
pinner.Pin(&a.Value[0])
attrs[i].value = unsafe.Pointer(&a.Value[0])
attrs[i].len = ulong(len(a.Value))
}
}
return attrs
}
func first(attrs []attribute) *attribute {
if len(attrs) == 0 {
return nil
}
return &attrs[0]
}
@@ -0,0 +1,72 @@
//go:build pkcs11 && linux && !android && (amd64 || arm64)
package pkcs11
import (
"crypto/x509"
"os"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStructLayoutsMatchTheCABI(t *testing.T) {
assert.Equal(t, uintptr(24), unsafe.Sizeof(attribute{}), "CK_ATTRIBUTE")
assert.Equal(t, uintptr(24), unsafe.Sizeof(mechanism{}), "CK_MECHANISM")
assert.Equal(t, uintptr(24), unsafe.Sizeof(pssParams{}), "CK_RSA_PKCS_PSS_PARAMS")
assert.Equal(t, uintptr(208), unsafe.Sizeof(tokenInfo{}), "CK_TOKEN_INFO")
assert.Equal(t, uintptr(48), unsafe.Sizeof(initializeArgs{}), "CK_C_INITIALIZE_ARGS")
assert.Equal(t, uintptr(8), unsafe.Offsetof(functionList{}.fn), "entry points follow the padded CK_VERSION")
assert.Equal(t, uintptr(8+68*8), unsafe.Sizeof(functionList{}), "CK_FUNCTION_LIST v2.40")
}
// TestTrustModule_ListsSystemCertificates drives a real module through the binding:
// p11-kit's trust module exposes the system CA store as certificate objects with no login.
func TestTrustModule_ListsSystemCertificates(t *testing.T) {
module := loadFirst(t,
"/usr/lib/pkcs11/p11-kit-trust.so",
"/usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib/aarch64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib64/pkcs11/p11-kit-trust.so",
)
tokens, err := module.Tokens()
require.NoError(t, err)
require.NotEmpty(t, tokens, "the trust module must present at least one token")
parsed := 0
for _, token := range tokens {
session, err := module.OpenSession(token.Label, nil)
require.NoError(t, err, token.Label)
objects, err := session.FindObjects(
Attribute{Type: AttrClass, Value: ULong(ClassCertificate)},
Attribute{Type: AttrCertificateType, Value: ULong(CertificateX509)},
)
require.NoError(t, err, token.Label)
for _, object := range objects {
der, err := session.Attribute(object, AttrValue)
require.NoError(t, err)
_, err = x509.ParseCertificate(der)
require.NoError(t, err, "CKA_VALUE must be a DER certificate")
parsed++
}
session.Close()
}
assert.Positive(t, parsed, "system trust anchors must be readable through the binding")
}
func loadFirst(t *testing.T, paths ...string) *Module {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
module, err := Load(path)
require.NoError(t, err, path)
return module
}
t.Skip("p11-kit trust module not installed")
return nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !(pkcs11 && linux && !android && (amd64 || arm64))
package pkcs11
// Supported reports whether this build can load PKCS#11 modules.
func Supported() bool {
return false
}
func load(string) (driver, error) {
return nil, ErrUnsupported
}
@@ -0,0 +1,19 @@
package pkcs11
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPINRejected(t *testing.T) {
for _, code := range []uint{rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked} {
err := fmt.Errorf("open session: %w", Error{Op: "C_Login", Code: code})
assert.True(t, PINRejected(err), "CKR 0x%x refuses the PIN, also when wrapped", code)
}
assert.False(t, PINRejected(Error{Op: "C_Login", Code: 0x30}), "a device error says nothing about the PIN")
assert.False(t, PINRejected(errors.New("CKR_PIN_INCORRECT")), "only a PKCS#11 return value counts")
assert.False(t, PINRejected(nil))
}
+328
View File
@@ -0,0 +1,328 @@
// Package pkcs11 is a minimal PKCS#11 client. It loads a module at runtime without cgo,
// opens a token session, lists objects and signs with keys the token holds. It exists so
// certificates whose keys live in a TPM behind tpm2-pkcs11 can be proven; whatever a
// certificate store does not need is left out.
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"sync"
)
// Object classes, attribute types, mechanisms and generators from PKCS#11 v2.40.
const (
ClassCertificate = 0x1
ClassPublicKey = 0x2
ClassPrivateKey = 0x3
CertificateX509 = 0x0
AttrClass = 0x0
AttrToken = 0x1
AttrLabel = 0x3
AttrValue = 0x11
AttrCertificateType = 0x80
AttrKeyType = 0x100
AttrSubject = 0x101
AttrID = 0x102
AttrModulus = 0x120
AttrPublicExponent = 0x122
AttrECParams = 0x180
AttrECPoint = 0x181
KeyRSA = 0x0
KeyEC = 0x3
MechRSAPKCSPSS = 0xd
MechSHA256 = 0x250
MechSHA384 = 0x260
MechECDSA = 0x1041
MGF1SHA256 = 0x2
MGF1SHA384 = 0x3
rvOK = 0x0
rvPINIncorrect = 0xa0
rvPINInvalid = 0xa1
rvPINLenRange = 0xa2
rvPINExpired = 0xa3
rvPINLocked = 0xa4
rvUserAlreadyLoggedIn = 0x100
rvAlreadyInitialized = 0x191
)
var ErrUnsupported = errors.New("PKCS#11 modules need a build with the pkcs11 tag on linux/amd64 or linux/arm64")
// Error is a PKCS#11 return value other than CKR_OK.
type Error struct {
Op string
Code uint
}
func (e Error) Error() string {
if name, ok := returnValueNames[e.Code]; ok {
return fmt.Sprintf("%s: %s", e.Op, name)
}
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
}
// PINRejected reports whether err is the token refusing the user PIN. Retrying the same
// PIN cannot succeed, and each attempt counts towards the token's lockout.
func PINRejected(err error) bool {
var e Error
if !errors.As(err, &e) {
return false
}
switch e.Code {
case rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked:
return true
}
return false
}
func isCode(err error, code uint) bool {
var e Error
return errors.As(err, &e) && e.Code == code
}
var returnValueNames = map[uint]string{
0x2: "CKR_HOST_MEMORY",
0x3: "CKR_SLOT_ID_INVALID",
0x5: "CKR_GENERAL_ERROR",
0x7: "CKR_ARGUMENTS_BAD",
0x12: "CKR_ATTRIBUTE_TYPE_INVALID",
0x13: "CKR_ATTRIBUTE_VALUE_INVALID",
0x30: "CKR_DEVICE_ERROR",
0x54: "CKR_FUNCTION_NOT_SUPPORTED",
0x68: "CKR_KEY_FUNCTION_NOT_PERMITTED",
0x70: "CKR_MECHANISM_INVALID",
0x71: "CKR_MECHANISM_PARAM_INVALID",
0x82: "CKR_OBJECT_HANDLE_INVALID",
0xa0: "CKR_PIN_INCORRECT",
0xa1: "CKR_PIN_INVALID",
0xa2: "CKR_PIN_LEN_RANGE",
0xa3: "CKR_PIN_EXPIRED",
0xa4: "CKR_PIN_LOCKED",
0xb3: "CKR_SESSION_HANDLE_INVALID",
0xd0: "CKR_TEMPLATE_INCOMPLETE",
0xd1: "CKR_TEMPLATE_INCONSISTENT",
0xe0: "CKR_TOKEN_NOT_PRESENT",
0x101: "CKR_USER_NOT_LOGGED_IN",
0x150: "CKR_BUFFER_TOO_SMALL",
0x190: "CKR_CRYPTOKI_NOT_INITIALIZED",
}
// Attribute is one entry of a PKCS#11 template. Integer-valued attributes such as the
// object class are encoded with ULong.
type Attribute struct {
Type uint
Value []byte
}
// Mechanism selects a signing algorithm. PSS carries the parameters CKM_RSA_PKCS_PSS needs.
type Mechanism struct {
Type uint
PSS *PSSParams
}
type PSSParams struct {
Hash uint
MGF uint
SaltLen uint
}
// Object is a handle the token issued for one of its objects.
type Object uint
// Token is a slot with a token present.
type Token struct {
Slot uint
Label string
}
// Module is a loaded and initialised PKCS#11 library. A module is loaded once per path
// and never finalised: tokens such as tpm2-pkcs11 do real work in C_Initialize, and the
// process exit releases everything anyway.
type Module struct {
d driver
// loginMu guards logins, the number of open sessions relying on the user login of
// each slot, and serializes logging in so a PIN is never sent twice at once.
loginMu sync.Mutex
logins map[uint]int
}
var (
modulesMu sync.Mutex
modules = map[string]*Module{}
)
// Load opens the shared library at path and initialises it, or returns the module already
// loaded from that path.
func Load(path string) (*Module, error) {
modulesMu.Lock()
defer modulesMu.Unlock()
if m, ok := modules[path]; ok {
return m, nil
}
d, err := load(path)
if err != nil {
return nil, err
}
m := &Module{d: d}
modules[path] = m
return m, nil
}
func (m *Module) Tokens() ([]Token, error) {
return m.d.tokens()
}
// OpenSession opens a read-only session with the token carrying label, or with the first
// token when label is empty, and logs in as the user when pin is not nil. An empty,
// non-nil pin still logs in.
func (m *Module) OpenSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, false)
}
// OpenReadWriteSession is OpenSession for callers that create objects on the token.
func (m *Module) OpenReadWriteSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, true)
}
func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session, error) {
token, err := m.token(label)
if err != nil {
return nil, err
}
handle, err := m.d.openSession(token.Slot, readWrite)
if err != nil {
return nil, err
}
s := &Session{d: m.d, handle: handle}
if pin == nil {
return s, nil
}
if err := m.acquireLogin(token.Slot, handle, pin); err != nil {
s.Close()
return nil, err
}
s.module, s.slot = m, token.Slot
return s, nil
}
// acquireLogin makes sure the user is logged in to the token in slot and counts the
// session as one relying on it. PKCS#11 login state belongs to the application, not to
// a session: every session with the token shares it, and logging out from any of them
// ends it for all. So the PIN is sent only when no session holds the login yet, and the
// last session to close logs out.
func (m *Module) acquireLogin(slot, handle uint, pin []byte) error {
m.loginMu.Lock()
defer m.loginMu.Unlock()
if m.logins[slot] > 0 {
m.logins[slot]++
return nil
}
if err := m.d.login(handle, pin); err != nil && !isCode(err, rvUserAlreadyLoggedIn) {
return err
}
if m.logins == nil {
m.logins = make(map[uint]int)
}
m.logins[slot] = 1
return nil
}
// releaseLogin ends the session's share of the login, logging out when it is the last.
func (m *Module) releaseLogin(slot, handle uint) {
m.loginMu.Lock()
defer m.loginMu.Unlock()
m.logins[slot]--
if m.logins[slot] > 0 {
return
}
delete(m.logins, slot)
m.d.logout(handle)
}
func (m *Module) token(label string) (Token, error) {
tokens, err := m.Tokens()
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if label == "" || token.Label == label {
return token, nil
}
}
if label == "" {
return Token{}, errors.New("no token present")
}
return Token{}, fmt.Errorf("no token labelled %q among %d tokens", label, len(tokens))
}
// Session is an open session with one token. Close releases the session's share of the
// login, logging out when no other session relies on it.
type Session struct {
d driver
handle uint
// module is set when the session relies on the token's login.
module *Module
slot uint
}
func (s *Session) Close() {
if s.module != nil {
s.module.releaseLogin(s.slot, s.handle)
}
s.d.closeSession(s.handle)
}
// FindObjects returns the handles of every object matching all attributes of template.
func (s *Session) FindObjects(template ...Attribute) ([]Object, error) {
return s.d.findObjects(s.handle, template)
}
// Attribute reads one attribute of an object.
func (s *Session) Attribute(obj Object, typ uint) ([]byte, error) {
return s.d.attribute(s.handle, obj, typ)
}
// Sign signs data, normally a digest, with the token-held key in a single operation.
func (s *Session) Sign(mech Mechanism, key Object, data []byte) ([]byte, error) {
return s.d.sign(s.handle, mech, key, data)
}
// CreateObject stores a new object described by template on the token.
func (s *Session) CreateObject(template ...Attribute) (Object, error) {
return s.d.createObject(s.handle, template)
}
type driver interface {
tokens() ([]Token, error)
openSession(slot uint, readWrite bool) (uint, error)
closeSession(session uint)
login(session uint, pin []byte) error
logout(session uint)
findObjects(session uint, template []Attribute) ([]Object, error)
attribute(session uint, obj Object, typ uint) ([]byte, error)
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
createObject(session uint, template []Attribute) (Object, error)
}
// ulongSize is the width of CK_ULONG on the 64-bit platforms the driver builds for.
const ulongSize = 8
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
func ULong(v uint) []byte {
return binary.NativeEndian.AppendUint64(nil, uint64(v))
}
func ulongValue(b []byte) (uint, error) {
if len(b) != ulongSize {
return 0, fmt.Errorf("CK_ULONG value has %d bytes", len(b))
}
return uint(binary.NativeEndian.Uint64(b)), nil
}
+95
View File
@@ -0,0 +1,95 @@
package pkcs11
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"math"
"math/big"
)
var curvesByOID = map[string]elliptic.Curve{
"1.2.840.10045.3.1.7": elliptic.P256(),
"1.3.132.0.34": elliptic.P384(),
"1.3.132.0.35": elliptic.P521(),
}
// PublicKey reads a CKO_PUBLIC_KEY object as a Go public key. RSA and EC keys are
// supported, the two kinds a certificate posture proof can be signed with.
func (s *Session) PublicKey(obj Object) (crypto.PublicKey, error) {
raw, err := s.Attribute(obj, AttrKeyType)
if err != nil {
return nil, err
}
keyType, err := ulongValue(raw)
if err != nil {
return nil, fmt.Errorf("CKA_KEY_TYPE: %w", err)
}
switch keyType {
case KeyRSA:
modulus, exponent, err := s.attributes(obj, AttrModulus, AttrPublicExponent)
if err != nil {
return nil, err
}
return rsaPublicKey(modulus, exponent)
case KeyEC:
params, point, err := s.attributes(obj, AttrECParams, AttrECPoint)
if err != nil {
return nil, err
}
return ecPublicKey(params, point)
}
return nil, fmt.Errorf("unsupported key type 0x%x", keyType)
}
func (s *Session) attributes(obj Object, first, second uint) ([]byte, []byte, error) {
a, err := s.Attribute(obj, first)
if err != nil {
return nil, nil, err
}
b, err := s.Attribute(obj, second)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) {
e := new(big.Int).SetBytes(exponent)
if e.Sign() <= 0 || e.Cmp(big.NewInt(math.MaxInt32)) > 0 {
return nil, errors.New("CKA_PUBLIC_EXPONENT is out of range")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: int(e.Int64())}, nil
}
// ecPublicKey decodes CKA_EC_PARAMS, the named curve OID, and CKA_EC_POINT, the
// uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare.
func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
var oid asn1.ObjectIdentifier
if _, err := asn1.Unmarshal(params, &oid); err != nil {
return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err)
}
curve, ok := curvesByOID[oid.String()]
if !ok {
return nil, fmt.Errorf("unsupported curve %s", oid)
}
size := (curve.Params().BitSize + 7) / 8
raw := point
if len(raw) != 1+2*size {
if _, err := asn1.Unmarshal(point, &raw); err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
}
if len(raw) != 1+2*size || raw[0] != 4 {
return nil, errors.New("CKA_EC_POINT is not an uncompressed point")
}
key, err := ecdsa.ParseUncompressedPublicKey(curve, raw)
if err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
return key, nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/asn1"
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestECPublicKey(t *testing.T) {
curves := []struct {
name string
curve elliptic.Curve
oid asn1.ObjectIdentifier
}{
{"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}},
{"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}},
}
for _, tt := range curves {
t.Run(tt.name, func(t *testing.T) {
key, err := ecdsa.GenerateKey(tt.curve, rand.Reader)
require.NoError(t, err)
params, err := asn1.Marshal(tt.oid)
require.NoError(t, err)
point := uncompressedPoint(key)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
// PKCS#11 wraps the point in an OCTET STRING, but some modules return it bare.
for form, encoded := range map[string][]byte{"DER octet string": wrapped, "bare point": point} {
pub, err := ecPublicKey(params, encoded)
require.NoError(t, err, form)
assert.True(t, key.PublicKey.Equal(pub), "%s must decode to the generated key", form)
}
})
}
}
func TestECPublicKey_Rejections(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
p256, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7})
require.NoError(t, err)
brainpool, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 36, 3, 3, 2, 8, 1, 1, 7})
require.NoError(t, err)
point := uncompressedPoint(key)
_, err = ecPublicKey(brainpool, point)
assert.Error(t, err, "curves the proof cannot use must be rejected")
_, err = ecPublicKey(p256, point[:len(point)-1])
assert.Error(t, err, "a truncated point must be rejected")
_, err = ecPublicKey([]byte("junk"), point)
assert.Error(t, err, "malformed parameters must be rejected")
}
func TestRSAPublicKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pub, err := rsaPublicKey(key.N.Bytes(), big.NewInt(int64(key.E)).Bytes())
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(pub), "modulus and exponent must decode to the generated key")
_, err = rsaPublicKey(key.N.Bytes(), nil)
assert.Error(t, err, "a missing exponent must be rejected")
}
func TestULongRoundTrip(t *testing.T) {
v, err := ulongValue(ULong(ClassPrivateKey))
require.NoError(t, err)
assert.Equal(t, uint(ClassPrivateKey), v)
_, err = ulongValue([]byte{1, 2, 3})
assert.Error(t, err, "a value of the wrong width must be rejected")
}
func uncompressedPoint(key *ecdsa.PrivateKey) []byte {
size := (key.Curve.Params().BitSize + 7) / 8
point := append([]byte{4}, key.X.FillBytes(make([]byte, size))...)
return append(point, key.Y.FillBytes(make([]byte, size))...)
}
+72
View File
@@ -0,0 +1,72 @@
package pkcs11
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeDriver stands in for a loaded module and records the calls a session makes.
type fakeDriver struct {
loginErr error
logins int
logouts int
closes int
}
func (f *fakeDriver) tokens() ([]Token, error) { return []Token{{Slot: 1, Label: "netbird"}}, nil }
func (f *fakeDriver) openSession(uint, bool) (uint, error) { return 7, nil }
func (f *fakeDriver) closeSession(uint) { f.closes++ }
func (f *fakeDriver) login(uint, []byte) error { f.logins++; return f.loginErr }
func (f *fakeDriver) logout(uint) { f.logouts++ }
func (f *fakeDriver) findObjects(uint, []Attribute) ([]Object, error) {
return nil, nil
}
func (f *fakeDriver) attribute(uint, Object, uint) ([]byte, error) { return nil, nil }
func (f *fakeDriver) sign(uint, Mechanism, Object, []byte) ([]byte, error) {
return nil, nil
}
func (f *fakeDriver) createObject(uint, []Attribute) (Object, error) { return 0, nil }
func TestOpenSession_LogsOutOnlyALoginItOwns(t *testing.T) {
t.Run("own login is logged out on close", func(t *testing.T) {
d := &fakeDriver{}
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
s.Close()
assert.Equal(t, 1, d.logouts, "the session that logged in logs out again")
})
t.Run("a login shared by sessions ends with the last one", func(t *testing.T) {
d := &fakeDriver{}
m := &Module{d: d}
first, err := m.OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
second, err := m.OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
assert.Equal(t, 1, d.logins, "the second session reuses the login instead of sending the PIN again")
first.Close()
assert.Zero(t, d.logouts, "logging out now would end the login the second session still uses")
second.Close()
assert.Equal(t, 1, d.logouts, "the last session to close logs out")
assert.Equal(t, 2, d.closes, "both sessions are closed")
})
t.Run("an existing login is taken over", func(t *testing.T) {
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvUserAlreadyLoggedIn}}
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
require.NoError(t, err, "an existing login is good enough to use the token")
s.Close()
assert.Equal(t, 1, d.logouts, "no session of this client holds the login any more, so it ends")
})
t.Run("rejected pin closes the session", func(t *testing.T) {
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvPINIncorrect}}
_, err := (&Module{d: d}).OpenSession("netbird", []byte("0000"))
assert.True(t, PINRejected(err), "the PIN error reaches the caller")
assert.Zero(t, d.logouts, "nothing to log out after a failed login")
assert.Equal(t, 1, d.closes, "the session opened for the login is closed")
})
}
+135
View File
@@ -0,0 +1,135 @@
package pkcs11
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// DefaultModule is p11-kit's proxy, which exposes every module the system has registered,
// tpm2-pkcs11 included, so a URI without module-path works on a stock p11-kit setup.
const DefaultModule = "p11-kit-proxy.so"
// URI is the subset of an RFC 7512 PKCS#11 URI this client understands: the token label,
// the module to load and where the user PIN comes from.
type URI struct {
Token string
ModulePath string
pinValue *string
pinSource string
}
// ParseURI parses raw following RFC 7512 section 2.3. A path attribute other than token
// is refused rather than ignored: the path narrows which token is used, and ignoring a
// constraint such as serial would widen the match to whichever token is listed first,
// where the RFC calls for no match at all. Duplicate attributes are refused, a
// module-path must be absolute and a module-name must be a bare name. Unknown query
// attributes are ignored, as the RFC asks.
func ParseURI(raw string) (*URI, error) {
rest, ok := strings.CutPrefix(raw, "pkcs11:")
if !ok {
return nil, errors.New("PKCS#11 URI does not start with the pkcs11 scheme")
}
path, query, _ := strings.Cut(rest, "?")
u := &URI{}
if err := eachAttribute(path, ";", u.setPathAttribute); err != nil {
return nil, err
}
if err := eachAttribute(query, "&", u.setQueryAttribute); err != nil {
return nil, err
}
// RFC 7512 section 2.4: a URI with both pin-value and pin-source should be refused.
if u.pinValue != nil && u.pinSource != "" {
return nil, errors.New("PKCS#11 URI carries both pin-value and pin-source")
}
return u, nil
}
func (u *URI) setPathAttribute(name, value string) error {
if name != "token" {
return fmt.Errorf("PKCS#11 URI path attribute %q is not supported, only token selects a token", name)
}
u.Token = value
return nil
}
func (u *URI) setQueryAttribute(name, value string) error {
switch name {
case "module-path":
if !filepath.IsAbs(value) {
return fmt.Errorf("PKCS#11 URI module-path %q must be absolute", value)
}
u.ModulePath = value
case "module-name":
if value == "" || strings.ContainsAny(value, `/\`) || strings.Contains(value, "..") {
return fmt.Errorf("PKCS#11 URI module-name %q must be a module name, not a path", value)
}
u.ModulePath = "lib" + value + ".so"
case "pin-value":
u.pinValue = &value
case "pin-source":
u.pinSource = value
}
return nil
}
// Module is the library to load, DefaultModule when the URI names none.
func (u *URI) Module() string {
if u.ModulePath == "" {
return DefaultModule
}
return u.ModulePath
}
// HasPIN reports whether the URI carries a PIN, inline or as a pin-source.
func (u *URI) HasPIN() bool {
return u.pinValue != nil || u.pinSource != ""
}
// PIN returns the user PIN, or nil when the URI carries none and no login should happen.
// A pin-source names a file whose single line is the PIN.
func (u *URI) PIN() ([]byte, error) {
if u.pinValue != nil {
return []byte(*u.pinValue), nil
}
if u.pinSource == "" {
return nil, nil
}
path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:")
pin, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read PIN: %w", err)
}
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
}
// eachAttribute splits list on sep and calls fn for every name=value pair, refusing a
// name that appears twice.
func eachAttribute(list, sep string, fn func(name, value string) error) error {
if list == "" {
return nil
}
seen := make(map[string]struct{})
for _, pair := range strings.Split(list, sep) {
name, value, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
}
if _, dup := seen[name]; dup {
return fmt.Errorf("PKCS#11 URI attribute %s appears more than once", name)
}
seen[name] = struct{}{}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
}
if err := fn(name, value); err != nil {
return err
}
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package pkcs11
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseURI(t *testing.T) {
tests := []struct {
name string
raw string
wantToken string
wantModule string
wantPIN []byte
}{
{
name: "token with module path and pin value",
raw: "pkcs11:token=netbird?module-path=" + absModule("libtpm2_pkcs11.so") + "&pin-value=1234",
wantToken: "netbird",
wantModule: absModule("libtpm2_pkcs11.so"),
wantPIN: []byte("1234"),
},
{
name: "module name becomes a library file",
raw: "pkcs11:token=netbird?module-name=tpm2_pkcs11",
wantToken: "netbird",
wantModule: "libtpm2_pkcs11.so",
},
{
name: "percent encoding, unknown query attributes are ignored",
raw: "pkcs11:token=my%20token?max-sessions=1&vendor-flag=on",
wantToken: "my token",
wantModule: DefaultModule,
},
{
name: "bare scheme uses the p11-kit proxy and no login",
raw: "pkcs11:",
wantModule: DefaultModule,
},
{
name: "empty pin value still logs in",
raw: "pkcs11:?pin-value=",
wantModule: DefaultModule,
wantPIN: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uri, err := ParseURI(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.wantToken, uri.Token, "token label")
assert.Equal(t, tt.wantModule, uri.Module(), "module to load")
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
})
}
}
func TestParseURI_Rejections(t *testing.T) {
tests := map[string]string{
"no scheme": "pkcs11",
"other scheme": "https://example.com",
"attribute no value": "pkcs11:token",
"bad percent encoding": "pkcs11:token=%zz",
// RFC 7512 2.3: an unrecognized path attribute matches nothing. Ignoring it
// instead would let the token listed first answer for the one serial names.
"unsupported serial": "pkcs11:token=netbird;serial=1234",
"unsupported model": "pkcs11:model=SoftHSM%20v2;token=netbird",
"object selector": "pkcs11:token=netbird;object=device",
"vendor path attribute": "pkcs11:token=netbird;vendor-slot=2",
"duplicate token": "pkcs11:token=a;token=b",
"duplicate module-path": "pkcs11:token=a?module-path=" + absModule("a.so") + "&module-path=" + absModule("b.so"),
"duplicate pin-value": "pkcs11:token=a?pin-value=1&pin-value=2",
"relative module-path": "pkcs11:token=a?module-path=lib/x.so",
"bare module-path": "pkcs11:token=a?module-path=libtpm2_pkcs11.so",
"module-name with path": "pkcs11:token=a?module-name=../../tmp/evil",
"module-name with slash": "pkcs11:token=a?module-name=tmp/evil",
"module-name with dotdot": "pkcs11:token=a?module-name=..",
"empty module-name": "pkcs11:token=a?module-name=",
"pin-value and pin-source": "pkcs11:token=a?pin-value=1&pin-source=file:/etc/pin",
}
for name, raw := range tests {
_, err := ParseURI(raw)
assert.Error(t, err, "%s: %s must be refused", name, raw)
}
}
func TestURI_PINFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "pin")
require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600))
for _, source := range []string{path, "file:" + path, "file://" + path} {
uri, err := ParseURI("pkcs11:token=netbird?pin-source=" + source)
require.NoError(t, err)
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, []byte("secret"), pin, "PIN from %s must drop the trailing newline", source)
}
uri, err := ParseURI("pkcs11:?pin-source=" + filepath.Join(t.TempDir(), "missing"))
require.NoError(t, err)
_, err = uri.PIN()
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
}
// absModule is an absolute module path on the platform the test runs on, as module-path
// must be absolute.
func absModule(name string) string {
if runtime.GOOS == "windows" {
return `C:\lib\` + name
}
return "/lib/" + name
}
+10
View File
@@ -185,6 +185,16 @@ type Config struct {
ClientCertKeyPair *tls.Certificate `json:"-"`
// CertStoreDir is no longer read: certificate posture takes the directory from
// NB_CERT_STORE_DIR in the daemon's environment. The field is kept only to report a
// value left from an earlier version.
CertStoreDir string `json:",omitempty"`
// CertPKCS11URI is no longer read, as the URI may carry the token PIN: certificate
// posture takes it from NB_CERT_PKCS11_URI in the daemon's environment. The field is
// kept only to report a value left from an earlier version.
CertPKCS11URI string `json:",omitempty"`
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
// Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"`
+29
View File
@@ -0,0 +1,29 @@
package tpm
import (
"errors"
"fmt"
"io"
"os"
"github.com/google/go-tpm/tpmutil"
)
// The kernel resource manager multiplexes clients and flushes what they leave behind,
// so it is tried before the raw device.
var devicePaths = []string{"/dev/tpmrm0", "/dev/tpm0"}
func open() (io.ReadWriteCloser, error) {
if path := os.Getenv(DeviceEnv); path != "" {
return tpmutil.OpenTPM(path)
}
var errs error
for _, path := range devicePaths {
rwc, err := tpmutil.OpenTPM(path)
if err == nil {
return rwc, nil
}
errs = errors.Join(errs, err)
}
return nil, fmt.Errorf("open TPM: %w", errs)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package tpm
import "io"
func open() (io.ReadWriteCloser, error) {
return nil, ErrUnsupported
}
+163
View File
@@ -0,0 +1,163 @@
package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"io"
"math/big"
legacy "github.com/google/go-tpm/legacy/tpm2"
"github.com/google/go-tpm/tpmutil"
)
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
// draft-bottomley-tpm2-keys and written by tpm2-openssl and tpm2-tss-engine.
const KeyPEMType = "TSS2 PRIVATE KEY"
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
// eccSRKTemplate is the TCG reference ECC-P256 storage root key. A key whose parent is
// a hierarchy rather than a persistent handle was wrapped by the primary this template
// derives, and tpm2-openssl and tpm2-tss-engine derive the same one, so the TPM
// reproduces the identical parent from the hierarchy seed without anything being stored.
var eccSRKTemplate = legacy.Public{
Type: legacy.AlgECC,
NameAlg: legacy.AlgSHA256,
Attributes: legacy.FlagStorageDefault | legacy.FlagNoDA,
ECCParameters: &legacy.ECCParams{
Symmetric: &legacy.SymScheme{Alg: legacy.AlgAES, KeyBits: 128, Mode: legacy.AlgCFB},
Sign: &legacy.SigScheme{Alg: legacy.AlgNull},
CurveID: legacy.CurveNISTP256,
},
}
// ParseKey reads a TSS2 key file and returns a signer that produces every signature
// inside the TPM; only the digest goes in and only the signature comes out. Keys guarded
// by an authorization value are rejected, since nothing can supply it without prompting.
func ParseKey(der []byte) (crypto.Signer, error) {
key, err := parseTSS2(der)
if err != nil {
return nil, err
}
if !key.EmptyAuth {
return nil, ErrKeyNeedsAuth
}
public, err := legacy.DecodePublic(key.PublicKey[2:])
if err != nil {
return nil, fmt.Errorf("decode TSS2 public area: %w", err)
}
pub, err := public.Key()
if err != nil {
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
}
return &keySigner{key: key, public: pub}, nil
}
type keySigner struct {
key *tss2Key
public crypto.PublicKey
}
func (s *keySigner) Public() crypto.PublicKey {
return s.public
}
// Sign loads the key under its parent, signs, and releases both handles. The TPM is
// opened per signature so no handle outlives the call, which matters on a device whose
// transient object slots are few and shared with everything else on the host.
func (s *keySigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
rwc, err := Open()
if err != nil {
return nil, err
}
defer func() { _ = rwc.Close() }()
parent := tpmutil.Handle(s.key.Parent)
if !persistentHandle(s.key.Parent) {
parent, _, err = legacy.CreatePrimary(rwc, parent, legacy.PCRSelection{}, "", "", eccSRKTemplate)
if err != nil {
return nil, fmt.Errorf("create TPM primary: %w", err)
}
defer func() { _ = legacy.FlushContext(rwc, parent) }()
}
public, private := s.key.blobs()
handle, _, err := legacy.Load(rwc, parent, "", public, private)
if err != nil {
return nil, fmt.Errorf("load TPM key: %w", err)
}
defer func() { _ = legacy.FlushContext(rwc, handle) }()
switch pub := s.public.(type) {
case *ecdsa.PublicKey:
return signECDSA(rwc, handle, digest, pub.Curve)
case *rsa.PublicKey:
return signRSA(rwc, handle, digest, opts)
default:
return nil, fmt.Errorf("unsupported TPM key type %T", s.public)
}
}
// signECDSA returns the signature as the ASN.1 sequence crypto.Signer is defined to
// return; the TPM hands back the two integers on their own.
func signECDSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, curve elliptic.Curve) ([]byte, error) {
hash, err := eccHash(curve)
if err != nil {
return nil, err
}
sig, err := legacy.Sign(rw, handle, "", digest, nil, &legacy.SigScheme{Alg: legacy.AlgECDSA, Hash: hash})
if err != nil {
return nil, fmt.Errorf("TPM ECDSA signature: %w", err)
}
if sig.ECC == nil {
return nil, fmt.Errorf("TPM returned a %v signature for an ECDSA key", sig.Alg)
}
return asn1.Marshal(struct{ R, S *big.Int }{sig.ECC.R, sig.ECC.S})
}
func signRSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
hash, err := legacy.HashToAlgorithm(opts.HashFunc())
if err != nil {
return nil, fmt.Errorf("TPM hash algorithm: %w", err)
}
scheme := &legacy.SigScheme{Alg: legacy.AlgRSASSA, Hash: hash}
if pss, ok := opts.(*rsa.PSSOptions); ok {
// The TPM chooses the salt length itself, the digest length on most chips, so
// only a request for that length is taken. PSSSaltLengthAuto asks for the
// largest salt the key allows and is refused. Verify with PSSSaltLengthAuto.
if pss.SaltLength != rsa.PSSSaltLengthEqualsHash &&
pss.SaltLength != len(digest) {
return nil, fmt.Errorf("TPM cannot produce a PSS signature with salt length %d", pss.SaltLength)
}
scheme.Alg = legacy.AlgRSAPSS
}
sig, err := legacy.Sign(rw, handle, "", digest, nil, scheme)
if err != nil {
return nil, fmt.Errorf("TPM RSA signature: %w", err)
}
if sig.RSA == nil {
return nil, fmt.Errorf("TPM returned a %v signature for an RSA key", sig.Alg)
}
return sig.RSA.Signature, nil
}
func eccHash(curve elliptic.Curve) (legacy.Algorithm, error) {
switch curve {
case elliptic.P256():
return legacy.AlgSHA256, nil
case elliptic.P384():
return legacy.AlgSHA384, nil
case elliptic.P521():
return legacy.AlgSHA512, nil
default:
return 0, fmt.Errorf("unsupported curve %s", curve.Params().Name)
}
}
+73
View File
@@ -0,0 +1,73 @@
package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/pem"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
key := newP256Key(t)
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey)))
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(signer.Public()), "signer must expose the key the TPM holds")
}
func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) {
key := newP256Key(t)
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithAuth())))
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
}
func TestParseKey_RejectsMalformedKey(t *testing.T) {
_, err := ParseKey([]byte("not a TSS2 key"))
assert.Error(t, err)
}
func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
t.Setenv(DeviceEnv, filepath.Join(t.TempDir(), "missing"))
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &newP256Key(t).PublicKey)))
require.NoError(t, err)
digest := sha256.Sum256([]byte("challenge"))
_, err = signer.Sign(rand.Reader, digest[:], crypto.SHA256)
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
}
// TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose checks the salt-length gate, which
// runs before the TPM is touched: the TPM picks the salt itself, so a request for the
// maximum salt (PSSSaltLengthAuto) or any other explicit length is refused.
func TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose(t *testing.T) {
digest := sha256.Sum256([]byte("challenge"))
for _, salt := range []int{rsa.PSSSaltLengthAuto, 20, 222} {
_, err := signRSA(nil, 0, digest[:], &rsa.PSSOptions{SaltLength: salt, Hash: crypto.SHA256})
assert.ErrorContains(t, err, "salt length", "salt length %d must be refused", salt)
}
}
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func decodePEM(t *testing.T, pemData string) []byte {
t.Helper()
block, _ := pem.Decode([]byte(pemData))
require.NotNil(t, block)
require.Equal(t, KeyPEMType, block.Type)
return block.Bytes
}
+19
View File
@@ -0,0 +1,19 @@
// Package tpm is the client's one door to the platform TPM 2.0. It opens the device
// and turns TPM-held key files into signers; every operation opens the TPM, runs and
// closes it, so no handle outlives a call.
package tpm
import (
"errors"
"io"
)
// DeviceEnv overrides the TPM device path, which also lets tests point at a swtpm socket.
const DeviceEnv = "NB_TPM_DEVICE"
var ErrUnsupported = errors.New("TPM is not supported on this platform")
// Open connects to the platform TPM 2.0. The caller closes it after one operation.
func Open() (io.ReadWriteCloser, error) {
return open()
}
+121
View File
@@ -0,0 +1,121 @@
// Package tpmtest builds TSS2 key files for tests, with or without a TPM behind them.
package tpmtest
import (
"crypto/ecdsa"
"crypto/elliptic"
"encoding/asn1"
"encoding/pem"
"testing"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/require"
)
const (
p256Bytes = 32
// KeyPEMType is the PEM block type of a TPM 2.0 key file.
KeyPEMType = "TSS2 PRIVATE KEY"
)
// oidLoadableKey is the key type of draft-bottomley-tpm2-keys that a parent wraps.
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
// tss2KeyDER is the ASN.1 container, written out independently of the parser under test
// so that an encoder bug and a decoder bug cannot cancel out. Policy, secret and auth
// policy are left out entirely: they are optional, and a key that carries them is
// refused anyway.
type tss2KeyDER struct {
Type asn1.ObjectIdentifier
EmptyAuth bool `asn1:"optional,explicit,tag:0"`
Parent int64
PublicKey []byte
PrivateKey []byte
}
// Option adjusts a key before it is encoded.
type Option func(*tss2KeyDER)
// WithParent names the handle the key is wrapped by, instead of the owner hierarchy.
func WithParent(handle int64) Option {
return func(k *tss2KeyDER) { k.Parent = handle }
}
// WithAuth marks the key as guarded by an authorization value, which this client refuses
// because nothing can supply one without prompting.
func WithAuth() Option {
return func(k *tss2KeyDER) { k.EmptyAuth = false }
}
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
// scheme, the shape tpm2-openssl creates certificate keys in.
func SigningTemplate() tpm2.Public {
return tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagSign | tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{CurveID: tpm2.CurveNISTP256},
}
}
// KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses
// and reports pub, but no TPM can load it.
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...Option) string {
t.Helper()
require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256")
area := SigningTemplate()
// The uncompressed encoding is 0x04 followed by the two coordinates, which is
// what the TPM wants them as.
point, err := pub.Bytes()
require.NoError(t, err)
require.Len(t, point, 1+2*p256Bytes)
area.ECCParameters.Point = tpm2.ECPoint{
XRaw: point[1 : 1+p256Bytes],
YRaw: point[1+p256Bytes:],
}
encoded, encErr := area.Encode()
require.NoError(t, encErr)
return EncodePEM(t, encoded, []byte("placeholder"), opts...)
}
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE
// KEY, giving each the TPM2B length prefix the format carries them with.
func EncodePEM(t *testing.T, public, private []byte, opts ...Option) string {
t.Helper()
key := tss2KeyDER{
Type: oidLoadableKey,
EmptyAuth: true,
Parent: int64(tpm2.HandleOwner),
PublicKey: prefixTPM2B(public),
PrivateKey: prefixTPM2B(private),
}
for _, opt := range opts {
opt(&key)
}
der, err := asn1.Marshal(key)
require.NoError(t, err)
return string(pem.EncodeToMemory(&pem.Block{Type: KeyPEMType, Bytes: der}))
}
func prefixTPM2B(b []byte) []byte {
out := make([]byte, 0, len(b)+2)
out = append(out, byte(len(b)>>8), byte(len(b)))
return append(out, b...)
}
// ECCSRKTemplate is the TCG reference ECC-P256 storage root key, the parent a key under
// a hierarchy is wrapped by. Tests that create a key in a real TPM have to use the same
// template the signer re-derives it with.
var ECCSRKTemplate = tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagStorageDefault | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{
Symmetric: &tpm2.SymScheme{Alg: tpm2.AlgAES, KeyBits: 128, Mode: tpm2.AlgCFB},
Sign: &tpm2.SigScheme{Alg: tpm2.AlgNull},
CurveID: tpm2.CurveNISTP256,
},
}
+133
View File
@@ -0,0 +1,133 @@
package tpm
import (
"encoding/asn1"
"errors"
"fmt"
"math"
legacy "github.com/google/go-tpm/legacy/tpm2"
)
// oidLoadableKey marks a key wrapped by a parent inside the TPM, which is the only kind
// enrollment tooling writes for a signing key and the only kind that can be loaded under
// an SRK. The sealed and importable variants carry different material and are refused.
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
var (
errNotLoadable = errors.New("TSS2 key is not a loadable key")
errKeyHasPolicy = errors.New("TSS2 key carries a policy, which is not supported")
errKeyHasSecret = errors.New("TSS2 key carries a secret, which is not supported")
errBadParent = errors.New("TSS2 key names a parent that is neither persistent nor a hierarchy")
errBadBlob = errors.New("TSS2 key blob is malformed")
)
// tss2KeyASN1 is the ASN.1 container of draft-bottomley-tpm2-keys, the format
// tpm2-openssl, tpm2-tss-engine and tpm2_encodeobject write. PublicKey and PrivateKey
// hold TPM2B structures, so each is its own two-byte length followed by that many bytes.
//
// EmptyAuth is decoded raw: OpenSSL-based tools write BOOLEAN TRUE as 0x01, which BER
// allows but encoding/asn1 rejects, as it accepts only the DER form 0xff. Parent is an
// int64 so a persistent handle such as 0x81000001 still fits on 32-bit platforms.
type tss2KeyASN1 struct {
Type asn1.ObjectIdentifier
EmptyAuth asn1.RawValue `asn1:"optional,explicit,tag:0"`
Policy []asn1.RawValue `asn1:"optional,explicit,tag:1"`
Secret []byte `asn1:"optional,explicit,tag:2"`
AuthPolicy []asn1.RawValue `asn1:"optional,explicit,tag:3"`
Parent int64
PublicKey []byte
PrivateKey []byte
}
// tss2Key is a decoded key file the client can ask the TPM to load.
type tss2Key struct {
EmptyAuth bool
Parent uint32
PublicKey []byte
PrivateKey []byte
}
// parseTSS2 decodes a TSS2 key file and rejects everything this client cannot honour,
// so a key that parses here is one the TPM can be asked to load.
func parseTSS2(der []byte) (*tss2Key, error) {
raw := new(tss2KeyASN1)
rest, err := asn1.Unmarshal(der, raw)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
}
if len(rest) > 0 {
return nil, fmt.Errorf("parse TSS2 key: %d trailing bytes", len(rest))
}
emptyAuth, err := berBoolean(raw.EmptyAuth)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key emptyAuth: %w", err)
}
switch {
case !raw.Type.Equal(oidLoadableKey):
return nil, fmt.Errorf("%w: %s", errNotLoadable, raw.Type)
case len(raw.Policy) > 0 || len(raw.AuthPolicy) > 0:
return nil, errKeyHasPolicy
case len(raw.Secret) > 0:
return nil, errKeyHasSecret
case raw.Parent < 0 || raw.Parent > math.MaxUint32 || !validParent(uint32(raw.Parent)):
return nil, fmt.Errorf("%w: %d", errBadParent, raw.Parent)
case !validTPM2B(raw.PublicKey) || !validTPM2B(raw.PrivateKey):
return nil, errBadBlob
}
return &tss2Key{
EmptyAuth: emptyAuth,
Parent: uint32(raw.Parent),
PublicKey: raw.PublicKey,
PrivateKey: raw.PrivateKey,
}, nil
}
// berBoolean decodes an optional BOOLEAN, absent meaning false. Any non-zero content
// byte is true, as BER (X.690 section 8.2.2) allows and OpenSSL writes.
func berBoolean(v asn1.RawValue) (bool, error) {
if len(v.FullBytes) == 0 {
return false, nil
}
// The field is explicitly tagged, so v is the [0] wrapper and the BOOLEAN is inside it.
if v.Class == asn1.ClassContextSpecific && v.IsCompound {
var inner asn1.RawValue
rest, err := asn1.Unmarshal(v.Bytes, &inner)
if err != nil || len(rest) > 0 {
return false, errors.New("malformed explicit tag")
}
v = inner
}
if v.Class != asn1.ClassUniversal || v.Tag != asn1.TagBoolean || v.IsCompound || len(v.Bytes) != 1 {
return false, errors.New("not a BOOLEAN")
}
return v.Bytes[0] != 0, nil
}
// blobs returns the public and private areas with their TPM2B length prefix removed,
// which is the form the load command takes them in.
func (k *tss2Key) blobs() (public, private []byte) {
return k.PublicKey[2:], k.PrivateKey[2:]
}
// validParent accepts a persistent handle, under which the key was wrapped directly, or
// one of the four hierarchies, under which the key is wrapped by a primary the TPM
// re-derives from the hierarchy seed.
func validParent(parent uint32) bool {
return persistentHandle(parent) ||
parent == uint32(legacy.HandleOwner) ||
parent == uint32(legacy.HandleNull) ||
parent == uint32(legacy.HandleEndorsement) ||
parent == uint32(legacy.HandlePlatform)
}
func persistentHandle(h uint32) bool {
return h>>24 == uint32(legacy.HandleTypePersistent)
}
// validTPM2B reports whether b is a TPM2B structure: a two-byte big-endian length
// followed by exactly that many bytes.
func validTPM2B(b []byte) bool {
return len(b) >= 2 && len(b)-2 == int(b[0])<<8+int(b[1])
}
+78
View File
@@ -0,0 +1,78 @@
package tpm
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/asn1"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
// derEmptyAuthTrue is the explicit [0] tag around a DER BOOLEAN TRUE, as encoding/asn1
// writes emptyAuth; OpenSSL writes the same element with 0x01 as the content byte.
var (
derEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0xff}
opensslEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0x01}
)
func p256(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func TestParseKey_AcceptsOpenSSLBoolean(t *testing.T) {
key := p256(t)
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey))
require.Equal(t, 1, bytes.Count(der, derEmptyAuthTrue), "fixture carries one DER emptyAuth TRUE")
// The same key as tpm2-openssl and tpm2-tss-engine write it: identical apart from
// the BOOLEAN content byte.
openssl := bytes.Replace(der, derEmptyAuthTrue, opensslEmptyAuthTrue, 1)
signer, err := ParseKey(openssl)
require.NoError(t, err, "BER allows any non-zero byte for TRUE, and OpenSSL writes 0x01")
assert.True(t, key.PublicKey.Equal(signer.Public()), "the key is the same either way")
}
func TestParseKey_AcceptsPersistentParent(t *testing.T) {
key := p256(t)
// 0x81000001 overflows a 32-bit int, the width the parent used to be decoded into.
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(0x81000001)))
parsed, err := parseTSS2(der)
require.NoError(t, err)
assert.Equal(t, uint32(0x81000001), parsed.Parent, "persistent parent handle")
assert.True(t, persistentHandle(parsed.Parent), "the key is loaded under the persistent parent directly")
}
func TestParseKey_RejectsParentOutOfRange(t *testing.T) {
key := p256(t)
for _, parent := range []int64{-1, 0x1_0000_0000, 0x02000000} {
_, err := parseTSS2(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(parent))))
assert.ErrorIs(t, err, errBadParent, "parent %#x is neither persistent nor a hierarchy", parent)
}
}
func TestBERBoolean(t *testing.T) {
absent, err := berBoolean(asn1.RawValue{})
require.NoError(t, err)
assert.False(t, absent, "an absent emptyAuth means the key needs authorization")
for content, want := range map[byte]bool{0x00: false, 0x01: true, 0xff: true} {
v := asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagBoolean, Bytes: []byte{content}, FullBytes: []byte{0x01, 0x01, content}}
got, err := berBoolean(v)
require.NoError(t, err)
assert.Equal(t, want, got, "content byte %#x", content)
}
_, err = berBoolean(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagInteger, Bytes: []byte{1}, FullBytes: []byte{0x02, 0x01, 0x01}})
assert.Error(t, err, "an INTEGER is not a BOOLEAN")
}
+121
View File
@@ -2,6 +2,8 @@ package server
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
@@ -9,9 +11,24 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
type jwtStoringMDMFetcher struct {
cache *jwtCache
caller ipcauth.Identity
generation uint64
}
func (f *jwtStoringMDMFetcher) Fetch() map[string]any {
f.generation = f.cache.currentGeneration()
f.cache.store("previous-profile-token", f.caller, time.Minute, f.generation)
return nil
}
type stubOAuthFlow struct {
token auth.TokenInfo
onWait func()
@@ -123,6 +140,110 @@ func TestSwitchProfile_DropsAccountPromptAndPendingFlow(t *testing.T) {
require.False(t, pending, "the previous profile's extend flow leaked across a profile switch")
}
func TestLogin_ProfileSwitchDropsAccountPromptAndPendingFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
other := "other-profile"
otherPath := filepath.Join(filepath.Dir(cfgPath), other+".json")
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: otherPath,
ManagementURL: "https://api.netbird.io:443",
})
require.NoError(t, err)
breakProfilePrivateKey(t, otherPath)
s.forceAccountPrompt = true
cancelled := false
s.oauthAuthFlow = oauthAuthFlow{
flow: &stubOAuthFlow{},
hint: "user@example.com",
waitCancel: func() { cancelled = true },
}
extendCancelled := false
s.extendAuthSessionFlow.Set(&stubOAuthFlow{}, auth.AuthFlowInfo{DeviceCode: "device"})
s.extendAuthSessionFlow.SetWaitCancel(func() { extendCancelled = true })
generation := s.jwtCache.currentGeneration()
_, err = s.Login(userCtx(), &proto.LoginRequest{ProfileName: &other, Username: &username})
require.Error(t, err, "the broken key must stop the login before a flow is built")
active, err := s.profileManager.GetActiveProfileState()
require.NoError(t, err)
require.Equal(t, profilemanager.ID(other), active.ID, "the login did not switch the profile")
require.False(t, s.forceAccountPrompt, "the prompt flag leaked across a login-driven profile switch")
require.Nil(t, s.oauthAuthFlow.flow, "the previous profile's flow leaked across a login-driven profile switch")
require.Empty(t, s.oauthAuthFlow.hint)
require.True(t, cancelled, "the pending wait was not cancelled")
require.True(t, extendCancelled, "the pending extend wait was not cancelled")
_, _, pending := s.extendAuthSessionFlow.Get()
require.False(t, pending, "the previous profile's extend flow leaked across a login-driven profile switch")
require.Greater(t, s.jwtCache.currentGeneration(), generation, "the previous profile's JWT cache survived a login-driven profile switch")
}
func TestLogin_ProfileSwitchRejectsJWTObtainedUnderPreviousConfig(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return false, errors.New("stop once the config is swapped")
}
other := "other-profile"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(filepath.Dir(cfgPath), other+".json"),
ManagementURL: "https://api.netbird.io:443",
})
require.NoError(t, err)
// getConfig loads the MDM policy right before Login swaps s.config, so the
// fetcher runs where a RequestJWTAuth racing the login would land: after the
// switch dropped the previous profile's state, while s.config still belongs
// to the previous profile. It caches a token under the generation current at
// that point, as WaitJWTToken would.
generation := s.jwtCache.currentGeneration()
fetcher := &jwtStoringMDMFetcher{cache: s.jwtCache, caller: unprivilegedIdentity()}
s.mdmLoader = mdm.NewLoader(fetcher)
_, err = s.Login(userCtx(), &proto.LoginRequest{ProfileName: &other, Username: &username})
require.Error(t, err)
require.Greater(t, fetcher.generation, generation, "the token was not cached after the switch dropped the previous profile's state")
_, found := s.jwtCache.get(fetcher.caller)
require.False(t, found, "a JWT obtained under the previous profile's config survived the login-driven switch")
}
func TestLogin_SameProfileKeepsPendingFlow(t *testing.T) {
s, _, profName, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
breakProfilePrivateKey(t, cfgPath)
cancelled := false
flow := &stubOAuthFlow{}
s.oauthAuthFlow = oauthAuthFlow{
flow: flow,
hint: "user@example.com",
waitCancel: func() { cancelled = true },
}
_, err := s.Login(userCtx(), &proto.LoginRequest{ProfileName: &profName, Username: &username})
require.Error(t, err, "the broken key must stop the login before a flow is built")
require.Equal(t, flow, s.oauthAuthFlow.flow, "a login on the same profile dropped the flow a second client could join")
require.Equal(t, "user@example.com", s.oauthAuthFlow.hint)
require.False(t, cancelled, "a login on the same profile cancelled the pending wait")
}
func TestWaitSSOLogin_JudgesTheFlowThatProducedTheToken(t *testing.T) {
s := newSSOTestServer(t, "user@example.com", false, "user@example.com")
attempts := 0
+84 -50
View File
@@ -722,7 +722,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}()
ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
ctx, activeProf, switched, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
if err != nil {
// The RPC boundary is where this gets recorded: nothing logs handler
// errors for us, and a caller that retries would otherwise leave no
@@ -752,6 +752,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
s.mutex.Lock()
s.config = config
if switched {
s.jwtCache.clear()
}
s.mutex.Unlock()
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
@@ -1192,11 +1195,15 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
if msg != nil && msg.ProfileName != nil {
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
switched, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf)
if err != nil {
s.mutex.Unlock()
log.Errorf("failed to switch profile: %v", err)
return nil, err
}
if switched {
s.dropPendingAuthFlows()
}
}
activeProf, err = s.profileManager.GetActiveProfileState()
@@ -1334,12 +1341,12 @@ func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.
}
// switchProfileIfNeeded resolves the user-supplied handle, updates the
// active profile state if it differs from the current one, and returns
// the resolved profile so callers can include its ID in RPC responses.
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
// active profile state if it differs from the current one, and reports
// whether the active profile changed.
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (bool, error) {
if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
log.Errorf("profile name is set to %s, but username is not provided", handle)
return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
return false, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
}
var username string
@@ -1349,26 +1356,48 @@ func (s *Server) switchProfileIfNeeded(handle string, userName *string, activePr
resolved, err := s.resolveProfileHandle(handle, username)
if err != nil {
return nil, err
return false, err
}
if resolved.ID != activeProf.ID || username != activeProf.Username {
if s.checkProfilesDisabled() {
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: resolved.ID,
Username: username,
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
return nil, fmt.Errorf("failed to set active profile state: %w", err)
}
if resolved.ID == activeProf.ID && username == activeProf.Username {
return false, nil
}
return resolved, nil
if s.checkProfilesDisabled() {
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
return false, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: resolved.ID,
Username: username,
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
return false, fmt.Errorf("failed to set active profile state: %w", err)
}
return true, nil
}
func (s *Server) dropPendingAuthFlows() {
// A pending login flow and the account-prompt flag describe the previous
// profile's login; carried across a switch they would judge the new
// profile's token against the old profile's account. CancelFunc is
// non-blocking, so calling it under the mutex is safe.
if cancel := s.oauthAuthFlow.waitCancel; cancel != nil {
cancel()
}
s.oauthAuthFlow = oauthAuthFlow{}
s.forceAccountPrompt = false
// A pending session extend belongs to the previous profile too: its device
// code was issued by that profile's IdP client, and WaitExtendAuthSession
// would submit the resulting token against the new profile's engine.
s.extendAuthSessionFlow.CancelWait()
s.extendAuthSessionFlow.Clear()
s.jwtCache.clear()
}
// SwitchProfile switches the active profile in the daemon.
@@ -1402,23 +1431,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
s.jwtCache.clear()
// A pending login flow and the account-prompt flag describe the previous
// profile's login; carried across a switch they would judge the new
// profile's token against the old profile's account. CancelFunc is
// non-blocking, so calling it under the mutex is safe.
if cancel := s.oauthAuthFlow.waitCancel; cancel != nil {
cancel()
}
s.oauthAuthFlow = oauthAuthFlow{}
s.forceAccountPrompt = false
// A pending session extend belongs to the previous profile too: its device
// code was issued by that profile's IdP client, and WaitExtendAuthSession
// would submit the resulting token against the new profile's engine.
s.extendAuthSessionFlow.CancelWait()
s.extendAuthSessionFlow.Clear()
s.dropPendingAuthFlows()
if msg != nil && msg.ProfileName != nil {
s.publishProfileListChanged(*msg.ProfileName)
@@ -2704,9 +2717,23 @@ func (s *Server) checkDisableAdvancedView() *bool {
return nil
}
// profileOwnerOption passes the OS account of the active profile to the connect client,
// which reads that account's certificate store for user certificate posture checks.
func (s *Server) profileOwnerOption() []internal.ConnectClientOption {
if s.profileManager == nil {
return nil
}
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
log.Warnf("failed to read the active profile owner, no user certificate store is used for certificate posture: %v", err)
return []internal.ConnectClientOption{internal.WithUnknownProfileOwner()}
}
return []internal.ConnectClientOption{internal.WithProfileOwner(activeProf.Username)}
}
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
log.Tracef("running client connection")
client := internal.NewConnectClient(ctx, config, statusRecorder)
client := internal.NewConnectClient(ctx, config, statusRecorder, s.profileOwnerOption()...)
client.SetUpdateManager(s.updateManager)
client.SetSyncResponsePersistence(s.persistSyncResponse)
@@ -2862,7 +2889,7 @@ var afterLoginPreCheck func()
// of this is reached; this one exists because that check is not synchronized
// against a concurrent privileged request that enables the SSH server, and a
// caller refused here must not have cancelled or switched anything either.
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, bool, error) {
if afterLoginPreCheck != nil {
afterLoginPreCheck()
}
@@ -2872,10 +2899,10 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
stored, err := s.storedLoginConfig(activeProf, msg)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
return nil, nil, err
return nil, nil, false, err
}
// The update-settings decision is re-taken here for the same reason as the
@@ -2884,7 +2911,7 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
// authoritative check, and it is the last read before persistLoginOverrides
// writes.
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) {
return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
return nil, nil, false, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
}
s.mutex.Lock()
@@ -2902,19 +2929,26 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
log.Warnf(errRestoreResidualState, err)
}
switched := false
if msg.ProfileName != nil {
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
return nil, nil, fmt.Errorf("switch profile: %w", err)
switched, err = s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf)
if err != nil {
return nil, nil, false, fmt.Errorf("switch profile: %w", err)
}
if switched {
s.mutex.Lock()
s.dropPendingAuthFlows()
s.mutex.Unlock()
}
}
activeProf, err = s.profileManager.GetActiveProfileState()
if err != nil {
return nil, nil, fmt.Errorf("active profile state: %w", err)
return nil, nil, false, fmt.Errorf("active profile state: %w", err)
}
if err := persistLoginOverrides(activeProf, msg); err != nil {
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
return nil, nil, false, fmt.Errorf("persist login overrides: %w", err)
}
// Provisioning under the same lock as the decision above, and next to the
@@ -2923,10 +2957,10 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
// had already answered its caller would be overwritten by the config this
// login read before it landed.
if _, _, err := provisionProfileIdentity(activeProf); err != nil {
return nil, nil, err
return nil, nil, false, err
}
return ctx, activeProf, nil
return ctx, activeProf, switched, nil
}
// persistLoginOverrides writes the config fields a login request is allowed to
+21 -1
View File
@@ -11,6 +11,7 @@ import (
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -61,6 +62,7 @@ type Info struct {
SystemManufacturer string
Environment Environment
Files []File // for posture checks
CertificateProofs []certposture.Proof
RosenpassEnabled bool
RosenpassPermissive bool
@@ -204,12 +206,27 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
// The buffered channel lets the abandoned goroutine finish and exit once its blocking call
// returns, so it does not leak beyond the duration of that call.
func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
return getInfoWithChecksTimeout(ctx, timeout, checks, nil, excludeIPs...)
}
// gatherInfoWithChecks is the gathering getInfoWithChecksTimeout bounds. Tests replace it
// to control when a gathering finishes.
var gatherInfoWithChecks = GetInfoWithChecks
// getInfoWithChecksTimeout is GetInfoWithChecksTimeout that calls done, when not nil, once
// the gathering is over: on return when it finished in time, else when the goroutine
// exits, which may be well after the timeout. done may be called twice and must be
// idempotent.
func getInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, done func(), excludeIPs ...netip.Addr) (*Info, bool) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
infoCh := make(chan *Info, 1)
go func() {
info, err := GetInfoWithChecks(ctx, checks, excludeIPs...)
if done != nil {
defer done()
}
info, err := gatherInfoWithChecks(ctx, checks, excludeIPs...)
if err != nil {
if ctx.Err() != nil {
return
@@ -223,6 +240,9 @@ func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks
select {
case info := <-infoCh:
if done != nil {
done()
}
return info, true
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+129 -10
View File
@@ -4,35 +4,154 @@ import (
"context"
"net/netip"
"slices"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
// infoLostAfter is how many timeouts an abandoned gathering is waited for before
// another may start beside it. A system call that is merely slow returns well within
// that; only one blocked for good, such as a stat on a dead network mount, is worked
// around.
infoLostAfter = 10
// maxAbandoned bounds how many gatherings may be left blocked, so a call that never
// returns cannot leak a goroutine on every refresh for the life of the daemon.
maxAbandoned = 2
)
// InfoSource gathers the system info sent to management, keeping the posture
// check results from the last Refresh for the cheap Current snapshots.
// check results from the last Refresh for the cheap Current snapshots. The zero value is
// ready to use.
type InfoSource struct {
files atomic.Pointer[[]File]
// started numbers Refresh calls in the order they began.
started atomic.Uint64
// now overrides the clock when set.
now func() time.Time
mu sync.Mutex
// abandoned holds the start of each gathering that timed out and is still blocked in
// a system call, by Refresh number.
abandoned map[uint64]time.Time
// files holds the file check results of the latest-started Refresh that succeeded,
// filesFrom its number.
files []File
filesFrom uint64
}
// Refresh gathers the info with the posture checks evaluated, bounded by timeout.
// Refresh gathers the info with the posture checks evaluated, bounded by timeout. It may
// run concurrently with other calls; the results Current reuses are those of the call
// that started last, so an older call finishing late cannot replace them. It reports
// false on a timeout, and also while a gathering that timed out earlier is still blocked
// in a system call: starting another would only add one more goroutine stuck on it.
// That one is given up on once it has run for infoLostAfter timeouts, up to maxAbandoned.
func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...)
if !ok {
started := s.clock()
if !s.mayStart(started, time.Duration(infoLostAfter)*timeout) {
return nil, false
}
files := slices.Clone(info.Files)
s.files.Store(&files)
seq := s.started.Add(1)
var mu sync.Mutex
var finished, abandoned bool
done := func() {
mu.Lock()
defer mu.Unlock()
if finished {
return
}
finished = true
if abandoned {
s.release(seq)
}
}
info, ok := getInfoWithChecksTimeout(ctx, timeout, checks, done, excludeIPs...)
if !ok {
mu.Lock()
if !finished {
abandoned = true
s.abandon(seq, started)
}
mu.Unlock()
return nil, false
}
s.publishFiles(seq, info.Files)
return info, true
}
// mayStart reports whether a gathering may start at now. Every abandoned gathering must
// have run for lost, and fewer than maxAbandoned may be left running.
func (s *InfoSource) mayStart(now time.Time, lost time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.abandoned) == 0 {
return true
}
if len(s.abandoned) >= maxAbandoned {
log.Warnf("%d system info gatherings are stuck, skipping this one", len(s.abandoned))
return false
}
for _, started := range s.abandoned {
if now.Sub(started) < lost {
log.Warnf("system info gathering that timed out earlier is still running, skipping this one")
return false
}
}
log.Warnf("system info gathering has been stuck for over %s, starting another", lost)
return true
}
// abandon records that gathering seq, started at started, timed out but is still running.
func (s *InfoSource) abandon(seq uint64, started time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if s.abandoned == nil {
s.abandoned = map[uint64]time.Time{}
}
s.abandoned[seq] = started
}
// release records that the abandoned gathering seq has returned.
func (s *InfoSource) release(seq uint64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.abandoned, seq)
}
func (s *InfoSource) abandonedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.abandoned)
}
func (s *InfoSource) clock() time.Time {
if s.now != nil {
return s.now()
}
return time.Now()
}
func (s *InfoSource) publishFiles(seq uint64, files []File) {
s.mu.Lock()
defer s.mu.Unlock()
if seq < s.filesFrom {
return
}
s.files = slices.Clone(files)
s.filesFrom = seq
}
// Current gathers the info without evaluating the checks, reusing the last Refresh results.
func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info {
info := GetInfo(ctx)
info.removeAddresses(excludeIPs...)
if files := s.files.Load(); files != nil {
info.Files = *files
}
s.mu.Lock()
info.Files = slices.Clone(s.files)
s.mu.Unlock()
return info
}
+139
View File
@@ -2,6 +2,7 @@ package system
import (
"context"
"net/netip"
"os"
"path/filepath"
"testing"
@@ -36,6 +37,106 @@ func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) {
assert.Equal(t, refreshed.Files, info.Files)
}
// TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns stands in for a gathering that
// timed out and is still blocked in a system call: no second one starts on top of it,
// and gathering works again once it exits.
func TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns(t *testing.T) {
var src InfoSource
src.abandoned = map[uint64]time.Time{1: time.Now()}
_, ok := src.Refresh(context.Background(), 15*time.Second, nil)
assert.False(t, ok, "no gathering starts while an earlier one is still running")
src.abandoned = nil
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
require.True(t, ok, "gathering runs once the earlier one exited")
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
assert.True(t, ok, "a gathering that finished in time does not block the next one")
}
// TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits checks that a gathering that
// timed out releases the source once its goroutine finishes, not before.
func TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits(t *testing.T) {
var src InfoSource
_, ok := src.Refresh(context.Background(), time.Nanosecond, nil)
require.False(t, ok, "gathering cannot finish within a nanosecond")
require.Eventually(t, func() bool { return src.abandonedCount() == 0 }, 10*time.Second, 10*time.Millisecond,
"the source is released when the abandoned gathering exits")
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
assert.True(t, ok, "gathering works again after the abandoned one exited")
}
// stubGathering replaces the gathering with one that reports a single file check for the
// path the checks name, and blocks a gathering for blockedPath until release is closed.
// entered receives once that gathering has started.
func stubGathering(t *testing.T, blockedPath string) (entered chan struct{}, release chan struct{}) {
t.Helper()
entered = make(chan struct{}, 1)
release = make(chan struct{})
original := gatherInfoWithChecks
gatherInfoWithChecks = func(_ context.Context, checks []*proto.Checks, _ ...netip.Addr) (*Info, error) {
path := checks[0].Files[0]
if path == blockedPath {
entered <- struct{}{}
<-release
}
return &Info{Files: []File{{Path: path, Exist: true}}}, nil
}
t.Cleanup(func() { gatherInfoWithChecks = original })
return entered, release
}
func filesCheck(path string) []*proto.Checks {
return []*proto.Checks{{Files: []string{path}}}
}
// TestInfoSource_RefreshRunsConcurrently: only a gathering that timed out holds off new
// ones; a caller gathering while another gathering is in progress is served.
func TestInfoSource_RefreshRunsConcurrently(t *testing.T) {
entered, release := stubGathering(t, "/slow")
var src InfoSource
slowDone := make(chan bool, 1)
go func() {
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/slow"))
slowDone <- ok
}()
<-entered
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/fast"))
assert.True(t, ok, "a gathering runs while another one is still in progress")
close(release)
assert.True(t, <-slowDone, "the slower gathering completes too")
}
// TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults: a Refresh for the previous
// checks that finishes after one for the current checks must not bring back the old
// results Current reports.
func TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults(t *testing.T) {
entered, release := stubGathering(t, "/old")
var src InfoSource
oldDone := make(chan struct{})
go func() {
defer close(oldDone)
_, _ = src.Refresh(context.Background(), 15*time.Second, filesCheck("/old"))
}()
<-entered
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/new"))
require.True(t, ok)
close(release)
<-oldDone
files := src.Current(context.Background()).Files
require.Len(t, files, 1)
assert.Equal(t, "/new", files[0].Path, "the results of the refresh that started last are kept")
}
func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
addrs := GetInfo(context.Background()).NetworkAddresses
if len(addrs) == 0 {
@@ -57,3 +158,41 @@ func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
assert.NotEqual(t, excluded, addr.NetIP.Addr())
}
}
// TestInfoSource_LostGatheringDoesNotBlockForever: a gathering blocked for good, such as
// an os.Stat on a dead network mount, must not stop every later meta sync. Once it has
// run for lostAfter timeouts another starts beside it, and no more than maxAbandoned
// are ever left running.
func TestInfoSource_LostGatheringDoesNotBlockForever(t *testing.T) {
entered, release := stubGathering(t, "/wedged")
t.Cleanup(func() { close(release) })
now := time.Now()
src := InfoSource{now: func() time.Time { return now }}
const timeout = 10 * time.Millisecond
lost := time.Duration(infoLostAfter) * timeout
_, ok := src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
require.False(t, ok, "the wedged gathering times out")
<-entered
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
assert.False(t, ok, "a gathering that may still finish holds off the next one")
now = now.Add(lost)
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
require.False(t, ok, "the second wedged gathering times out too")
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("once the first gathering is lost another one starts")
}
now = now.Add(lost)
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
assert.False(t, ok, "no more than maxAbandoned gatherings are left running")
select {
case <-entered:
t.Fatal("a third gathering started beside two lost ones")
case <-time.After(50 * time.Millisecond):
}
}
+6 -3
View File
@@ -65,6 +65,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/go-cmp v0.7.0
github.com/google/go-tpm v0.9.8
github.com/google/gopacket v1.1.19
github.com/google/nftables v0.3.0
github.com/gopacket/gopacket v1.4.0
@@ -92,8 +93,6 @@ require (
github.com/ory/dockertest/v4 v4.0.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pb33f/libopenapi v0.41.2
github.com/pb33f/libopenapi-validator v0.15.0
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203
github.com/pion/ice/v4 v4.0.0-00010101000000-000000000000
github.com/pion/logging v0.2.4
@@ -207,6 +206,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
@@ -238,7 +238,7 @@ require (
github.com/golang/mock v1.6.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/go-tpm-tools v0.4.9 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
@@ -305,6 +305,8 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pb33f/go-yaml v0.1.1 // indirect
github.com/pb33f/jsonpath v0.8.4 // indirect
github.com/pb33f/libopenapi v0.41.2
github.com/pb33f/libopenapi-validator v0.15.0
github.com/pb33f/ordered-map/v2 v2.3.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
@@ -320,6 +322,7 @@ require (
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
+6 -6
View File
@@ -183,8 +183,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
@@ -316,8 +316,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI=
github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
@@ -651,8 +651,8 @@ github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
@@ -35,6 +35,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -90,6 +91,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -145,6 +147,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -434,7 +437,8 @@
"192.168.0.1/24"
]
},
"ProcessCheck": null
"ProcessCheck": null,
"CertificateCheck": null
}
}
},
+4
View File
@@ -143,6 +143,7 @@ var (
MgmtPort: mgmtPort,
MgmtMetricsPort: mgmtMetricsPort,
DisableLegacyManagementPort: disableLegacyManagementPort,
LetsEncryptListenAddress: mgmtLetsencryptListen,
DisableMetrics: disableMetrics,
DisableGeoliteUpdate: disableGeoliteUpdate,
UserDeleteFromIDPEnabled: userDeleteFromIDPEnabled,
@@ -367,6 +368,9 @@ func LogConfigInfo(cfg *nbconfig.Config) {
if cfg.Relay != nil {
log.Infof("Relay addresses: %v", cfg.Relay.Addresses)
}
if cfg.Signal != nil {
log.Infof("Signal addresses: %v", cfg.Signal.URI)
}
}
// EnsureEncryptionKey generates and saves a DataStoreEncryptionKey if not set
+2
View File
@@ -29,6 +29,7 @@ var (
mgmtMetricsPort int
disableLegacyManagementPort bool
mgmtLetsencryptDomain string
mgmtLetsencryptListen string
mgmtSingleAccModeDomain string
certFile string
certKey string
@@ -70,6 +71,7 @@ func init() {
mgmtCmd.Flags().StringVar(&mgmtDataDir, "datadir", defaultMgmtDataDir, "server data directory location")
mgmtCmd.Flags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location. Config params specified via command line (e.g. datadir) have a precedence over configuration from this file")
mgmtCmd.Flags().StringVar(&mgmtLetsencryptDomain, "letsencrypt-domain", "", "a domain to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS")
mgmtCmd.Flags().StringVar(&mgmtLetsencryptListen, "letsencrypt-listen-address", ":443", "address of the separate Let's Encrypt challenge listener, used when --port is not 443. Set it empty when public port 443 is forwarded to --port, which answers the challenges itself")
mgmtCmd.Flags().StringVar(&mgmtSingleAccModeDomain, "single-account-mode-domain", defaultSingleAccModeDomain, "Enables single account mode. This means that all the users will be under the same account grouped by the specified domain. If the installation has more than one account, the property is ineffective. Enabled by default with the default domain "+defaultSingleAccModeDomain)
mgmtCmd.Flags().BoolVar(&disableSingleAccMode, "disable-single-account-mode", false, "If set to true, disables single account mode. The --single-account-mode-domain property will be ignored and every new user will have a separate NetBird account.")
mgmtCmd.Flags().StringVar(&certFile, "cert-file", "", "Location of your SSL certificate. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect")
@@ -427,6 +427,9 @@ func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string)
}
out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
}
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &posture.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out
}
@@ -2,6 +2,7 @@ package manager
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net"
@@ -1079,15 +1080,16 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
oidcCfg := m.proxyController.GetOIDCValidationConfig()
var errs []error
for _, s := range services {
err = m.replaceHostByLookup(ctx, accountID, s)
if err != nil {
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
if err := m.replaceHostByLookup(ctx, accountID, s); err != nil {
errs = append(errs, fmt.Errorf("replace host by lookup for service %s: %w", s.ID, err))
continue
}
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
}
return nil
return errors.Join(errs...)
}
func (m *Manager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) {
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -1481,3 +1482,42 @@ func TestReplaceHostByLookup_SkipsClusterTarget(t *testing.T) {
require.NoError(t, mgr.replaceHostByLookup(ctx, accountID, svc), "cluster target must not trigger peer/resource lookup")
assert.Equal(t, "127.0.0.1", svc.Targets[0].Host, "operator-supplied host must be preserved for cluster target")
}
func TestReloadAllServicesForAccount_ContinuesAfterFailedService(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
proxyController := proxy.NewMockController(ctrl)
accountID := "test-account"
broken := &rpservice.Service{
ID: "svc-broken",
AccountID: accountID,
ProxyCluster: "eu.proxy.netbird.io",
Targets: []*rpservice.Target{{TargetId: "x", TargetType: "unknown"}},
}
healthy := &rpservice.Service{
ID: "svc-healthy",
AccountID: accountID,
ProxyCluster: "eu.proxy.netbird.io",
Targets: []*rpservice.Target{{TargetId: "peer-1", TargetType: rpservice.TargetTypePeer, Host: "100.64.0.1", Protocol: "http", Port: 8080, Enabled: true}},
}
mockStore.EXPECT().GetAccountServices(ctx, store.LockingStrengthNone, accountID).Return([]*rpservice.Service{broken, healthy}, nil)
mockStore.EXPECT().GetPeerByID(ctx, store.LockingStrengthNone, accountID, "peer-1").
Return(&nbpeer.Peer{ID: "peer-1", IP: netip.MustParseAddr("100.70.0.9")}, nil)
proxyController.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{})
proxyController.EXPECT().SendServiceUpdateToCluster(ctx, accountID, gomock.Any(), "eu.proxy.netbird.io").
Do(func(_ context.Context, _ string, mapping *proto.ProxyMapping, _ string) {
assert.Equal(t, "svc-healthy", mapping.GetId())
require.Len(t, mapping.GetPath(), 1)
assert.Contains(t, mapping.GetPath()[0].GetTarget(), "100.70.0.9", "mapping must carry the peer's current IP")
})
mgr := &Manager{store: mockStore, proxyController: proxyController}
err := mgr.ReloadAllServicesForAccount(ctx, accountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "svc-broken")
assert.NotContains(t, err.Error(), "svc-healthy")
}
@@ -12,7 +12,7 @@ const (
GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip
from peers
where account_id = $1
@@ -180,6 +180,7 @@ type Peer struct {
MetaKernelVersion sql.NullString `nmap:"skip"`
MetaNetworkAddresses []byte `nmap:"skip,json"`
MetaFiles []byte `nmap:"skip,json"`
MetaCertificates []byte `nmap:"skip,json"`
MetaCapabilities []byte `nmap:"skip,json"`
MetaFlags []byte `nmap:"skip,json"`
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
@@ -340,6 +341,12 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
return toret, nil, err
}
}
if p.MetaCertificates != nil {
err := json.Unmarshal(p.MetaCertificates, &dp.Meta.Certificates)
if err != nil {
return toret, nil, err
}
}
if p.MetaCapabilities != nil {
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
if err != nil {
@@ -11,7 +11,7 @@ const (
GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip
from peers
where account_id = ?
+33 -7
View File
@@ -68,6 +68,7 @@ type BaseServer struct {
mgmtMetricsPort int
mgmtPort int
disableLegacyManagementPort bool
letsEncryptListenAddress string
autoResolveDomains bool
proxyAuthClose func()
@@ -82,6 +83,8 @@ type BaseServer struct {
tlsConfig *tls.Config
certManager *autocert.Manager
update *version.Update
// certListener serves Let's Encrypt challenges when mgmtPort is not 443.
certListener net.Listener
errCh chan error
wg sync.WaitGroup
@@ -103,6 +106,9 @@ type Config struct {
UserDeleteFromIDPEnabled bool
AutoResolveDomains bool
TLSConfig *tls.Config
// LetsEncryptListenAddress is the separate Let's Encrypt challenge listener
// used when MgmtPort is not 443. Empty disables it.
LetsEncryptListenAddress string
}
// NewServer initializes and configures a new Server instance
@@ -117,6 +123,7 @@ func NewServer(cfg *Config) *BaseServer {
userDeleteFromIDPEnabled: cfg.UserDeleteFromIDPEnabled,
mgmtPort: cfg.MgmtPort,
disableLegacyManagementPort: cfg.DisableLegacyManagementPort,
letsEncryptListenAddress: cfg.LetsEncryptListenAddress,
mgmtMetricsPort: cfg.MgmtMetricsPort,
autoResolveDomains: cfg.AutoResolveDomains,
tlsConfig: cfg.TLSConfig,
@@ -210,19 +217,18 @@ func (s *BaseServer) start(ctx context.Context) error {
rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.IDPHandler(), s.Metrics().GetMeter())
switch {
case s.certManager != nil:
// a call to certManager.Listener() always creates a new listener so we do it once
cml := s.certManager.Listener()
if s.mgmtPort == 443 {
// CertManager, HTTP and gRPC API all on the same port
rootHandler = s.certManager.HTTPHandler(rootHandler)
s.listener = cml
s.listener = s.certManager.Listener()
} else {
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.certManager.TLSConfig())
if err != nil {
return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err)
}
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
if err := s.serveLetsEncryptChallenges(ctx); err != nil {
return err
}
}
case s.tlsConfig != nil:
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig)
@@ -309,8 +315,8 @@ func (s *BaseServer) Stop() error {
if s.listener != nil {
_ = s.listener.Close()
}
if s.certManager != nil {
_ = s.certManager.Listener().Close()
if s.certListener != nil {
_ = s.certListener.Close()
}
s.GRPCServer().Stop()
if s.proxyAuthClose != nil {
@@ -416,6 +422,26 @@ func (s *BaseServer) serveGRPC(ctx context.Context, grpcServer *grpc.Server, por
return listener, nil
}
// serveLetsEncryptChallenges starts the separate Let's Encrypt challenge listener
// unless it is disabled. The main TLS listener uses the cert manager's TLS
// config, so it still answers TLS-ALPN-01 challenges when public port 443 is
// forwarded to it.
func (s *BaseServer) serveLetsEncryptChallenges(ctx context.Context) error {
if s.letsEncryptListenAddress == "" {
log.WithContext(ctx).Infof("LetsEncrypt challenge server disabled, challenges are answered on port %d", s.mgmtPort)
return nil
}
cml, err := tls.Listen("tcp", s.letsEncryptListenAddress, s.certManager.TLSConfig())
if err != nil {
return fmt.Errorf("create LetsEncrypt challenge listener on %s: %w", s.letsEncryptListenAddress, err)
}
s.certListener = cml
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
return nil
}
func (s *BaseServer) serveHTTP(ctx context.Context, httpListener net.Listener, handler http.Handler) {
s.wg.Add(1)
go func() {
@@ -0,0 +1,55 @@
package server
import (
"context"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/acme/autocert"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
)
func newLetsEncryptTestServer(address string) *BaseServer {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}, MgmtPort: 8443, LetsEncryptListenAddress: address})
srv.certManager = &autocert.Manager{}
return srv
}
func TestServeLetsEncryptChallenges_Disabled(t *testing.T) {
srv := newLetsEncryptTestServer("")
require.NoError(t, srv.serveLetsEncryptChallenges(context.Background()))
require.Nil(t, srv.certListener, "no challenge listener should be created when the address is empty")
}
func TestServeLetsEncryptChallenges_CustomAddress(t *testing.T) {
srv := newLetsEncryptTestServer("127.0.0.1:0")
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
if srv.certListener != nil {
_ = srv.certListener.Close()
}
srv.wg.Wait()
})
require.NoError(t, srv.serveLetsEncryptChallenges(ctx))
require.NotNil(t, srv.certListener, "challenge listener should be created on the configured address")
conn, err := net.DialTimeout("tcp", srv.certListener.Addr().String(), time.Second)
require.NoError(t, err)
require.NoError(t, conn.Close())
}
func TestServeLetsEncryptChallenges_BindFailure(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = occupied.Close() })
srv := newLetsEncryptTestServer(occupied.Addr().String())
require.Error(t, srv.serveLetsEncryptChallenges(context.Background()))
require.Nil(t, srv.certListener, "no challenge listener should be stored when the bind fails")
}
@@ -0,0 +1,88 @@
package grpc
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const certChallengeKeyDomain = "netbird-cert-challenge-key"
// newCertChallenger derives the nonce secret from the data store encryption key:
// generated once, written back to the configuration, and read by every instance, so a
// nonce stays verifiable across a restart and between instances.
//
// Where none is configured it falls back to the server's WireGuard key, which is
// regenerated per process. Unpredictable is the property that has to hold either way: a
// peer that could guess the secret would mint future windows' nonces, sign them while
// its key is present, and keep passing after it is gone.
func newCertChallenger(encryptionKey string, serverKey wgtypes.Key) *certposture.Challenger {
secret := []byte(encryptionKey)
if len(secret) == 0 {
log.Warnf("no data store encryption key, deriving certificate challenges from the ephemeral server key: peers will be rejected once per restart and across instances")
secret = serverKey[:]
}
h := sha256.New()
h.Write([]byte(certChallengeKeyDomain))
h.Write(secret)
return certposture.NewChallenger(h.Sum(nil))
}
// stampCertificateChallenges fills the per-peer nonce into every certificate challenge
// right before the response is encrypted for that peer, reporting whether it issued one.
//
// On a sync stream the answer registers the peer for renewal. A nonce is stateless, but
// the renewal that keeps it fresh is local: only the instance holding a peer's stream
// can push to it, so an instance renews exactly the peers it streams nonces to.
func stampCertificateChallenges(checks []*proto.Checks, challenger *certposture.Challenger, peerKey wgtypes.Key) bool {
var nonce []byte
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
if nonce == nil {
nonce = challenger.Nonce(peerKey[:], time.Now())
}
challenge.Nonce = nonce
}
return nonce != nil
}
// verifiedCertificates turns the peer's proofs into PEM chains for its meta. Possession
// (nonce + signature) is verified here; trust against a check's CAs is evaluated by the
// posture check itself. Each proof stands for its own key, so an invalid one is dropped
// without the others: a user store answering with an unusable key must not cost the
// device the machine certificate it proved.
func (s *Server) verifiedCertificates(ctx context.Context, peerKey wgtypes.Key, proofs []*proto.CertificateProof) []string {
if len(proofs) == 0 {
return nil
}
now := time.Now()
chains := make([]string, 0, len(proofs))
for _, p := range proofs {
chain, err := s.challenger.Verify(certposture.Proof{
Nonce: p.GetNonce(),
Chain: p.GetChain(),
SigAlg: p.GetSigAlg(),
Signature: p.GetSignature(),
}, peerKey[:], now)
if err != nil {
log.WithContext(ctx).Warnf("dropping a certificate proof of peer %s: %v", peerKey, err)
continue
}
chains = append(chains, certposture.EncodeChainPEM(chain))
}
if len(chains) == 0 {
return nil
}
return chains
}
@@ -0,0 +1,91 @@
package grpc
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertificateChallenge_StampAndVerifyRoundTrip(t *testing.T) {
const encryptionKey = "data-store-encryption-key"
challenger := newCertChallenger(encryptionKey, generateKey(t))
peerKey := generateKey(t).PublicKey()
ca := certtest.NewCA(t, "corp-root")
ctx := context.Background()
checks := toProtocolChecks(ctx, []*nmdata.PostureChecks{{
ID: "cert-check",
Checks: nmdata.ChecksDefinition{CertificateCheck: &nmdata.CertificateCheck{CACertificates: []string{ca.PEM}}},
}})
require.Len(t, checks, 1)
require.Equal(t, []string{ca.PEM}, checks[0].GetCertificateChallenge().GetCaCertificates())
require.Empty(t, checks[0].GetCertificateChallenge().GetNonce())
stampCertificateChallenges(checks, challenger, peerKey)
nonce := checks[0].GetCertificateChallenge().GetNonce()
require.NotEmpty(t, nonce)
deviceKey := certtest.ECDSAKey(t)
leaf := ca.Issue(t, deviceKey, "device")
sigAlg, sig, err := certposture.Sign(deviceKey, nonce, peerKey[:])
require.NoError(t, err)
proofs := []*proto.CertificateProof{{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}}
s := &Server{challenger: challenger}
chains := s.verifiedCertificates(ctx, peerKey, proofs)
require.Len(t, chains, 1)
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
t.Run("proof replayed by another peer is rejected", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, generateKey(t).PublicKey(), proofs))
})
t.Run("nonce from another deployment's secret is rejected", func(t *testing.T) {
other := &Server{challenger: newCertChallenger("a-different-encryption-key", generateKey(t))}
assert.Nil(t, other.verifiedCertificates(ctx, peerKey, proofs))
})
t.Run("a second instance with the same encryption key accepts it", func(t *testing.T) {
// The point of deriving from the stored key: another instance, with its own
// freshly generated WireGuard key, verifies what this one issued. This is also
// what a restart looks like, and it used to reject.
peer := &Server{challenger: newCertChallenger(encryptionKey, generateKey(t))}
assert.Len(t, peer.verifiedCertificates(ctx, peerKey, proofs), 1)
})
t.Run("an invalid proof is dropped without the valid ones", func(t *testing.T) {
// A device proving its machine certificate while a user store answers with a key
// it cannot use must keep the machine certificate.
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
chains := s.verifiedCertificates(ctx, peerKey, append([]*proto.CertificateProof{bad}, proofs...))
require.Len(t, chains, 1, "the valid proof survives the invalid one")
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
})
t.Run("only invalid proofs yield no certificates", func(t *testing.T) {
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
assert.Empty(t, s.verifiedCertificates(ctx, peerKey, []*proto.CertificateProof{bad}))
})
t.Run("no proofs yields no certificates", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, nil))
})
}
func TestStampCertificateChallenges_SkipsFileOnlyChecks(t *testing.T) {
checks := []*proto.Checks{{Files: []string{"/bin/agent"}}}
stampCertificateChallenges(checks, newCertChallenger("key", generateKey(t)), generateKey(t).PublicKey())
assert.Nil(t, checks[0].GetCertificateChallenge())
}
func generateKey(t *testing.T) wgtypes.Key {
t.Helper()
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
return key
}
@@ -7,6 +7,7 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -18,16 +19,20 @@ func PeerUpdateHandlerFactory(
peerKey wgtypes.Key,
updates chan *network_map.UpdateMessage,
secretsManager SecretsManager,
challenger *certposture.Challenger,
onChallengeStamped func(),
srv proto.ManagementService_SyncServer,
cleanupfunc func()) *PeerUpdateHandler {
return &PeerUpdateHandler{
peerKey: peerKey,
updates: updates,
secretsManager: secretsManager,
srv: srv,
encrypter: encryption.DefaultEncrypter{},
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
cleanupFunc: cleanupfunc,
peerKey: peerKey,
updates: updates,
secretsManager: secretsManager,
challenger: challenger,
onChallengeStamped: onChallengeStamped,
srv: srv,
encrypter: encryption.DefaultEncrypter{},
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
cleanupFunc: cleanupfunc,
}
}
@@ -40,10 +45,15 @@ type PeerUpdateHandler struct {
updates chan *network_map.UpdateMessage
appMetrics telemetry.AppMetrics
secretsManager SecretsManager
srv syncSender
encrypter encryption.Encrypter
debouncer Debouncer
cleanupFunc func()
challenger *certposture.Challenger
// onChallengeStamped registers the account for renewal. Every push reaches this
// path, including the ones a REST update on another instance broadcasts, which is
// what lets an instance pick up an account whose peers never reconnect.
onChallengeStamped func()
srv syncSender
encrypter encryption.Encrypter
debouncer Debouncer
cleanupFunc func()
}
func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler {
@@ -117,6 +127,9 @@ func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map
return status.Errorf(codes.Internal, "failed processing update message")
}
if stampCertificateChallenges(update.Update.GetChecks(), pu.challenger, pu.peerKey) && pu.onChallengeStamped != nil {
pu.onChallengeStamped()
}
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
if err != nil {
pu.cleanupFunc()
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"

Some files were not shown because too many files have changed in this diff Show More