diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index dd6b9fe68..880370ede 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -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] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a79357505..d133d87f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0e9b11bc5..6bb4e9e8a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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" diff --git a/client/cmd/posture.go b/client/cmd/posture.go new file mode 100644 index 000000000..b0870233d --- /dev/null +++ b/client/cmd/posture.go @@ -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()) + }, +} diff --git a/client/cmd/root.go b/client/cmd/root.go index 4525a9bd6..51179f77c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -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) diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index 496903044..f71689df1 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -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)) -} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index cb2800978..f0cc3ed88 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -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") + } + } +} diff --git a/client/internal/certproof/collect.go b/client/internal/certproof/collect.go new file mode 100644 index 000000000..65affca71 --- /dev/null +++ b/client/internal/certproof/collect.go @@ -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) +} diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go new file mode 100644 index 000000000..92f0471e7 --- /dev/null +++ b/client/internal/certproof/collect_darwin.go @@ -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) +} diff --git a/client/internal/certproof/collect_js.go b/client/internal/certproof/collect_js.go new file mode 100644 index 000000000..fd7c4c690 --- /dev/null +++ b/client/internal/certproof/collect_js.go @@ -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() +} diff --git a/client/internal/certproof/collect_other.go b/client/internal/certproof/collect_other.go new file mode 100644 index 000000000..785d3db65 --- /dev/null +++ b/client/internal/certproof/collect_other.go @@ -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()}) +} diff --git a/client/internal/certproof/collect_test.go b/client/internal/certproof/collect_test.go new file mode 100644 index 000000000..e16ee3668 --- /dev/null +++ b/client/internal/certproof/collect_test.go @@ -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) + } +} diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go new file mode 100644 index 000000000..8e5695539 --- /dev/null +++ b/client/internal/certproof/collect_windows.go @@ -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 +} diff --git a/client/internal/certproof/collector.go b/client/internal/certproof/collector.go new file mode 100644 index 000000000..2931f4b00 --- /dev/null +++ b/client/internal/certproof/collector.go @@ -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 +} diff --git a/client/internal/certproof/collector_test.go b/client/internal/certproof/collector_test.go new file mode 100644 index 000000000..a28c47da2 --- /dev/null +++ b/client/internal/certproof/collector_test.go @@ -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) +} diff --git a/client/internal/certproof/consoleuser_darwin.go b/client/internal/certproof/consoleuser_darwin.go new file mode 100644 index 000000000..a5182c7fd --- /dev/null +++ b/client/internal/certproof/consoleuser_darwin.go @@ -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 +} diff --git a/client/internal/certproof/consoleuser_darwin_test.go b/client/internal/certproof/consoleuser_darwin_test.go new file mode 100644 index 000000000..9c1aaa22c --- /dev/null +++ b/client/internal/certproof/consoleuser_darwin_test.go @@ -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") +} diff --git a/client/internal/certproof/desktopuser_windows.go b/client/internal/certproof/desktopuser_windows.go new file mode 100644 index 000000000..6f50aae4f --- /dev/null +++ b/client/internal/certproof/desktopuser_windows.go @@ -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) +} diff --git a/client/internal/certproof/desktopuser_windows_test.go b/client/internal/certproof/desktopuser_windows_test.go new file mode 100644 index 000000000..95d29eb5f --- /dev/null +++ b/client/internal/certproof/desktopuser_windows_test.go @@ -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") + } +} diff --git a/client/internal/certproof/doc.go b/client/internal/certproof/doc.go new file mode 100644 index 000000000..c8dc62f0c --- /dev/null +++ b/client/internal/certproof/doc.go @@ -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 diff --git a/client/internal/certproof/helper.go b/client/internal/certproof/helper.go new file mode 100644 index 000000000..81a72ac70 --- /dev/null +++ b/client/internal/certproof/helper.go @@ -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 +} diff --git a/client/internal/certproof/helper_backoff.go b/client/internal/certproof/helper_backoff.go new file mode 100644 index 000000000..3372b026b --- /dev/null +++ b/client/internal/certproof/helper_backoff.go @@ -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)) +} diff --git a/client/internal/certproof/helper_backoff_test.go b/client/internal/certproof/helper_backoff_test.go new file mode 100644 index 000000000..0d3cafea1 --- /dev/null +++ b/client/internal/certproof/helper_backoff_test.go @@ -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") +} diff --git a/client/internal/certproof/helper_kill_other.go b/client/internal/certproof/helper_kill_other.go new file mode 100644 index 000000000..2ececb27e --- /dev/null +++ b/client/internal/certproof/helper_kill_other.go @@ -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() +} diff --git a/client/internal/certproof/helper_kill_unix.go b/client/internal/certproof/helper_kill_unix.go new file mode 100644 index 000000000..e434d4962 --- /dev/null +++ b/client/internal/certproof/helper_kill_unix.go @@ -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() +} diff --git a/client/internal/certproof/helper_kill_windows.go b/client/internal/certproof/helper_kill_windows.go new file mode 100644 index 000000000..1e143c132 --- /dev/null +++ b/client/internal/certproof/helper_kill_windows.go @@ -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) +} diff --git a/client/internal/certproof/helper_kill_windows_test.go b/client/internal/certproof/helper_kill_windows_test.go new file mode 100644 index 000000000..4778d253b --- /dev/null +++ b/client/internal/certproof/helper_kill_windows_test.go @@ -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") +} diff --git a/client/internal/certproof/helper_run.go b/client/internal/certproof/helper_run.go new file mode 100644 index 000000000..52c9db775 --- /dev/null +++ b/client/internal/certproof/helper_run.go @@ -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() +} diff --git a/client/internal/certproof/helper_run_test.go b/client/internal/certproof/helper_run_test.go new file mode 100644 index 000000000..ea1246e93 --- /dev/null +++ b/client/internal/certproof/helper_run_test.go @@ -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") +} diff --git a/client/internal/certproof/helper_spawn.go b/client/internal/certproof/helper_spawn.go new file mode 100644 index 000000000..968cc3679 --- /dev/null +++ b/client/internal/certproof/helper_spawn.go @@ -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) +} diff --git a/client/internal/certproof/helper_spawn_test.go b/client/internal/certproof/helper_spawn_test.go new file mode 100644 index 000000000..338a5f66a --- /dev/null +++ b/client/internal/certproof/helper_spawn_test.go @@ -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") +} diff --git a/client/internal/certproof/helper_test.go b/client/internal/certproof/helper_test.go new file mode 100644 index 000000000..37dadead2 --- /dev/null +++ b/client/internal/certproof/helper_test.go @@ -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") +} diff --git a/client/internal/certproof/keychain_darwin.go b/client/internal/certproof/keychain_darwin.go new file mode 100644 index 000000000..2bffdc81e --- /dev/null +++ b/client/internal/certproof/keychain_darwin.go @@ -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 -k " + + "-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("", 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) +} diff --git a/client/internal/certproof/keystore.go b/client/internal/certproof/keystore.go new file mode 100644 index 000000000..7ce676b4c --- /dev/null +++ b/client/internal/certproof/keystore.go @@ -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:])}) +} diff --git a/client/internal/certproof/keystore_test.go b/client/internal/certproof/keystore_test.go new file mode 100644 index 000000000..592d7414f --- /dev/null +++ b/client/internal/certproof/keystore_test.go @@ -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) +} diff --git a/client/internal/certproof/pinlatch.go b/client/internal/certproof/pinlatch.go new file mode 100644 index 000000000..79e396b11 --- /dev/null +++ b/client/internal/certproof/pinlatch.go @@ -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=