mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 07:19:08 +01:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # shared/management/proto/management.pb.go
This commit is contained in:
@@ -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())
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
|
||||
// store to sign with, so the stores stay out of the WebAssembly build.
|
||||
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserContext identifies the user whose certificates a collection would include. A
|
||||
// browser has no per-user store, so it never changes.
|
||||
func UserContext(Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultStore is empty in a browser.
|
||||
func DefaultStore() Store {
|
||||
return Stores{}
|
||||
}
|
||||
|
||||
func helperStore() Store {
|
||||
return DefaultStore()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
|
||||
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
|
||||
// per-user certificates out of reach of a privileged daemon, so every other platform
|
||||
// reads its store in the daemon itself.
|
||||
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
|
||||
}
|
||||
|
||||
// UserContext identifies the user whose certificates a collection would include. These
|
||||
// platforms have no per-user store, so it never changes.
|
||||
func UserContext(Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// helperStore is the store the helper reads. Nothing launches a helper on these
|
||||
// platforms, so it is the store the daemon reads, configured from the same environment,
|
||||
// which lets an administrator check a setup by running the helper by hand.
|
||||
func helperStore() Store {
|
||||
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
||||
|
||||
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
|
||||
corpCA := certtest.NewCA(t, "corp-root")
|
||||
otherCA := certtest.NewCA(t, "other-root")
|
||||
unrelatedCA := certtest.NewCA(t, "unrelated-root")
|
||||
|
||||
dir := storeDir(t)
|
||||
deviceKey := certtest.ECDSAKey(t)
|
||||
device := corpCA.Issue(t, deviceKey, "device")
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
|
||||
|
||||
otherKey := certtest.RSAKey(t)
|
||||
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
|
||||
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
|
||||
|
||||
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
|
||||
writeFile(t, dir, "notes.txt", "ignored")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
nonce := challenger.Nonce(peerKey, time.Now())
|
||||
challenge := func(cas ...string) *proto.Checks {
|
||||
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
|
||||
}
|
||||
checks := []*proto.Checks{
|
||||
{Files: []string{"/usr/bin/agent"}},
|
||||
challenge(corpCA.PEM),
|
||||
challenge(corpCA.PEM),
|
||||
challenge(otherCA.PEM),
|
||||
challenge(unrelatedCA.PEM),
|
||||
challenge("not a pem"),
|
||||
}
|
||||
|
||||
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
|
||||
|
||||
require.Len(t, proofs, 2)
|
||||
var subjects []string
|
||||
for _, p := range proofs {
|
||||
chain, err := challenger.Verify(p, peerKey, time.Now())
|
||||
require.NoError(t, err)
|
||||
subjects = append(subjects, chain[0].Subject.CommonName)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
|
||||
}
|
||||
|
||||
func TestCollect_NothingToProve(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
key := certtest.ECDSAKey(t)
|
||||
ca := certtest.NewCA(t, "root")
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store Store
|
||||
checks []*proto.Checks
|
||||
}{
|
||||
{"no checks", NewFileStore(dir), nil},
|
||||
{"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
|
||||
{"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
|
||||
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_ChainWithIntermediate(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := intermediate.Issue(t, key, "device")
|
||||
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
|
||||
|
||||
candidates, err := NewFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1)
|
||||
require.Len(t, candidates[0].Chain, 2)
|
||||
|
||||
roots, err := certposture.ParseCAs([]string{root.PEM})
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
|
||||
}
|
||||
|
||||
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
|
||||
// leaves the directory group-writable on systems with a user-private group umask.
|
||||
func storeDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o700))
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
|
||||
}
|
||||
|
||||
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
dir := storeDir(t)
|
||||
key := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
store := NewFileStore(dir)
|
||||
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nonce []byte
|
||||
peerKey []byte
|
||||
want int
|
||||
}{
|
||||
{"issued nonce and peer key are signed", nonce, peerKey, 1},
|
||||
{"short nonce is not signed", nonce[:8], peerKey, 0},
|
||||
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
|
||||
{"short peer key is not signed", nonce, peerKey[:16], 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
|
||||
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
|
||||
"the device key signs only what management could have issued")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
dir := storeDir(t)
|
||||
|
||||
// A stale key next to a renewed certificate, sorted before the good pair, must not
|
||||
// produce a proof that management rejects and stop the search there.
|
||||
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
|
||||
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
|
||||
goodKey := certtest.ECDSAKey(t)
|
||||
good := ca.Issue(t, goodKey, "good")
|
||||
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
|
||||
|
||||
candidates, err := NewFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
|
||||
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
proofs := CollectChallenges(context.Background(), NewFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
|
||||
require.Len(t, proofs, 1)
|
||||
_, err = challenger.Verify(proofs[0], peerKey, now)
|
||||
assert.NoError(t, err, "the proof sent is one management accepts")
|
||||
}
|
||||
|
||||
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
|
||||
type staticStore []Candidate
|
||||
|
||||
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
|
||||
|
||||
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
|
||||
|
||||
// Renewing a CA with the same key pair leaves two certificates with the same
|
||||
// subject and key in the store. The expired one sorts first here.
|
||||
expiredTmpl := *intermediate.Cert
|
||||
expiredTmpl.SerialNumber = big.NewInt(1)
|
||||
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
|
||||
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
|
||||
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
|
||||
require.NoError(t, err)
|
||||
expired, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := intermediate.Issue(t, key, "device")
|
||||
pool := []*x509.Certificate{expired, intermediate.Cert}
|
||||
|
||||
chain := buildChain(leaf, pool)
|
||||
require.Len(t, chain, 2)
|
||||
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
|
||||
|
||||
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
|
||||
|
||||
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
|
||||
verified, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
|
||||
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
|
||||
"management's own check accepts the chain the proof carries")
|
||||
}
|
||||
|
||||
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
|
||||
rootA := certtest.NewCA(t, "root-a")
|
||||
rootB := certtest.NewCA(t, "root-b")
|
||||
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
|
||||
|
||||
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
|
||||
crossTmpl := *issuer.Cert
|
||||
crossTmpl.SerialNumber = big.NewInt(2)
|
||||
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
|
||||
require.NoError(t, err)
|
||||
cross, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := issuer.Issue(t, key, "device")
|
||||
pool := []*x509.Certificate{issuer.Cert, cross}
|
||||
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
challenges := []*proto.CertificateChallenge{
|
||||
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
|
||||
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||
}
|
||||
|
||||
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
|
||||
|
||||
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
|
||||
for _, root := range []*certtest.CA{rootA, rootB} {
|
||||
matched := false
|
||||
for _, p := range proofs {
|
||||
chain, err := challenger.Verify(p, peerKey, now)
|
||||
require.NoError(t, err)
|
||||
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
|
||||
}
|
||||
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const helperTimeout = 30 * time.Second
|
||||
|
||||
// CollectProofs answers the certificate challenges in checks from every store this
|
||||
// machine can reach. The service reads the local machine store itself, where AD and
|
||||
// Intune enrol device certificates, and reaches the signed-in user's store by launching
|
||||
// a helper with that session's token. A machine at the sign-in screen therefore proves
|
||||
// device certificates alone.
|
||||
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
challenges := certificateChallenges(checks)
|
||||
if len(challenges) == 0 {
|
||||
logNoChallenges(checks)
|
||||
return nil
|
||||
}
|
||||
|
||||
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||
|
||||
// The helper already runs as the signed-in user, and an ordinary process has no
|
||||
// right to a session token, so only the service goes looking for one.
|
||||
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||
return proofs
|
||||
}
|
||||
|
||||
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
|
||||
if err != nil {
|
||||
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
|
||||
}
|
||||
return mergeProofs(proofs, userProofs)
|
||||
}
|
||||
|
||||
// UserContext identifies the session whose store a collection would include: a session
|
||||
// of the profile owner, or empty when no user store would be asked. A change means a
|
||||
// collection made earlier no longer reflects what this machine can prove.
|
||||
func UserContext(cfg Config) string {
|
||||
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||
return ""
|
||||
}
|
||||
user, ok := CurrentDesktopUser(cfg.ProfileOwner)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
defer user.Close()
|
||||
return fmt.Sprintf("%d:%s", user.Session, user.Name)
|
||||
}
|
||||
|
||||
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
|
||||
// that user's store rather than the machine store the service already read.
|
||||
func helperStore() Store {
|
||||
return NewUserStore()
|
||||
}
|
||||
|
||||
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
|
||||
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
|
||||
// directly, so the session token goes straight into the child process.
|
||||
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
|
||||
user, ok := CurrentDesktopUser(owner)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
defer user.Close()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve own binary: %w", err)
|
||||
}
|
||||
|
||||
// The user's own environment, not the service's: the service environment may carry
|
||||
// secrets such as a setup key that the signed-in user must not be able to read.
|
||||
env, err := user.Token.Environ(false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(user.Token),
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.CREATE_NO_WINDOW,
|
||||
}
|
||||
|
||||
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
|
||||
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
|
||||
}
|
||||
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
|
||||
return proofs, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
|
||||
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
|
||||
}}}
|
||||
|
||||
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
|
||||
var c Collector
|
||||
called := false
|
||||
|
||||
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.Nil(t, proofs)
|
||||
assert.False(t, called, "no store is touched when no check carries a challenge")
|
||||
}
|
||||
|
||||
func TestCollector_ReturnsProofs(t *testing.T) {
|
||||
var c Collector
|
||||
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||
|
||||
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
|
||||
|
||||
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
|
||||
}
|
||||
|
||||
func TestCollector_AbandonsStuckCollection(t *testing.T) {
|
||||
c := Collector{timeout: 50 * time.Millisecond, slots: &collectSlots{}}
|
||||
release := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
|
||||
// A token or keychain call that ignores its context and blocks well past the deadline.
|
||||
stuck := func(context.Context) []certposture.Proof {
|
||||
defer close(finished)
|
||||
<-release
|
||||
return []certposture.Proof{{Nonce: []byte("late")}}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
proofs := c.collect(context.Background(), challengeChecks, stuck)
|
||||
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
|
||||
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
|
||||
|
||||
called := false
|
||||
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
assert.Nil(t, proofs)
|
||||
assert.False(t, called, "no second collection starts while the first is still running")
|
||||
|
||||
close(release)
|
||||
<-finished
|
||||
require.Eventually(t, func() bool { return c.slots.idle() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
|
||||
|
||||
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
|
||||
"collection works again after the stuck call returned")
|
||||
}
|
||||
|
||||
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
|
||||
c := Collector{timeout: 20 * time.Millisecond, slots: &collectSlots{}}
|
||||
cancelled := make(chan struct{})
|
||||
|
||||
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
|
||||
<-ctx.Done()
|
||||
close(cancelled)
|
||||
return nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-cancelled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
|
||||
// collection stuck in a token call outlives the engine that started it, so the next
|
||||
// engine's Collector must not start another one until it has finished.
|
||||
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
|
||||
shared := &collectSlots{}
|
||||
first := Collector{timeout: 20 * time.Millisecond, slots: shared}
|
||||
second := Collector{timeout: time.Second, slots: shared}
|
||||
|
||||
release := make(chan struct{})
|
||||
stuck := func(context.Context) []certposture.Proof {
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
|
||||
|
||||
called := false
|
||||
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return []certposture.Proof{{}}
|
||||
})
|
||||
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
|
||||
assert.False(t, called, "a second collection does not start on top of the abandoned one")
|
||||
|
||||
close(release)
|
||||
require.Eventually(t, func() bool { return shared.idle() }, time.Second, 5*time.Millisecond)
|
||||
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
|
||||
"collections resume once the abandoned one finished")
|
||||
}
|
||||
|
||||
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
|
||||
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
|
||||
var a, b Collector
|
||||
assert.Same(t, a.slotsInUse(), b.slotsInUse(), "separate Collectors share the process slots")
|
||||
}
|
||||
|
||||
// TestCollector_LostCollectionDoesNotBlockForever: a store call that never answers, such
|
||||
// as a wedged token on Linux where there is no process to kill, must not refuse every
|
||||
// later collection. Once it has run for lostAfter deadlines another starts beside it,
|
||||
// and no more than maxInFlight ever run.
|
||||
func TestCollector_LostCollectionDoesNotBlockForever(t *testing.T) {
|
||||
now := time.Now()
|
||||
slots := &collectSlots{}
|
||||
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var started atomic.Int32
|
||||
wedged := func(context.Context) []certposture.Proof {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
waitStarted(t, &started, 1)
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
assert.Equal(t, int32(1), started.Load(), "a second collection is refused while the first may still finish")
|
||||
assert.False(t, c.Stuck(), "one slow collection is not stuck yet")
|
||||
|
||||
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
waitStarted(t, &started, 2)
|
||||
assert.False(t, c.Stuck(), "the second collection may still return, so the collector is not stuck yet")
|
||||
|
||||
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
assert.Equal(t, int32(2), started.Load(), "no more than maxInFlight collections run")
|
||||
assert.True(t, c.Stuck(), "with every slot held by a lost collection the collector reports itself stuck")
|
||||
}
|
||||
|
||||
// TestCollector_LostWindowFollowsTheOldestRunningCollection: A wedges, B starts beside it
|
||||
// once A counts as lost, then A returns. B is recent, so a third collection must wait for
|
||||
// B to be lost in its own right rather than inherit A's start time.
|
||||
func TestCollector_LostWindowFollowsTheOldestRunningCollection(t *testing.T) {
|
||||
now := time.Now()
|
||||
slots := &collectSlots{}
|
||||
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||
lost := time.Duration(lostAfter) * c.deadline()
|
||||
|
||||
releaseA := make(chan struct{})
|
||||
releaseB := make(chan struct{})
|
||||
defer close(releaseB)
|
||||
var started atomic.Int32
|
||||
blockOn := func(release chan struct{}) func(context.Context) []certposture.Proof {
|
||||
return func(context.Context) []certposture.Proof {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
c.collect(context.Background(), challengeChecks, blockOn(releaseA))
|
||||
waitStarted(t, &started, 1)
|
||||
now = now.Add(lost)
|
||||
c.collect(context.Background(), challengeChecks, blockOn(releaseB))
|
||||
waitStarted(t, &started, 2)
|
||||
|
||||
close(releaseA)
|
||||
require.Eventually(t, func() bool {
|
||||
slots.mu.Lock()
|
||||
defer slots.mu.Unlock()
|
||||
return len(slots.running) == 1
|
||||
}, time.Second, 5*time.Millisecond, "A frees its slot once it returns")
|
||||
|
||||
c.collect(context.Background(), challengeChecks, blockOn(make(chan struct{})))
|
||||
assert.Equal(t, int32(2), started.Load(), "a third collection waits while B is recent")
|
||||
}
|
||||
|
||||
// waitStarted waits for the collections the test launched to have started. collect may
|
||||
// return at its deadline before the goroutine running the store call was scheduled.
|
||||
func waitStarted(t *testing.T, started *atomic.Int32, n int32) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool { return started.Load() == n }, time.Second, time.Millisecond,
|
||||
"%d collections should have started", n)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
|
||||
|
||||
encodingUTF8 = 0x08000100
|
||||
consoleNameBufSize = 256
|
||||
)
|
||||
|
||||
var (
|
||||
consoleOnce sync.Once
|
||||
consoleErr error
|
||||
|
||||
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
|
||||
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
|
||||
)
|
||||
|
||||
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
|
||||
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
|
||||
type ConsoleUser struct {
|
||||
Name string
|
||||
UID uint32
|
||||
GID uint32
|
||||
}
|
||||
|
||||
// CurrentConsoleUser reports the user sitting at the desktop. The second return value is
|
||||
// false when nobody is: at the login window macOS either reports no console user at all
|
||||
// or attributes the session to root, and neither has a login keychain to offer.
|
||||
func CurrentConsoleUser() (ConsoleUser, bool) {
|
||||
if err := loadConsoleUser(); err != nil {
|
||||
log.Debugf("console user lookup unavailable: %v", err)
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
|
||||
var uid, gid uint32
|
||||
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
|
||||
if name == 0 {
|
||||
log.Debug("no console user is logged in, no login keychain is reachable")
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
defer release(name)
|
||||
|
||||
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
|
||||
if !user.hasDesktop() {
|
||||
log.Debugf("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// hasDesktop reports whether the console session is a real user desktop. The login
|
||||
// window runs as root and some macOS releases name it "loginwindow" instead.
|
||||
func (u ConsoleUser) hasDesktop() bool {
|
||||
switch u.Name {
|
||||
case "", "root", "loginwindow":
|
||||
return false
|
||||
}
|
||||
return u.UID != 0
|
||||
}
|
||||
|
||||
// isOwner reports whether the console user is owner, the account of the active profile,
|
||||
// which is recorded as a short user name or, for an account without one, a numeric uid.
|
||||
// With no owner the console user counts, as macOS has a single console user.
|
||||
func (u ConsoleUser) isOwner(owner string) bool {
|
||||
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
|
||||
}
|
||||
|
||||
func cfString(str uintptr) string {
|
||||
buf := make([]byte, consoleNameBufSize)
|
||||
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
|
||||
return ""
|
||||
}
|
||||
if end := bytes.IndexByte(buf, 0); end >= 0 {
|
||||
return string(buf[:end])
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
|
||||
// first because CFRelease is resolved there and released strings depend on it.
|
||||
func loadConsoleUser() error {
|
||||
if err := loadKeychain(); err != nil {
|
||||
return err
|
||||
}
|
||||
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
|
||||
return consoleErr
|
||||
}
|
||||
|
||||
func resolveConsoleUser() error {
|
||||
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
|
||||
}
|
||||
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
||||
}
|
||||
|
||||
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
|
||||
}
|
||||
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
|
||||
|
||||
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve CFStringGetCString: %w", err)
|
||||
}
|
||||
purego.RegisterFunc(&cfStringGetCString, symbol)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user ConsoleUser
|
||||
desktop bool
|
||||
}{
|
||||
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
|
||||
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
|
||||
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
|
||||
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
|
||||
{"no console user", ConsoleUser{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with
|
||||
// a desktop open must report a non-root user; a headless runner must report none.
|
||||
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
|
||||
user, ok := CurrentConsoleUser()
|
||||
if !ok {
|
||||
t.Log("no console user, running headless")
|
||||
return
|
||||
}
|
||||
assert.NotEmpty(t, user.Name, "a console user must have a name")
|
||||
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
|
||||
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
|
||||
}
|
||||
|
||||
func TestConsoleUser_IsOwner(t *testing.T) {
|
||||
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
|
||||
|
||||
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
|
||||
assert.True(t, user.isOwner("maycon"), "the owner by short name")
|
||||
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
|
||||
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
|
||||
assert.False(t, user.isOwner("502"), "another uid is another account")
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
noActiveSession = 0xFFFFFFFF
|
||||
servicesSession = 0
|
||||
|
||||
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE; wtsActive and wtsDisconnected are
|
||||
// WTSActive and WTSDisconnected of WTS_CONNECTSTATE_CLASS. None is exported by
|
||||
// x/sys/windows.
|
||||
wtsCurrentServer = windows.Handle(0)
|
||||
wtsActive = 0
|
||||
wtsDisconnected = 4
|
||||
)
|
||||
|
||||
// DesktopUser is an interactive session and the account signed into it. The user's
|
||||
// certificate store is readable only from a process running as that account, because
|
||||
// its private keys are protected against the user profile rather than the machine.
|
||||
type DesktopUser struct {
|
||||
Session uint32
|
||||
Name string
|
||||
Token windows.Token
|
||||
}
|
||||
|
||||
// Close releases the session token.
|
||||
func (u DesktopUser) Close() {
|
||||
if err := u.Token.Close(); err != nil {
|
||||
log.Debugf("failed closing desktop session token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentDesktopUser returns a token for the session whose certificate store should be
|
||||
// asked: a session of owner, the account the active profile belongs to, with the
|
||||
// physical console preferred over remote sessions. With no owner only the console user
|
||||
// counts. Picking any signed-in user instead would let whoever else is logged in to a
|
||||
// terminal server or VDI host decide the result. The second return is false when no
|
||||
// such session exists, and only machine certificates can then be proven.
|
||||
//
|
||||
// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an
|
||||
// ordinary process does not.
|
||||
func CurrentDesktopUser(owner string) (DesktopUser, bool) {
|
||||
console := windows.WTSGetActiveConsoleSessionId()
|
||||
if owner == "" {
|
||||
return consoleUser(console)
|
||||
}
|
||||
|
||||
sessions, err := userSessions(console)
|
||||
if err != nil {
|
||||
log.Debugf("cannot enumerate terminal sessions: %v", err)
|
||||
return DesktopUser{}, false
|
||||
}
|
||||
var found DesktopUser
|
||||
var ok bool
|
||||
for _, session := range sessions {
|
||||
user, signedIn := desktopUser(session)
|
||||
if !signedIn {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case !sameAccountName(user.Name, owner):
|
||||
user.Close()
|
||||
case !ok:
|
||||
found, ok = user, true
|
||||
case strings.EqualFold(user.Name, found.Name):
|
||||
// Another session of the same account; the first one in preference order wins.
|
||||
user.Close()
|
||||
default:
|
||||
// An owner recorded without a domain matches accounts of several domains here.
|
||||
// Picking one would let another domain's user answer for the owner.
|
||||
log.Debugf("certificate posture: profile owner %s matches both %s and %s, no user certificate store is used", owner, found.Name, user.Name)
|
||||
user.Close()
|
||||
found.Close()
|
||||
return DesktopUser{}, false
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
log.Debugf("certificate posture: profile owner %s has no signed-in session, no user certificate store is reachable", owner)
|
||||
}
|
||||
return found, ok
|
||||
}
|
||||
|
||||
func consoleUser(console uint32) (DesktopUser, bool) {
|
||||
if console == noActiveSession || console == servicesSession {
|
||||
log.Debug("no console session, no user certificate store is reachable")
|
||||
return DesktopUser{}, false
|
||||
}
|
||||
user, ok := desktopUser(console)
|
||||
if !ok {
|
||||
log.Debugf("console session %d has nobody signed in, no user certificate store is reachable", console)
|
||||
}
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// sameAccountName compares DOMAIN\account names case-insensitively, as Windows does, and an
|
||||
// owner given without a domain against the account part alone. The session side comes from
|
||||
// the session token's own SID, which Windows resolves from its cache of signed-in users.
|
||||
// Resolving the owner name to a SID instead would ask the domain controller, which on a
|
||||
// laptop that cannot reach it yet blocks for tens of seconds, past the collection deadline.
|
||||
func sameAccountName(sessionName, owner string) bool {
|
||||
if strings.EqualFold(sessionName, owner) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(owner, `\`) {
|
||||
return false
|
||||
}
|
||||
_, account, found := strings.Cut(sessionName, `\`)
|
||||
return found && strings.EqualFold(account, owner)
|
||||
}
|
||||
|
||||
func desktopUser(session uint32) (DesktopUser, bool) {
|
||||
var token windows.Token
|
||||
if err := windows.WTSQueryUserToken(session, &token); err != nil {
|
||||
log.Debugf("no user token for session %d: %v", session, err)
|
||||
return DesktopUser{}, false
|
||||
}
|
||||
|
||||
name, err := tokenAccount(token)
|
||||
if err != nil {
|
||||
log.Debugf("session %d token has no readable account: %v", session, err)
|
||||
if closeErr := token.Close(); closeErr != nil {
|
||||
log.Debugf("failed closing session token: %v", closeErr)
|
||||
}
|
||||
return DesktopUser{}, false
|
||||
}
|
||||
return DesktopUser{Session: session, Name: name, Token: token}, true
|
||||
}
|
||||
|
||||
func tokenAccount(token windows.Token) (string, error) {
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
account, domain, _, err := user.User.Sid.LookupAccount("")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("look up account: %w", err)
|
||||
}
|
||||
if domain == "" {
|
||||
return account, nil
|
||||
}
|
||||
return domain + `\` + account, nil
|
||||
}
|
||||
|
||||
// userSessions lists the sessions a user can be signed in to, console first, then
|
||||
// active remote sessions, then disconnected ones, whose user is still signed in. Session
|
||||
// 0 is skipped: it hosts services and never belongs to an interactive user.
|
||||
func userSessions(console uint32) ([]uint32, error) {
|
||||
var info *windows.WTS_SESSION_INFO
|
||||
var count uint32
|
||||
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
|
||||
return nil, fmt.Errorf("enumerate sessions: %w", err)
|
||||
}
|
||||
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
|
||||
|
||||
var sessions []uint32
|
||||
if console != noActiveSession && console != servicesSession {
|
||||
sessions = append(sessions, console)
|
||||
}
|
||||
for _, state := range []uint32{wtsActive, wtsDisconnected} {
|
||||
for _, session := range unsafe.Slice(info, count) {
|
||||
if session.SessionID == servicesSession || session.SessionID == console || session.State != state {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, session.SessionID)
|
||||
}
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// runningAsLocalSystem reports whether this process is the service. The helper runs as
|
||||
// the signed-in user and must read its own store rather than launching another helper.
|
||||
func runningAsLocalSystem() bool {
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
log.Debugf("failed reading own token user: %v", err)
|
||||
return false
|
||||
}
|
||||
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSameAccountName(t *testing.T) {
|
||||
tests := []struct {
|
||||
session, owner string
|
||||
want bool
|
||||
}{
|
||||
{`CORP\alice`, `CORP\alice`, true},
|
||||
{`CORP\alice`, `corp\ALICE`, true},
|
||||
{`CORP\alice`, `alice`, true},
|
||||
{`CORP\alice`, `OTHER\alice`, false},
|
||||
{`CORP\alice`, `bob`, false},
|
||||
{`alice`, `alice`, true},
|
||||
{`CORP\alice`, `CORP\alic`, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, sameAccountName(tt.session, tt.owner), "session %q, owner %q", tt.session, tt.owner)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentDesktopUser against the real session manager: an owner no session belongs to
|
||||
// must never yield a session, whoever else is signed in.
|
||||
func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) {
|
||||
user, ok := CurrentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`)
|
||||
if ok {
|
||||
user.Close()
|
||||
}
|
||||
assert.False(t, ok, "a profile owner without a session must not borrow another user's store")
|
||||
}
|
||||
|
||||
// TestCurrentDesktopUser_FindsTheOwnersSession runs against a real machine as LocalSystem,
|
||||
// with NB_TEST_DESKTOP_OWNER naming an account that is signed in. That account's session
|
||||
// must be found, and only that account's.
|
||||
func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) {
|
||||
owner := os.Getenv("NB_TEST_DESKTOP_OWNER")
|
||||
if owner == "" {
|
||||
t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem")
|
||||
}
|
||||
user, ok := CurrentDesktopUser(owner)
|
||||
require.True(t, ok, "the signed-in owner %s must have a session", owner)
|
||||
defer user.Close()
|
||||
t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name)
|
||||
assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name)
|
||||
assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner")
|
||||
|
||||
console, consoleOK := CurrentDesktopUser("")
|
||||
if consoleOK {
|
||||
defer console.Close()
|
||||
t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name)
|
||||
} else {
|
||||
t.Log("without an owner nobody counts: no user at the console")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// HelperRequest is the work the daemon hands to a helper running in a user session. The
|
||||
// peer key binds every signature to this machine, so a proof cannot be replayed onto
|
||||
// another peer.
|
||||
type HelperRequest struct {
|
||||
PeerKey []byte `json:"peerKey"`
|
||||
Challenges []HelperChallenge `json:"challenges"`
|
||||
}
|
||||
|
||||
// HelperChallenge is one certificate challenge in the form the helper needs.
|
||||
type HelperChallenge struct {
|
||||
Nonce []byte `json:"nonce"`
|
||||
CACertificates []string `json:"caCertificates"`
|
||||
}
|
||||
|
||||
// HelperResponse carries the proofs the helper produced from its own keychain.
|
||||
type HelperResponse struct {
|
||||
Proofs []certposture.Proof `json:"proofs"`
|
||||
}
|
||||
|
||||
// RunHelper answers the challenges on in from the store of the user running this
|
||||
// process and writes the proofs to out. It is the child half of the console user
|
||||
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
|
||||
// session instead. Only the signature crosses back, never the private key.
|
||||
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
return runHelper(ctx, helperStore(), in, out)
|
||||
}
|
||||
|
||||
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
|
||||
var req HelperRequest
|
||||
if err := json.NewDecoder(in).Decode(&req); err != nil {
|
||||
return fmt.Errorf("decode helper request: %w", err)
|
||||
}
|
||||
|
||||
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
|
||||
for _, challenge := range req.Challenges {
|
||||
challenges = append(challenges, &proto.CertificateChallenge{
|
||||
Nonce: challenge.Nonce,
|
||||
CaCertificates: challenge.CACertificates,
|
||||
})
|
||||
}
|
||||
|
||||
var proofs []certposture.Proof
|
||||
if len(challenges) > 0 {
|
||||
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
|
||||
}
|
||||
log.Debugf("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
|
||||
|
||||
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
|
||||
return fmt.Errorf("encode helper response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
|
||||
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
|
||||
// every collection would put up a new one each time the user ignored or denied the last.
|
||||
const helperQuietPeriod = time.Hour
|
||||
|
||||
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
|
||||
// is not launched again because its last run proved nothing.
|
||||
type helperBackoff struct {
|
||||
mu sync.Mutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
func newHelperBackoff() *helperBackoff {
|
||||
return &helperBackoff{until: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// allow reports whether the helper may be launched for key now.
|
||||
func (b *helperBackoff) allow(key string, now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return !now.Before(b.until[key])
|
||||
}
|
||||
|
||||
// record stores the outcome of a helper run for key: one that proved something clears
|
||||
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
|
||||
// map holds only users currently held off.
|
||||
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for other, until := range b.until {
|
||||
if !now.Before(until) {
|
||||
delete(b.until, other)
|
||||
}
|
||||
}
|
||||
if proven {
|
||||
delete(b.until, key)
|
||||
return
|
||||
}
|
||||
b.until[key] = now.Add(helperQuietPeriod)
|
||||
}
|
||||
|
||||
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
|
||||
// order, leaving out the nonces, which rotate without changing what the user is asked to
|
||||
// prove.
|
||||
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
|
||||
sets := make([]string, 0, len(challenges))
|
||||
for _, challenge := range challenges {
|
||||
cas := make([]string, 0, len(challenge.GetCaCertificates()))
|
||||
for _, ca := range challenge.GetCaCertificates() {
|
||||
sum := sha256.Sum256([]byte(ca))
|
||||
cas = append(cas, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
slices.Sort(cas)
|
||||
sets = append(sets, strings.Join(cas, ","))
|
||||
}
|
||||
slices.Sort(sets)
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(user))
|
||||
for _, set := range sets {
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(set))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
|
||||
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
|
||||
// success or other CAs are asked at once.
|
||||
func TestHelperBackoff(t *testing.T) {
|
||||
now := time.Now()
|
||||
b := newHelperBackoff()
|
||||
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
|
||||
|
||||
assert.True(t, b.allow(key, now), "a user never asked is asked")
|
||||
|
||||
b.record(key, false, now)
|
||||
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
|
||||
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
|
||||
|
||||
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
|
||||
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
|
||||
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
|
||||
|
||||
b.record(key, true, now)
|
||||
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
|
||||
}
|
||||
|
||||
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
|
||||
// the user is asked to prove, so they do not reset the quiet period.
|
||||
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
|
||||
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
|
||||
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
|
||||
assert.Equal(t, a, b, "the key does not depend on the nonce")
|
||||
}
|
||||
|
||||
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
|
||||
// challenges reordered, are the same question to the user.
|
||||
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
|
||||
a := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||
{CaCertificates: []string{"ca-1", "ca-2"}},
|
||||
{CaCertificates: []string{"ca-3"}},
|
||||
})
|
||||
b := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||
{CaCertificates: []string{"ca-3"}},
|
||||
{CaCertificates: []string{"ca-2", "ca-1"}},
|
||||
})
|
||||
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
|
||||
|
||||
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
|
||||
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
|
||||
}
|
||||
|
||||
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
|
||||
// next outcome is recorded, so the map does not grow with every user and CA set seen.
|
||||
func TestHelperBackoff_PrunesExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
b := newHelperBackoff()
|
||||
b.record("old", false, now)
|
||||
b.record("new", false, now.Add(helperQuietPeriod))
|
||||
|
||||
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
|
||||
assert.Contains(t, b.until, "new", "a current entry is kept")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package certproof
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// startHelper starts cmd.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
return func() {}, cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
|
||||
// group when cmd's context ends. The helper may run below launchers such as launchctl
|
||||
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
|
||||
// only the direct child would leave the helper running and Wait blocked until the
|
||||
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
|
||||
func killHelperGroupOnCancel(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.Cancel = func() error {
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.WaitDelay = helperWaitDelay
|
||||
}
|
||||
|
||||
// startHelper starts cmd. Killing the helper with everything below it is arranged by
|
||||
// killHelperGroupOnCancel where the helper runs under a launcher.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
return func() {}, cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
|
||||
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
|
||||
// only the helper on Windows leaves its descendants running, and one that holds the
|
||||
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
job, err := newKillOnCloseJob()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeJob := func() {
|
||||
if err := windows.CloseHandle(job); err != nil {
|
||||
log.Debugf("failed to close certificate proof helper job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Cancel = func() error {
|
||||
return windows.TerminateJobObject(job, 1)
|
||||
}
|
||||
cmd.WaitDelay = helperWaitDelay
|
||||
if err := cmd.Start(); err != nil {
|
||||
closeJob()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A helper outside the job is still killed directly and bounded by WaitDelay; only
|
||||
// its descendants would outlive it.
|
||||
if err := assignToJob(job, cmd.Process.Pid); err != nil {
|
||||
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
|
||||
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||
}
|
||||
return closeJob, nil
|
||||
}
|
||||
|
||||
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
|
||||
// is closed.
|
||||
func newKillOnCloseJob() (windows.Handle, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, fmt.Errorf("set job object limits: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// assignToJob puts the process pid into job.
|
||||
func assignToJob(job windows.Handle, pid int) error {
|
||||
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open process: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = windows.CloseHandle(process)
|
||||
}()
|
||||
return windows.AssignProcessToJobObject(job, process)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
|
||||
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
|
||||
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
|
||||
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
|
||||
|
||||
start := time.Now()
|
||||
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
const (
|
||||
maxHelperStdout = 1 << 20
|
||||
maxHelperStderr = 4 << 10
|
||||
|
||||
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
|
||||
helperWaitDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
|
||||
|
||||
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
|
||||
// The helper runs as an unprivileged user who can control its output, so both streams
|
||||
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
|
||||
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode helper request: %w", err)
|
||||
}
|
||||
|
||||
stdout := &cappedBuffer{limit: maxHelperStdout}
|
||||
stderr := &cappedBuffer{limit: maxHelperStderr}
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
release, err := startHelper(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start helper: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
logHelperStderr(stderr.String())
|
||||
if stdout.truncated {
|
||||
return nil, errHelperOutputTooLarge
|
||||
}
|
||||
|
||||
var resp HelperResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode helper response: %w", err)
|
||||
}
|
||||
return requestedProofs(req, resp.Proofs), nil
|
||||
}
|
||||
|
||||
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
|
||||
// certificate it could not sign with, which is otherwise only visible in the user's
|
||||
// session. Each line is quoted because the helper's user controls its content.
|
||||
func logHelperStderr(stderr string) {
|
||||
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
log.Debugf("certificate posture helper: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
|
||||
// most as many as req has challenges.
|
||||
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
|
||||
var kept []certposture.Proof
|
||||
for _, proof := range proofs {
|
||||
if len(kept) == len(req.Challenges) {
|
||||
break
|
||||
}
|
||||
if !req.asked(proof.Nonce) {
|
||||
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
|
||||
continue
|
||||
}
|
||||
kept = append(kept, proof)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func (r HelperRequest) asked(nonce []byte) bool {
|
||||
for _, challenge := range r.Challenges {
|
||||
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
|
||||
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
|
||||
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
|
||||
// which io.Copy prefers over Write, bypassing the cap.
|
||||
type cappedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
limit int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||
if room := b.limit - b.buf.Len(); room < len(p) {
|
||||
b.truncated = true
|
||||
if room > 0 {
|
||||
b.buf.Write(p[:room])
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Bytes() []byte {
|
||||
return b.buf.Bytes()
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) String() string {
|
||||
return b.buf.String()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build !windows && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
// fakeHelper is a child process that drains its stdin and then prints script's output,
|
||||
// standing in for a helper running in a user session the daemon cannot trust.
|
||||
func fakeHelper(t *testing.T, script string) *exec.Cmd {
|
||||
t.Helper()
|
||||
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
|
||||
}
|
||||
|
||||
func printJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
out, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return fmt.Sprintf("printf '%%s' '%s'", out)
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
|
||||
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
resp := HelperResponse{Proofs: []certposture.Proof{
|
||||
{Nonce: []byte("injected"), Signature: []byte("x")},
|
||||
{Nonce: []byte("asked"), Signature: []byte("first")},
|
||||
{Nonce: []byte("asked"), Signature: []byte("second")},
|
||||
}}
|
||||
|
||||
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
|
||||
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
assert.ErrorIs(t, err, errHelperOutputTooLarge)
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
|
||||
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
|
||||
}
|
||||
|
||||
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
|
||||
// for one about a certificate it could not sign with: the warning reaches the daemon's
|
||||
// log, quoted so a helper cannot forge log lines with embedded newlines.
|
||||
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
|
||||
var logged bytes.Buffer
|
||||
log.SetOutput(&logged)
|
||||
level := log.GetLevel()
|
||||
log.SetLevel(log.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetLevel(level)
|
||||
})
|
||||
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
|
||||
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
|
||||
}
|
||||
|
||||
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
|
||||
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
|
||||
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
|
||||
// grandchild.
|
||||
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
|
||||
killHelperGroupOnCancel(cmd)
|
||||
|
||||
start := time.Now()
|
||||
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
|
||||
|
||||
assert.ErrorContains(t, err, "decode helper response")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build (darwin && !ios) || windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest {
|
||||
req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))}
|
||||
for _, challenge := range challenges {
|
||||
req.Challenges = append(req.Challenges, HelperChallenge{
|
||||
Nonce: challenge.GetNonce(),
|
||||
CACertificates: challenge.GetCaCertificates(),
|
||||
})
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that
|
||||
// both stores hold so the same certificate is proven once.
|
||||
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
|
||||
if len(user) == 0 {
|
||||
return device
|
||||
}
|
||||
|
||||
seen := make(map[[sha256.Size]byte]struct{}, len(device))
|
||||
for _, proof := range device {
|
||||
if len(proof.Chain) > 0 {
|
||||
seen[sha256.Sum256(proof.Chain[0])] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
merged := device
|
||||
for _, proof := range user {
|
||||
if len(proof.Chain) == 0 {
|
||||
continue
|
||||
}
|
||||
fingerprint := sha256.Sum256(proof.Chain[0])
|
||||
if _, done := seen[fingerprint]; done {
|
||||
continue
|
||||
}
|
||||
seen[fingerprint] = struct{}{}
|
||||
merged = append(merged, proof)
|
||||
logUserProof(proof)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func logUserProof(proof certposture.Proof) {
|
||||
leaf, err := x509.ParseCertificate(proof.Chain[0])
|
||||
if err != nil {
|
||||
log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err)
|
||||
return
|
||||
}
|
||||
log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build (darwin && !ios) || windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
shared := ca.Issue(t, certtest.ECDSAKey(t), "shared")
|
||||
userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only")
|
||||
|
||||
device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}}
|
||||
user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}}
|
||||
|
||||
merged := mergeProofs(device, user)
|
||||
|
||||
require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped")
|
||||
assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place")
|
||||
assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended")
|
||||
}
|
||||
|
||||
func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}}
|
||||
|
||||
merged := mergeProofs(device, nil)
|
||||
|
||||
require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof")
|
||||
assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged")
|
||||
}
|
||||
|
||||
func TestHelperRequest_CarriesEveryChallenge(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
challenges := []*proto.CertificateChallenge{
|
||||
{Nonce: []byte("first"), CaCertificates: []string{ca.PEM}},
|
||||
{Nonce: []byte("second")},
|
||||
}
|
||||
|
||||
req := helperRequest(challenges, peerKey)
|
||||
|
||||
require.Len(t, req.Challenges, 2, "every challenge must reach the helper")
|
||||
assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine")
|
||||
assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged")
|
||||
assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged")
|
||||
assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
)
|
||||
|
||||
func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
dir := storeDir(t)
|
||||
key := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
nonce := challenger.Nonce(peerKey, time.Now())
|
||||
request, err := json.Marshal(HelperRequest{
|
||||
PeerKey: peerKey,
|
||||
Challenges: []HelperChallenge{{Nonce: nonce, CACertificates: []string{ca.PEM}}},
|
||||
})
|
||||
require.NoError(t, err, "request must encode")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout))
|
||||
|
||||
var resp HelperResponse
|
||||
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON")
|
||||
require.Len(t, resp.Proofs, 1, "the matching certificate should produce one proof")
|
||||
|
||||
// Verify exactly as management does, so the proof is proven to survive the encode,
|
||||
// the process boundary and the decode intact.
|
||||
chain, err := challenger.Verify(resp.Proofs[0], peerKey, time.Now())
|
||||
require.NoError(t, err, "the decoded proof must verify against the issued nonce")
|
||||
assert.Equal(t, "device", chain[0].Subject.CommonName, "the proven leaf should be the device certificate")
|
||||
}
|
||||
|
||||
func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
|
||||
request, err := json.Marshal(HelperRequest{PeerKey: peerKey})
|
||||
require.NoError(t, err)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
require.NoError(t, runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader(request), &stdout))
|
||||
|
||||
var resp HelperResponse
|
||||
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
|
||||
assert.Empty(t, resp.Proofs, "no challenges should produce no proofs")
|
||||
}
|
||||
|
||||
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
err := runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout)
|
||||
|
||||
require.Error(t, err, "a malformed request must fail rather than emit an empty proof set")
|
||||
assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure")
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||
|
||||
errSecItemNotFound = -25300
|
||||
errSecInteractionNotAllowed = -25308
|
||||
)
|
||||
|
||||
// errKeyNeedsApproval reports a key whose access list does not include netbird, so using
|
||||
// it needs the user's approval, which a daemon has no UI to ask for.
|
||||
var errKeyNeedsApproval = errors.New("the key's access control requires user approval for netbird; " +
|
||||
"import the identity with netbird allowed (security import <identity.p12> -k <keychain> " +
|
||||
"-T /Applications/NetBird.app/Contents/MacOS/netbird), " +
|
||||
"or set AllowAllAppsAccess in the MDM certificate payload, which allows every application")
|
||||
|
||||
var (
|
||||
keychainOnce sync.Once
|
||||
keychainErr error
|
||||
|
||||
secItemCopyMatching func(query uintptr, result *uintptr) int32
|
||||
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
|
||||
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
|
||||
secCertificateCopyData func(cert uintptr) uintptr
|
||||
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
|
||||
|
||||
secKeychainOpen func(path *byte, keychain *uintptr) int32
|
||||
secKeychainCopySearchList func(searchList *uintptr) int32
|
||||
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
|
||||
cfArrayCreate func(alloc uintptr, values *uintptr, count int, callBacks uintptr) uintptr
|
||||
|
||||
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
|
||||
cfArrayGetCount func(array uintptr) int
|
||||
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
|
||||
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
|
||||
cfDataGetLength func(data uintptr) int
|
||||
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
|
||||
cfErrorGetCode func(err uintptr) int
|
||||
cfRelease func(ref uintptr)
|
||||
|
||||
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
|
||||
kSecMatchSearchList uintptr
|
||||
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
|
||||
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, kCFTypeArrayCallBacks uintptr
|
||||
)
|
||||
|
||||
// systemKeychain is where MDM installs device identities.
|
||||
const systemKeychain = "/Library/Keychains/System.keychain"
|
||||
|
||||
// DefaultStore is the System keychain for the root daemon, so it never touches a user's
|
||||
// keychain, whose keys would ask for approval in a session the daemon has no UI in. Run
|
||||
// by an ordinary user, it is that user's keychain search list.
|
||||
func DefaultStore() Store {
|
||||
if os.Geteuid() == 0 {
|
||||
return NewKeychainStore(systemKeychain)
|
||||
}
|
||||
return NewKeychainStore()
|
||||
}
|
||||
|
||||
// KeychainStore yields the identities of a set of keychains, or of the process's keychain
|
||||
// search list when none is named, reached through purego so the client keeps building
|
||||
// with CGO_ENABLED=0.
|
||||
type KeychainStore struct {
|
||||
keychains []string
|
||||
}
|
||||
|
||||
// NewKeychainStore returns a store that searches the keychain files at paths, or the
|
||||
// process's keychain search list when no path is given.
|
||||
func NewKeychainStore(paths ...string) *KeychainStore {
|
||||
return &KeychainStore{keychains: paths}
|
||||
}
|
||||
|
||||
func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
if err := loadKeychain(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchList, done, err := searchListOf(s.keychains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
|
||||
var leaves []*x509.Certificate
|
||||
err = eachIdentity(searchList, func(_ uintptr, der []byte) (bool, error) {
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
log.Warnf("skipping keychain identity: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
log.Debugf("keychain identity: subject=%q issuer=%q serial=%s expires=%s", cert.Subject, cert.Issuer, cert.SerialNumber, cert.NotAfter)
|
||||
leaves = append(leaves, cert)
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The certificate query runs even without identities: it separates a keychain that is
|
||||
// readable but holds no identity from one the process cannot read at all.
|
||||
pool, err := keychainCertificates(searchList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(leaves) == 0 {
|
||||
log.Debugf("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
|
||||
return nil, nil
|
||||
}
|
||||
log.Debugf("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
|
||||
|
||||
candidates := make([]Candidate, 0, len(leaves))
|
||||
for _, leaf := range leaves {
|
||||
chain := buildChain(leaf, pool)
|
||||
log.Debugf("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
|
||||
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
|
||||
log.Debugf("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
|
||||
}
|
||||
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf, keychains: s.keychains}, Intermediates: pool})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// keychainSigner holds only the certificate; the identity is looked up again at signing
|
||||
// time so no keychain references outlive a call.
|
||||
type keychainSigner struct {
|
||||
leaf *x509.Certificate
|
||||
keychains []string
|
||||
}
|
||||
|
||||
func (s *keychainSigner) Public() crypto.PublicKey {
|
||||
return s.leaf.PublicKey
|
||||
}
|
||||
|
||||
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debugf("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
|
||||
|
||||
searchList, done, err := searchListOf(s.keychains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
|
||||
algorithm := keychainAlgorithm(scheme)
|
||||
var signature []byte
|
||||
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
|
||||
if !bytes.Equal(der, s.leaf.Raw) {
|
||||
return false, nil
|
||||
}
|
||||
signature, err = signWithIdentity(identity, algorithm, digest)
|
||||
return true, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if signature == nil {
|
||||
return nil, errors.New("certificate is no longer in the keychain")
|
||||
}
|
||||
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func keychainAlgorithm(scheme sigScheme) uintptr {
|
||||
switch scheme {
|
||||
case schemeECDSASHA384:
|
||||
return kSecKeyAlgorithmECDSASHA384
|
||||
case schemeRSAPSSSHA256:
|
||||
return kSecKeyAlgorithmRSAPSSSHA256
|
||||
default:
|
||||
return kSecKeyAlgorithmECDSASHA256
|
||||
}
|
||||
}
|
||||
|
||||
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
|
||||
var key uintptr
|
||||
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
|
||||
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
|
||||
}
|
||||
defer release(key)
|
||||
|
||||
data := cfDataCreate(0, &digest[0], len(digest))
|
||||
if data == 0 {
|
||||
return nil, errors.New("CFDataCreate returned NULL")
|
||||
}
|
||||
defer release(data)
|
||||
|
||||
var cfErr uintptr
|
||||
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
|
||||
if signature == 0 {
|
||||
if cfErr == 0 {
|
||||
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
|
||||
}
|
||||
defer release(cfErr)
|
||||
code := cfErrorGetCode(cfErr)
|
||||
if code == errSecInteractionNotAllowed {
|
||||
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
|
||||
}
|
||||
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
|
||||
}
|
||||
defer release(signature)
|
||||
return dataBytes(signature), nil
|
||||
}
|
||||
|
||||
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
|
||||
// or yields 0, the process's own search list, when paths is empty. The caller calls done
|
||||
// once it no longer uses the list.
|
||||
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
|
||||
if len(paths) == 0 {
|
||||
return 0, func() {}, nil
|
||||
}
|
||||
list, err := openSearchList(paths)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return list, func() { release(list) }, nil
|
||||
}
|
||||
|
||||
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
|
||||
// caller releases the array.
|
||||
func openSearchList(paths []string) (uintptr, error) {
|
||||
refs := make([]uintptr, 0, len(paths))
|
||||
defer func() {
|
||||
for _, ref := range refs {
|
||||
release(ref)
|
||||
}
|
||||
}()
|
||||
for _, path := range paths {
|
||||
cpath := append([]byte(path), 0)
|
||||
var keychain uintptr
|
||||
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
|
||||
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
|
||||
}
|
||||
refs = append(refs, keychain)
|
||||
}
|
||||
// The array retains the keychains, so the references opened here are released.
|
||||
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
|
||||
if list == 0 {
|
||||
return 0, errors.New("CFArrayCreate returned NULL")
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// eachIdentity calls fn with every identity in searchList, or in the process's search
|
||||
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
|
||||
// skipped rather than ending the walk.
|
||||
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
|
||||
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
|
||||
var cert uintptr
|
||||
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
|
||||
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
|
||||
return false, nil
|
||||
}
|
||||
der := certificateDER(cert)
|
||||
release(cert)
|
||||
if der == nil {
|
||||
log.Debug("skipping keychain identity whose certificate has no DER data")
|
||||
return false, nil
|
||||
}
|
||||
return fn(identity, der)
|
||||
})
|
||||
}
|
||||
|
||||
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
var unparsable int
|
||||
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
|
||||
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
|
||||
certs = append(certs, cert)
|
||||
return false, nil
|
||||
}
|
||||
unparsable++
|
||||
return false, nil
|
||||
})
|
||||
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
|
||||
return certs, err
|
||||
}
|
||||
|
||||
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
|
||||
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
|
||||
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
|
||||
if searchList != 0 {
|
||||
keys = append(keys, kSecMatchSearchList)
|
||||
values = append(values, searchList)
|
||||
}
|
||||
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
|
||||
defer release(query)
|
||||
|
||||
var items uintptr
|
||||
switch status := secItemCopyMatching(query, &items); status {
|
||||
case 0:
|
||||
case errSecItemNotFound:
|
||||
log.Debugf("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
|
||||
return nil
|
||||
default:
|
||||
log.Debugf("keychain %s query returned OSStatus %d", name, status)
|
||||
return fmt.Errorf("SecItemCopyMatching: %d", status)
|
||||
}
|
||||
defer release(items)
|
||||
|
||||
n := cfArrayGetCount(items)
|
||||
log.Debugf("keychain %s query returned %d items", name, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
|
||||
// returns NULL, which it does for an object that is not a valid certificate.
|
||||
func certificateDER(cert uintptr) []byte {
|
||||
data := secCertificateCopyData(cert)
|
||||
if data == 0 {
|
||||
return nil
|
||||
}
|
||||
defer release(data)
|
||||
return dataBytes(data)
|
||||
}
|
||||
|
||||
func dataBytes(data uintptr) []byte {
|
||||
n := cfDataGetLength(data)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
|
||||
}
|
||||
|
||||
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
|
||||
// several Security calls return NULL on failure, so every release goes through here.
|
||||
func release(ref uintptr) {
|
||||
if ref != 0 {
|
||||
cfRelease(ref)
|
||||
}
|
||||
}
|
||||
|
||||
func loadKeychain() error {
|
||||
keychainOnce.Do(func() {
|
||||
if keychainErr = resolveKeychain(); keychainErr != nil {
|
||||
log.Debugf("macOS keychain unavailable for certificate posture: %v", keychainErr)
|
||||
return
|
||||
}
|
||||
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
|
||||
logSearchList()
|
||||
})
|
||||
return keychainErr
|
||||
}
|
||||
|
||||
// logSearchList reports the keychains the process searches. The root daemon sees the
|
||||
// System keychain and System Roots, never a user's login keychain.
|
||||
func logSearchList() {
|
||||
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
|
||||
log.Debug("keychain search list diagnostics unavailable on this macOS version")
|
||||
return
|
||||
}
|
||||
|
||||
var list uintptr
|
||||
if status := secKeychainCopySearchList(&list); status != 0 {
|
||||
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
|
||||
return
|
||||
}
|
||||
defer release(list)
|
||||
|
||||
n := cfArrayGetCount(list)
|
||||
log.Debugf("keychain search list contains %d keychains", n)
|
||||
for i := 0; i < n; i++ {
|
||||
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
|
||||
}
|
||||
}
|
||||
|
||||
func keychainPath(keychain uintptr) string {
|
||||
path := make([]byte, 1024)
|
||||
length := uint32(len(path))
|
||||
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
|
||||
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
|
||||
}
|
||||
return string(path[:length])
|
||||
}
|
||||
|
||||
func resolveKeychain() error {
|
||||
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||
}
|
||||
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
||||
}
|
||||
|
||||
for _, fn := range []struct {
|
||||
ptr any
|
||||
lib uintptr
|
||||
name string
|
||||
}{
|
||||
{&secItemCopyMatching, security, "SecItemCopyMatching"},
|
||||
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
|
||||
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
|
||||
{&secCertificateCopyData, security, "SecCertificateCopyData"},
|
||||
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
|
||||
{&secKeychainOpen, security, "SecKeychainOpen"},
|
||||
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
|
||||
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
|
||||
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
|
||||
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
|
||||
{&cfDataCreate, coreFoundation, "CFDataCreate"},
|
||||
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
|
||||
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
|
||||
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
|
||||
{&cfRelease, coreFoundation, "CFRelease"},
|
||||
} {
|
||||
symbol, err := purego.Dlsym(fn.lib, fn.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||
}
|
||||
purego.RegisterFunc(fn.ptr, symbol)
|
||||
}
|
||||
|
||||
for _, global := range []struct {
|
||||
ptr *uintptr
|
||||
lib uintptr
|
||||
name string
|
||||
deref bool
|
||||
}{
|
||||
{&kSecClass, security, "kSecClass", true},
|
||||
{&kSecClassIdentity, security, "kSecClassIdentity", true},
|
||||
{&kSecClassCertificate, security, "kSecClassCertificate", true},
|
||||
{&kSecMatchLimit, security, "kSecMatchLimit", true},
|
||||
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
|
||||
{&kSecReturnRef, security, "kSecReturnRef", true},
|
||||
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
|
||||
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
|
||||
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
|
||||
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
|
||||
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
|
||||
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
|
||||
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
|
||||
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
|
||||
} {
|
||||
addr, err := purego.Dlsym(global.lib, global.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", global.name, err)
|
||||
}
|
||||
if global.deref {
|
||||
addr = **(**uintptr)(unsafe.Pointer(&addr))
|
||||
}
|
||||
*global.ptr = addr
|
||||
}
|
||||
|
||||
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
|
||||
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
|
||||
// longer exports it so keychain lookups keep working without it.
|
||||
func resolveOptional(lib uintptr, name string, ptr any) {
|
||||
symbol, err := purego.Dlsym(lib, name)
|
||||
if err != nil {
|
||||
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
|
||||
return
|
||||
}
|
||||
purego.RegisterFunc(ptr, symbol)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
|
||||
|
||||
type sigScheme int
|
||||
|
||||
const (
|
||||
schemeECDSASHA256 sigScheme = iota + 1
|
||||
schemeECDSASHA384
|
||||
schemeRSAPSSSHA256
|
||||
)
|
||||
|
||||
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
|
||||
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
|
||||
switch pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
switch opts.HashFunc() {
|
||||
case crypto.SHA256:
|
||||
return schemeECDSASHA256, nil
|
||||
case crypto.SHA384:
|
||||
return schemeECDSASHA384, nil
|
||||
}
|
||||
case *rsa.PublicKey:
|
||||
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
|
||||
return schemeRSAPSSSHA256, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
|
||||
}
|
||||
|
||||
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
|
||||
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
|
||||
chain := []*x509.Certificate{leaf}
|
||||
current := leaf
|
||||
for current.CheckSignatureFrom(current) != nil {
|
||||
issuer := issuerIn(current, pool, chain)
|
||||
if issuer == nil {
|
||||
break
|
||||
}
|
||||
chain = append(chain, issuer)
|
||||
current = issuer
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
|
||||
for _, candidate := range pool {
|
||||
if slices.ContainsFunc(seen, candidate.Equal) {
|
||||
continue
|
||||
}
|
||||
if cert.CheckSignatureFrom(candidate) == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
|
||||
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
|
||||
if len(raw) == 0 || len(raw)%2 != 0 {
|
||||
return nil, errors.New("malformed raw ECDSA signature")
|
||||
}
|
||||
half := len(raw) / 2
|
||||
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
)
|
||||
|
||||
func TestBuildChain_FollowsIssuersThroughThePool(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||
unrelated := certtest.NewCA(t, "unrelated")
|
||||
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
|
||||
pool := []*x509.Certificate{unrelated.Cert, root.Cert, leaf, intermediate.Cert}
|
||||
|
||||
chain := buildChain(leaf, pool)
|
||||
|
||||
require.Equal(t, []*x509.Certificate{leaf, intermediate.Cert, root.Cert}, chain)
|
||||
roots, err := certposture.ParseCAs([]string{root.PEM})
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, certposture.VerifyChain(chain, roots, time.Now()))
|
||||
}
|
||||
|
||||
func TestBuildChain_StopsWhereThePoolEnds(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
|
||||
|
||||
assert.Equal(t, []*x509.Certificate{leaf}, buildChain(leaf, nil))
|
||||
assert.Equal(t, []*x509.Certificate{leaf, intermediate.Cert}, buildChain(leaf, []*x509.Certificate{intermediate.Cert}))
|
||||
}
|
||||
|
||||
func TestSchemeFor(t *testing.T) {
|
||||
ecKey := certtest.ECDSAKey(t)
|
||||
rsaKey := certtest.RSAKey(t)
|
||||
pss := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pub crypto.PublicKey
|
||||
opts crypto.SignerOpts
|
||||
want sigScheme
|
||||
}{
|
||||
{"ecdsa sha256", ecKey.Public(), crypto.SHA256, schemeECDSASHA256},
|
||||
{"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384},
|
||||
{"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256},
|
||||
{"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0},
|
||||
{"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := schemeFor(tc.pub, tc.opts)
|
||||
if tc.want == 0 {
|
||||
assert.ErrorIs(t, err, errUnsupportedScheme)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestECDSASignatureASN1(t *testing.T) {
|
||||
key := certtest.ECDSAKey(t).(*ecdsa.PrivateKey)
|
||||
digest := sha256.Sum256([]byte("nonce"))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
|
||||
require.NoError(t, err)
|
||||
raw := append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
|
||||
|
||||
der, err := ecdsaSignatureASN1(raw)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der))
|
||||
|
||||
_, err = ecdsaSignatureASN1(raw[:63])
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// errPINRejectedBefore is returned instead of logging in with a PIN the token already
|
||||
// refused: every failed login counts towards the token's lockout, which for a TPM is
|
||||
// shared with everything else on the machine, and proofs are collected on every sync.
|
||||
var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again")
|
||||
|
||||
// errPINNeedsToken refuses a PIN that names no token to log in to.
|
||||
var errPINNeedsToken = errors.New("a PKCS#11 PIN needs the token named in NB_CERT_PKCS11_URI, as token=<label>")
|
||||
|
||||
// rejectedPINs outlives a single store, since a store is built for each collection.
|
||||
var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}}
|
||||
|
||||
// pinLoginMu serializes logging in to a PKCS#11 token with a PIN, from checking the latch
|
||||
// to recording a rejection.
|
||||
var pinLoginMu sync.Mutex
|
||||
|
||||
// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not
|
||||
// kept in memory any longer than the store that read it.
|
||||
type pinLatch struct {
|
||||
mu sync.Mutex
|
||||
keys map[[sha256.Size]byte]struct{}
|
||||
}
|
||||
|
||||
func (l *pinLatch) has(key [sha256.Size]byte) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
_, ok := l.keys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (l *pinLatch) add(key [sha256.Size]byte) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.keys[key] = struct{}{}
|
||||
}
|
||||
|
||||
// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token
|
||||
// rejected is still tried on the token it belongs to.
|
||||
func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte {
|
||||
var buf []byte
|
||||
for _, part := range [][]byte{[]byte(module), []byte(token), pin} {
|
||||
buf = binary.BigEndian.AppendUint64(buf, uint64(len(part)))
|
||||
buf = append(buf, part...)
|
||||
}
|
||||
return sha256.Sum256(buf)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) {
|
||||
base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
|
||||
|
||||
assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another")
|
||||
assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")),
|
||||
"field boundaries are part of the key, so shifted fields do not collide")
|
||||
}
|
||||
|
||||
func TestPINLatch_RemembersRejectedKeys(t *testing.T) {
|
||||
latch := &pinLatch{keys: map[[32]byte]struct{}{}}
|
||||
rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000"))
|
||||
other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
|
||||
|
||||
assert.False(t, latch.has(rejected), "nothing is rejected before a login fails")
|
||||
latch.add(rejected)
|
||||
assert.True(t, latch.has(rejected), "a rejected PIN is not tried again")
|
||||
assert.False(t, latch.has(other), "other PINs are unaffected")
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
)
|
||||
|
||||
const (
|
||||
// PINEnv carries the user PIN of the PKCS#11 token. It is read from the daemon's
|
||||
// environment only, so the PIN never lands in the profile config or on a command line.
|
||||
PINEnv = "NB_TPM_PIN"
|
||||
// PKCS11URIEnv carries the RFC 7512 URI of the token. Like the PIN it lives in the
|
||||
// daemon's environment rather than the profile config, since it may carry pin-value.
|
||||
PKCS11URIEnv = "NB_CERT_PKCS11_URI"
|
||||
)
|
||||
|
||||
// PKCS11Config names the token whose certificates the store yields. URI is an RFC 7512
|
||||
// PKCS#11 URI, or empty for the first token the p11-kit proxy exposes. PIN is the user
|
||||
// PIN, and takes precedence over a pin-value or pin-source the URI carries.
|
||||
type PKCS11Config struct {
|
||||
URI string
|
||||
PIN string
|
||||
}
|
||||
|
||||
// PKCS11FromEnv returns the token named by NB_CERT_PKCS11_URI with the PIN set in
|
||||
// NB_TPM_PIN, either empty when unset.
|
||||
func PKCS11FromEnv() PKCS11Config {
|
||||
return PKCS11Config{URI: os.Getenv(PKCS11URIEnv), PIN: os.Getenv(PINEnv)}
|
||||
}
|
||||
|
||||
// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes
|
||||
// TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the
|
||||
// convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM
|
||||
// directory by public key. Every signature happens on the token.
|
||||
type PKCS11Store struct {
|
||||
uri *pkcs11.URI
|
||||
pin string
|
||||
certDir string
|
||||
}
|
||||
|
||||
// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in
|
||||
// certDir without a key of their own are paired with the token's keys by public key.
|
||||
//
|
||||
// A PIN is only accepted together with a token label: without one the PIN would go to
|
||||
// whichever token the module lists first, which on a machine with a smartcard plugged
|
||||
// in may be the card, and every wrong PIN counts towards that card's lockout.
|
||||
func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) {
|
||||
store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir}
|
||||
if cfg.URI != "" {
|
||||
parsed, err := pkcs11.ParseURI(cfg.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store.uri = parsed
|
||||
}
|
||||
if (cfg.PIN != "" || store.uri.HasPIN()) && store.uri.Token == "" {
|
||||
return nil, errPINNeedsToken
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
session, err := s.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
certs, err := tokenCertificates(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileChains, err := s.fileChains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debugf("%s holds %d certificates, %d certificate files without a key wait for its keys", s, len(certs), len(fileChains))
|
||||
|
||||
pool := make([]*x509.Certificate, 0, len(certs))
|
||||
for _, cert := range certs {
|
||||
pool = append(pool, cert.cert)
|
||||
}
|
||||
for _, chain := range fileChains {
|
||||
pool = append(pool, chain...)
|
||||
}
|
||||
|
||||
var candidates []Candidate
|
||||
for _, cert := range certs {
|
||||
if _, err := privateKey(session, cert.id); err != nil {
|
||||
log.Debugf("%s certificate %q has no usable private key: %v", s, cert.cert.Subject, err)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, s.candidate(cert.cert, cert.id, pool))
|
||||
}
|
||||
if len(fileChains) == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
keys, err := tokenPublicKeys(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, chain := range fileChains {
|
||||
leaf := chain[0]
|
||||
id, ok := keys.idFor(leaf.PublicKey)
|
||||
if !ok {
|
||||
log.Debugf("%s holds no key for certificate %q from %s", s, leaf.Subject, s.certDir)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, s.candidate(leaf, id, pool))
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate {
|
||||
chain := buildChain(leaf, pool)
|
||||
log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
|
||||
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool}
|
||||
}
|
||||
|
||||
// fileChains reads the certificate files in the PEM directory that carry no key of their
|
||||
// own; the file store answers for the ones that do.
|
||||
func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) {
|
||||
if s.certDir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
paths, err := certFiles(s.certDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chains [][]*x509.Certificate
|
||||
for _, path := range paths {
|
||||
chain, signer, err := loadPEM(path)
|
||||
if err != nil || signer != nil {
|
||||
continue
|
||||
}
|
||||
chains = append(chains, chain)
|
||||
}
|
||||
return chains, nil
|
||||
}
|
||||
|
||||
func (s *PKCS11Store) String() string {
|
||||
if s.uri.Token == "" {
|
||||
return "PKCS#11 token"
|
||||
}
|
||||
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
|
||||
}
|
||||
|
||||
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
|
||||
module, err := pkcs11.Load(s.uri.Module())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pin, err := s.userPIN()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pin == nil {
|
||||
return module.OpenSession(s.uri.Token, nil)
|
||||
}
|
||||
|
||||
// The check, the login and recording a rejection happen under one lock, so two
|
||||
// collections running at once cannot both send a PIN the token is about to refuse.
|
||||
pinLoginMu.Lock()
|
||||
defer pinLoginMu.Unlock()
|
||||
key := rejectedPINKey(s.uri.Module(), s.uri.Token, pin)
|
||||
if rejectedPINs.has(key) {
|
||||
return nil, errPINRejectedBefore
|
||||
}
|
||||
session, err := module.OpenSession(s.uri.Token, pin)
|
||||
if pkcs11.PINRejected(err) {
|
||||
rejectedPINs.add(key)
|
||||
return nil, fmt.Errorf("%s rejected the PIN, not retrying it until the daemon restarts: %w", s, err)
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (s *PKCS11Store) userPIN() ([]byte, error) {
|
||||
if s.pin != "" {
|
||||
return []byte(s.pin), nil
|
||||
}
|
||||
return s.uri.PIN()
|
||||
}
|
||||
|
||||
type tokenKey struct {
|
||||
id []byte
|
||||
public crypto.PublicKey
|
||||
}
|
||||
|
||||
type tokenKeys []tokenKey
|
||||
|
||||
func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) {
|
||||
objects, err := session.FindObjects(pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPublicKey)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make(tokenKeys, 0, len(objects))
|
||||
for _, object := range objects {
|
||||
id, err := session.Attribute(object, pkcs11.AttrID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public, err := session.PublicKey(object)
|
||||
if err != nil {
|
||||
log.Debugf("skipping public key on PKCS#11 token: %v", err)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, tokenKey{id: id, public: public})
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// idFor finds the token key whose public half is pub, so a certificate kept outside the
|
||||
// token is still signed for by the key inside it.
|
||||
func (k tokenKeys) idFor(pub crypto.PublicKey) ([]byte, bool) {
|
||||
for _, key := range k {
|
||||
equaler, ok := key.public.(interface{ Equal(crypto.PublicKey) bool })
|
||||
if ok && len(key.id) > 0 && equaler.Equal(pub) {
|
||||
return key.id, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type tokenCertificate struct {
|
||||
cert *x509.Certificate
|
||||
id []byte
|
||||
}
|
||||
|
||||
func tokenCertificates(session *pkcs11.Session) ([]tokenCertificate, error) {
|
||||
objects, err := session.FindObjects(
|
||||
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassCertificate)},
|
||||
pkcs11.Attribute{Type: pkcs11.AttrCertificateType, Value: pkcs11.ULong(pkcs11.CertificateX509)},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs := make([]tokenCertificate, 0, len(objects))
|
||||
for _, object := range objects {
|
||||
der, err := session.Attribute(object, pkcs11.AttrValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
log.Warnf("skipping unparsable certificate on PKCS#11 token: %v", err)
|
||||
continue
|
||||
}
|
||||
id, err := session.Attribute(object, pkcs11.AttrID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, tokenCertificate{cert: cert, id: id})
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
var errNoPrivateKey = errors.New("no private key shares the certificate's CKA_ID")
|
||||
|
||||
func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) {
|
||||
if len(id) == 0 {
|
||||
return 0, errNoPrivateKey
|
||||
}
|
||||
keys, err := session.FindObjects(
|
||||
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPrivateKey)},
|
||||
pkcs11.Attribute{Type: pkcs11.AttrID, Value: id},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return 0, errNoPrivateKey
|
||||
}
|
||||
return keys[0], nil
|
||||
}
|
||||
|
||||
// pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh
|
||||
// session at signing time so no token handle outlives a call.
|
||||
type pkcs11Signer struct {
|
||||
store *PKCS11Store
|
||||
leaf *x509.Certificate
|
||||
id []byte
|
||||
}
|
||||
|
||||
func (s *pkcs11Signer) Public() crypto.PublicKey {
|
||||
return s.leaf.PublicKey
|
||||
}
|
||||
|
||||
func (s *pkcs11Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.store.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
key, err := privateKey(session, s.id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature, err := session.Sign(pkcs11Mechanism(scheme), key, digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
return signature, nil
|
||||
}
|
||||
return ecdsaSignatureASN1(signature)
|
||||
}
|
||||
|
||||
// pkcs11Mechanism maps a signature scheme onto the token mechanism that consumes a digest.
|
||||
func pkcs11Mechanism(scheme sigScheme) pkcs11.Mechanism {
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
return pkcs11.Mechanism{
|
||||
Type: pkcs11.MechRSAPKCSPSS,
|
||||
PSS: &pkcs11.PSSParams{Hash: pkcs11.MechSHA256, MGF: pkcs11.MGF1SHA256, SaltLen: sha256.Size},
|
||||
}
|
||||
}
|
||||
return pkcs11.Mechanism{Type: pkcs11.MechECDSA}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
|
||||
// testPKCS11DisposableEnv marks the token as disposable, allowing tests that spend
|
||||
// attempts of its PIN lockout counter.
|
||||
testPKCS11DisposableEnv = "NB_TEST_PKCS11_DISPOSABLE"
|
||||
)
|
||||
|
||||
type failingStore struct{}
|
||||
|
||||
func (failingStore) Candidates(context.Context) ([]Candidate, error) {
|
||||
return nil, errors.New("token unplugged")
|
||||
}
|
||||
|
||||
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
key := certtest.ECDSAKey(t)
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
|
||||
candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token")
|
||||
}
|
||||
|
||||
// TestCollect_PKCS11TokenEndToEnd needs an initialised token with a user PIN, named by
|
||||
// NB_TEST_PKCS11_URI. With SoftHSM:
|
||||
//
|
||||
// softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
|
||||
// NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
|
||||
// go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
|
||||
//
|
||||
// It imports a key and its certificate as token objects, then proves the certificate
|
||||
// through the store the way the daemon would. Every run adds one more identity to the token.
|
||||
func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
|
||||
store, uri := pkcs11TestStore(t, "")
|
||||
|
||||
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
|
||||
for name, key := range keys {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-"+name)
|
||||
leaf := ca.Issue(t, key, "device-"+name)
|
||||
importIdentity(t, uri, key, leaf)
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
|
||||
proofs := Collect(context.Background(), store, checks, peerKey)
|
||||
require.Len(t, proofs, 1, "the token-held key must prove exactly this run's certificate")
|
||||
chain, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, leaf.Equal(chain[0]), "proof must carry the imported certificate")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Attribute types the import needs and the store does not.
|
||||
const (
|
||||
attrPrivate = 0x2
|
||||
attrIssuer = 0x81
|
||||
attrSerialNumber = 0x82
|
||||
attrSensitive = 0x103
|
||||
attrSign = 0x108
|
||||
attrVerify = 0x10a
|
||||
attrPrivateExponent = 0x123
|
||||
attrPrime1 = 0x124
|
||||
attrPrime2 = 0x125
|
||||
attrExponent1 = 0x126
|
||||
attrExponent2 = 0x127
|
||||
attrCoefficient = 0x128
|
||||
)
|
||||
|
||||
var (
|
||||
ckTrue = []byte{1}
|
||||
ckFalse = []byte{0}
|
||||
// The P-256 named curve OID in DER, which is what CKA_EC_PARAMS carries.
|
||||
oidP256 = []byte{0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}
|
||||
)
|
||||
|
||||
// importIdentity stores key and leaf on the token the way tpm2_ptool import and addcert
|
||||
// do: private and public key objects plus the certificate, all under one CKA_ID.
|
||||
func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Certificate) {
|
||||
t.Helper()
|
||||
id := importKey(t, uri, key, leaf.Subject.CommonName)
|
||||
importCertificate(t, uri, leaf, id)
|
||||
}
|
||||
|
||||
func importKey(t *testing.T, uri string, key crypto.Signer, label string) []byte {
|
||||
t.Helper()
|
||||
session := readWriteSession(t, uri)
|
||||
defer session.Close()
|
||||
|
||||
id := make([]byte, 8)
|
||||
_, err := rand.Read(id)
|
||||
require.NoError(t, err)
|
||||
|
||||
private := []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPrivateKey)),
|
||||
attr(pkcs11.AttrToken, ckTrue),
|
||||
attr(attrPrivate, ckTrue),
|
||||
attr(attrSensitive, ckTrue),
|
||||
attr(attrSign, ckTrue),
|
||||
attr(pkcs11.AttrLabel, []byte(label)),
|
||||
attr(pkcs11.AttrID, id),
|
||||
}
|
||||
_, err = session.CreateObject(append(private, privateKeyAttributes(t, key)...)...)
|
||||
require.NoError(t, err, "import private key")
|
||||
|
||||
public := []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPublicKey)),
|
||||
attr(pkcs11.AttrToken, ckTrue),
|
||||
attr(attrPrivate, ckFalse),
|
||||
attr(attrVerify, ckTrue),
|
||||
attr(pkcs11.AttrLabel, []byte(label)),
|
||||
attr(pkcs11.AttrID, id),
|
||||
}
|
||||
_, err = session.CreateObject(append(public, publicKeyAttributes(t, key)...)...)
|
||||
require.NoError(t, err, "import public key")
|
||||
return id
|
||||
}
|
||||
|
||||
func importCertificate(t *testing.T, uri string, leaf *x509.Certificate, id []byte) {
|
||||
t.Helper()
|
||||
session := readWriteSession(t, uri)
|
||||
defer session.Close()
|
||||
|
||||
serial, err := asn1.Marshal(leaf.SerialNumber)
|
||||
require.NoError(t, err)
|
||||
_, err = session.CreateObject(
|
||||
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassCertificate)),
|
||||
attr(pkcs11.AttrCertificateType, pkcs11.ULong(pkcs11.CertificateX509)),
|
||||
attr(pkcs11.AttrToken, ckTrue),
|
||||
attr(attrPrivate, ckFalse),
|
||||
attr(pkcs11.AttrLabel, []byte(leaf.Subject.CommonName)),
|
||||
attr(pkcs11.AttrID, id),
|
||||
attr(pkcs11.AttrSubject, leaf.RawSubject),
|
||||
attr(attrIssuer, leaf.RawIssuer),
|
||||
attr(attrSerialNumber, serial),
|
||||
attr(pkcs11.AttrValue, leaf.Raw),
|
||||
)
|
||||
require.NoError(t, err, "import certificate")
|
||||
}
|
||||
|
||||
func readWriteSession(t *testing.T, uri string) *pkcs11.Session {
|
||||
t.Helper()
|
||||
parsed, err := pkcs11.ParseURI(uri)
|
||||
require.NoError(t, err)
|
||||
module, err := pkcs11.Load(parsed.Module())
|
||||
require.NoError(t, err)
|
||||
pin, err := parsed.PIN()
|
||||
require.NoError(t, err)
|
||||
session, err := module.OpenReadWriteSession(parsed.Token, pin)
|
||||
require.NoError(t, err)
|
||||
return session
|
||||
}
|
||||
|
||||
func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
|
||||
t.Helper()
|
||||
switch k := key.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
return []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
|
||||
attr(pkcs11.AttrECParams, oidP256),
|
||||
attr(pkcs11.AttrValue, ecPrivateScalar(t, k)),
|
||||
}
|
||||
case *rsa.PrivateKey:
|
||||
k.Precompute()
|
||||
return []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
|
||||
attr(pkcs11.AttrModulus, k.N.Bytes()),
|
||||
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
|
||||
attr(attrPrivateExponent, k.D.Bytes()),
|
||||
attr(attrPrime1, k.Primes[0].Bytes()),
|
||||
attr(attrPrime2, k.Primes[1].Bytes()),
|
||||
attr(attrExponent1, k.Precomputed.Dp.Bytes()),
|
||||
attr(attrExponent2, k.Precomputed.Dq.Bytes()),
|
||||
attr(attrCoefficient, k.Precomputed.Qinv.Bytes()),
|
||||
}
|
||||
}
|
||||
t.Fatalf("unsupported key %T", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicKeyAttributes describes the CKO_PUBLIC_KEY object tokens keep next to a private
|
||||
// key, which is what the store reads to pair a certificate file with its key.
|
||||
func publicKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
|
||||
t.Helper()
|
||||
switch k := key.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
point := append([]byte{4}, k.X.FillBytes(make([]byte, 32))...)
|
||||
point = append(point, k.Y.FillBytes(make([]byte, 32))...)
|
||||
wrapped, err := asn1.Marshal(point)
|
||||
require.NoError(t, err)
|
||||
return []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
|
||||
attr(pkcs11.AttrECParams, oidP256),
|
||||
attr(pkcs11.AttrECPoint, wrapped),
|
||||
}
|
||||
case *rsa.PrivateKey:
|
||||
return []pkcs11.Attribute{
|
||||
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
|
||||
attr(pkcs11.AttrModulus, k.N.Bytes()),
|
||||
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
|
||||
}
|
||||
}
|
||||
t.Fatalf("unsupported key %T", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func attr(typ uint, value []byte) pkcs11.Attribute {
|
||||
return pkcs11.Attribute{Type: typ, Value: value}
|
||||
}
|
||||
|
||||
// pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when
|
||||
// no token is configured or this build lacks PKCS#11 support.
|
||||
func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) {
|
||||
t.Helper()
|
||||
uri := os.Getenv(testPKCS11URIEnv)
|
||||
if uri == "" {
|
||||
t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv)
|
||||
}
|
||||
store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir)
|
||||
require.NoError(t, err)
|
||||
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
|
||||
t.Skip(err)
|
||||
}
|
||||
return store, uri
|
||||
}
|
||||
|
||||
// TestCollect_PKCS11KeyWithFileCertificate covers the split layout: the key lives on the
|
||||
// token, the certificate is a PEM file in the directory, and the two are paired by public
|
||||
// key because nothing on the token carries the certificate's CKA_ID.
|
||||
func TestCollect_PKCS11KeyWithFileCertificate(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
store, uri := pkcs11TestStore(t, dir)
|
||||
|
||||
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
|
||||
for name, key := range keys {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-file-"+name)
|
||||
leaf := ca.Issue(t, key, "device-file-"+name)
|
||||
importKey(t, uri, key, "device-file-"+name)
|
||||
writeFile(t, dir, "device-"+name+".pem", certtest.CertPEM(leaf))
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
|
||||
proofs := Collect(context.Background(), store, checks, peerKey)
|
||||
require.Len(t, proofs, 1, "the token key must prove the certificate kept on disk")
|
||||
chain, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, leaf.Equal(chain[0]), "proof must carry the certificate from the directory")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCS11Store_FileChains(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
dir := storeDir(t)
|
||||
// Only certificate files without a key of their own belong to the token; the file
|
||||
// store answers for the others, and non-certificate files are ignored.
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "device")))
|
||||
writeFile(t, dir, "ca.crt", ca.PEM)
|
||||
keyed := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "inline.pem", certtest.CertPEM(ca.Issue(t, keyed, "inline"))+certtest.KeyPEM(t, keyed))
|
||||
writeFile(t, dir, "sibling.crt", certtest.CertPEM(ca.Issue(t, keyed, "sibling")))
|
||||
writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed))
|
||||
writeFile(t, dir, "notes.txt", "not a certificate")
|
||||
|
||||
chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains()
|
||||
require.NoError(t, err)
|
||||
var subjects []string
|
||||
for _, chain := range chains {
|
||||
subjects = append(subjects, chain[0].Subject.CommonName)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token")
|
||||
|
||||
chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, chains, "no directory configured means no file certificates")
|
||||
}
|
||||
|
||||
func TestNewPKCS11Store_PIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg PKCS11Config
|
||||
wantPIN []byte
|
||||
wantModule string
|
||||
}{
|
||||
{"pin with a token label opens that token through p11-kit", PKCS11Config{URI: "pkcs11:token=netbird", PIN: "1234"}, []byte("1234"), pkcs11.DefaultModule},
|
||||
{"pin field wins over pin-value", PKCS11Config{URI: "pkcs11:token=netbird?module-path=" + absModule("x.so") + "&pin-value=0000", PIN: "1234"}, []byte("1234"), absModule("x.so")},
|
||||
{"uri pin-value stands in for a missing field", PKCS11Config{URI: "pkcs11:token=netbird?pin-value=0000"}, []byte("0000"), pkcs11.DefaultModule},
|
||||
{"no pin at all means no login", PKCS11Config{URI: "pkcs11:token=netbird"}, nil, pkcs11.DefaultModule},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store, err := NewPKCS11Store(tt.cfg, "")
|
||||
require.NoError(t, err)
|
||||
pin, err := store.userPIN()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
|
||||
assert.Equal(t, tt.wantModule, store.uri.Module(), "module to load")
|
||||
})
|
||||
}
|
||||
|
||||
_, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "")
|
||||
assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults")
|
||||
|
||||
for name, cfg := range map[string]PKCS11Config{
|
||||
"env pin without uri": {PIN: "1234"},
|
||||
"env pin, uri lacks token": {URI: "pkcs11:?module-path=" + absModule("x.so"), PIN: "1234"},
|
||||
"inline pin-value only": {URI: "pkcs11:?pin-value=0000"},
|
||||
"pin-source only": {URI: "pkcs11:?pin-source=file:/etc/netbird/pkcs11.pin"},
|
||||
} {
|
||||
_, err := NewPKCS11Store(cfg, "")
|
||||
assert.ErrorIs(t, err, errPINNeedsToken, "%s: a PIN must not go to whichever token is listed first", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPKCS11Store_WrongPINIsTriedOnce logs in to the real token with a wrong PIN: the
|
||||
// token refuses it, and the next collection refuses to send the same PIN again rather
|
||||
// than spending another attempt of the token's lockout counter. The one attempt it does
|
||||
// spend counts against a real token's lockout, so it runs only on a token marked
|
||||
// disposable through NB_TEST_PKCS11_DISPOSABLE.
|
||||
func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) {
|
||||
disposable, _ := strconv.ParseBool(os.Getenv(testPKCS11DisposableEnv))
|
||||
if !disposable {
|
||||
t.Skipf("set %s=1 to spend a wrong-PIN attempt on the token", testPKCS11DisposableEnv)
|
||||
}
|
||||
_, uri := pkcs11TestStore(t, "")
|
||||
|
||||
wrongPIN := "wrong-pin-" + t.Name()
|
||||
store, err := NewPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.Candidates(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.True(t, pkcs11.PINRejected(err), "the token itself rejects the PIN: %v", err)
|
||||
|
||||
_, err = store.Candidates(context.Background())
|
||||
assert.ErrorIs(t, err, errPINRejectedBefore, "the rejected PIN is not sent to the token again")
|
||||
|
||||
good, err := NewPKCS11Store(PKCS11Config{URI: uri}, "")
|
||||
require.NoError(t, err)
|
||||
_, err = good.Candidates(context.Background())
|
||||
assert.NoError(t, err, "the correct PIN for the same token is unaffected")
|
||||
}
|
||||
|
||||
// absModule is an absolute module path on the platform the test runs on, as module-path
|
||||
// must be absolute.
|
||||
func absModule(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\lib\` + name
|
||||
}
|
||||
return "/lib/" + name
|
||||
}
|
||||
|
||||
// ecPrivateScalar returns the raw private scalar the token stores in CKA_VALUE.
|
||||
func ecPrivateScalar(t *testing.T, k *ecdsa.PrivateKey) []byte {
|
||||
t.Helper()
|
||||
raw, err := k.Bytes()
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
StoreDirEnv = "NB_CERT_STORE_DIR"
|
||||
defaultStoreDir = "/etc/netbird/certs"
|
||||
|
||||
// maxStoreFileSize bounds a certificate or key file of the PEM directory. A chain with
|
||||
// its key is a few kilobytes.
|
||||
maxStoreFileSize = 1 << 20
|
||||
)
|
||||
|
||||
// errKeyMismatch rejects a key that does not belong to the certificate it sits with: it
|
||||
// would sign a proof management can only reject, in place of a usable later candidate.
|
||||
var (
|
||||
errKeyMismatch = errors.New("private key does not match the certificate")
|
||||
errNoSiblingKey = errors.New("no key file next to the certificate")
|
||||
)
|
||||
|
||||
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
|
||||
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
|
||||
// path to a challenge's CAs can be found even where Chain followed a different issuer,
|
||||
// such as an expired copy of a renewed intermediate.
|
||||
type Candidate struct {
|
||||
Chain []*x509.Certificate
|
||||
Signer crypto.Signer
|
||||
Intermediates []*x509.Certificate
|
||||
}
|
||||
|
||||
// issuers is every certificate other than the leaf that a path may run through.
|
||||
func (c Candidate) issuers() []*x509.Certificate {
|
||||
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
|
||||
}
|
||||
|
||||
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
|
||||
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
|
||||
type Store interface {
|
||||
Candidates(ctx context.Context) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// Config selects where the daemon looks for certificates. PKCS11 names a token whose keys
|
||||
// sign for certificates on the token or in the PEM directory, which NB_CERT_STORE_DIR
|
||||
// names on Linux, /etc/netbird/certs by default.
|
||||
//
|
||||
// ProfileOwner is the OS account the active profile belongs to. On macOS and Windows only
|
||||
// that account's certificate store is consulted for user certificates, so on a machine
|
||||
// with several people signed in the result does not depend on who else is logged in.
|
||||
// Empty means the profile has no owner, and only the user at the physical console counts.
|
||||
// OwnerUnknown means the owner could not be determined, and no user store is consulted:
|
||||
// guessing would let whoever sits at the console answer for the profile.
|
||||
type Config struct {
|
||||
PKCS11 PKCS11Config
|
||||
ProfileOwner string
|
||||
OwnerUnknown bool
|
||||
}
|
||||
|
||||
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
|
||||
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
|
||||
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
|
||||
type FileStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewFileStore(dir string) *FileStore {
|
||||
return &FileStore{dir: dir}
|
||||
}
|
||||
|
||||
func StoreDir() string {
|
||||
if dir := os.Getenv(StoreDirEnv); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return defaultStoreDir
|
||||
}
|
||||
|
||||
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
paths, err := certFiles(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []Candidate
|
||||
for _, path := range paths {
|
||||
chain, signer, err := loadPEM(path)
|
||||
if err != nil {
|
||||
log.Warnf("skipping certificate %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if signer == nil {
|
||||
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// certFiles lists the certificate files in dir, none when the directory does not exist.
|
||||
// The directory is checked before it is listed, so one that others can write to is
|
||||
// refused before its entries are read.
|
||||
func certFiles(dir string) ([]string, error) {
|
||||
err := checkStoreDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refusing certificate store: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
|
||||
}
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isCertFile(entry.Name()) {
|
||||
paths = append(paths, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// loadPEM reads a certificate file and its private key, held in the file itself or in
|
||||
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
|
||||
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
|
||||
data, err := readStoreFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
chain, signer, err := parsePEM(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return nil, nil, errors.New("no certificate")
|
||||
}
|
||||
if signer == nil {
|
||||
signer, err = siblingKey(path)
|
||||
switch {
|
||||
case errors.Is(err, errNoSiblingKey):
|
||||
// A certificate with no key of its own: the token store pairs it later.
|
||||
return chain, nil, nil
|
||||
case err != nil:
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if !samePublicKey(signer.Public(), chain[0].PublicKey) {
|
||||
return nil, nil, errKeyMismatch
|
||||
}
|
||||
return chain, signer, nil
|
||||
}
|
||||
|
||||
// siblingKey reads the private key from the "<name>.key" file next to a certificate
|
||||
// file, reporting errNoSiblingKey when there is none.
|
||||
func siblingKey(path string) (crypto.Signer, error) {
|
||||
keyData, err := readStoreFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errNoSiblingKey
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key file: %w", err)
|
||||
}
|
||||
_, signer, err := parsePEM(keyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if signer == nil {
|
||||
return nil, errors.New("no private key in key file")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func samePublicKey(a, b crypto.PublicKey) bool {
|
||||
equaler, ok := a.(interface{ Equal(crypto.PublicKey) bool })
|
||||
return ok && equaler.Equal(b)
|
||||
}
|
||||
|
||||
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
|
||||
var chain []*x509.Certificate
|
||||
var signer crypto.Signer
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
return chain, signer, nil
|
||||
}
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tss2KeyPEMType:
|
||||
key, err := parsePrivateKey(block)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
signer = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
var key any
|
||||
var err error
|
||||
switch block.Type {
|
||||
case tss2KeyPEMType:
|
||||
return parseTSS2Key(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
key, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
default:
|
||||
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
signer, ok := key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, errors.New("private key cannot sign")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func isCertFile(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pem", ".crt", ".cer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stores queries several stores and carries on when one fails, so a broken token cannot
|
||||
// hide the certificates a directory holds. A failure is logged instead of returned
|
||||
// because Collect treats a store error as "no proofs at all".
|
||||
type Stores []Store
|
||||
|
||||
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
|
||||
var all []Candidate
|
||||
for _, store := range s {
|
||||
candidates, err := store.Candidates(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("certificate store %T unavailable: %v", store, err)
|
||||
continue
|
||||
}
|
||||
all = append(all, candidates...)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
)
|
||||
|
||||
var unsupportedTokenOnce sync.Once
|
||||
|
||||
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
|
||||
func DefaultStore() Store {
|
||||
return NewFileStore(StoreDir())
|
||||
}
|
||||
|
||||
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
|
||||
// names one. The token pairs the directory's key-less certificates with its own keys.
|
||||
func storeWithToken(cfg Config) Store {
|
||||
files := NewFileStore(StoreDir())
|
||||
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
|
||||
return files
|
||||
}
|
||||
if !pkcs11.Supported() {
|
||||
unsupportedTokenOnce.Do(func() {
|
||||
log.Warnf("ignoring the configured PKCS#11 token: %v", pkcs11.ErrUnsupported)
|
||||
})
|
||||
return files
|
||||
}
|
||||
token, err := NewPKCS11Store(cfg.PKCS11, StoreDir())
|
||||
if err != nil {
|
||||
log.Warnf("ignoring PKCS#11 URI: %v", err)
|
||||
return files
|
||||
}
|
||||
return Stores{files, token}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
)
|
||||
|
||||
func TestStoreWithToken(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
t.Setenv(StoreDirEnv, dir)
|
||||
|
||||
files, ok := storeWithToken(Config{}).(*FileStore)
|
||||
require.True(t, ok, "no token configured reads the PEM directory alone")
|
||||
assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR")
|
||||
|
||||
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
|
||||
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory")
|
||||
|
||||
configured := map[string]PKCS11Config{
|
||||
"env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"},
|
||||
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
|
||||
}
|
||||
if !pkcs11.Supported() {
|
||||
for name, cfg := range configured {
|
||||
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: cfg}),
|
||||
"%s: a build without PKCS#11 support reads the PEM directory alone", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
for name, cfg := range configured {
|
||||
store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores)
|
||||
require.True(t, ok, "%s joins the token to the PEM directory", name)
|
||||
require.Len(t, store, 2, name)
|
||||
token, ok := store[1].(*PKCS11Store)
|
||||
require.True(t, ok, name)
|
||||
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-tpm/legacy/tpm2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm"
|
||||
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestFileStore_TPMKeyFile(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
leaf := ca.Issue(t, key, "device")
|
||||
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
|
||||
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
|
||||
|
||||
// A key that needs a password can never be used silently, so its certificate is skipped.
|
||||
locked := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
|
||||
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth()))
|
||||
|
||||
candidates, err := NewFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
|
||||
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
|
||||
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
|
||||
}
|
||||
|
||||
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
|
||||
// swtpm started with:
|
||||
//
|
||||
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
|
||||
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
|
||||
//
|
||||
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
|
||||
// hierarchy, and proves the certificate for it through the regular file store.
|
||||
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
|
||||
if os.Getenv(tpm.DeviceEnv) == "" {
|
||||
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
|
||||
}
|
||||
public, private := createTPMKey(t)
|
||||
keyPEM := tpmtest.EncodePEM(t, public, private)
|
||||
block, _ := pem.Decode([]byte(keyPEM))
|
||||
require.NotNil(t, block)
|
||||
signer, err := tpm.ParseKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
leaf := ca.Issue(t, signer, "device")
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
|
||||
writeFile(t, dir, "device.key", keyPEM)
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
|
||||
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
|
||||
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
|
||||
chain, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
|
||||
}
|
||||
|
||||
func createTPMKey(t *testing.T) (public, private []byte) {
|
||||
t.Helper()
|
||||
rwc, err := tpm.Open()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = rwc.Close() }()
|
||||
|
||||
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
|
||||
|
||||
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
|
||||
require.NoError(t, err)
|
||||
return public, private
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm"
|
||||
)
|
||||
|
||||
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file.
|
||||
const tss2KeyPEMType = tpm.KeyPEMType
|
||||
|
||||
// parseTSS2Key returns a signer for a TPM-held key file that signs inside the TPM.
|
||||
func parseTSS2Key(der []byte) (crypto.Signer, error) {
|
||||
return tpm.ParseKey(der)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file. A browser has no TPM, so
|
||||
// the TPM library stays out of the WebAssembly build and such a key is refused.
|
||||
const tss2KeyPEMType = "TSS2 PRIVATE KEY"
|
||||
|
||||
func parseTSS2Key([]byte) (crypto.Signer, error) {
|
||||
return nil, errors.New("TPM keys are not supported in this build")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// readStoreFile reads a certificate or key file of the PEM directory, up to the size
|
||||
// limit. Ownership is checked on Unix only; elsewhere the PEM directory is not the store
|
||||
// the daemon reads by default.
|
||||
func readStoreFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(data) > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s is over the %d byte limit", path, maxStoreFileSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// checkStoreDir accepts any directory where the PEM directory is not a default store.
|
||||
func checkStoreDir(string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// readStoreFile reads a certificate or key file of the PEM directory as the daemon may
|
||||
// trust it: the path must not be a symlink and must be a regular file that only root or
|
||||
// this process's own user can write. Otherwise a user able to place or redirect a file
|
||||
// could make the daemon sign with a key of their choosing, or with a root-only key kept
|
||||
// elsewhere on the machine. The file is checked through the descriptor it is read from,
|
||||
// so it cannot be swapped between the check and the read.
|
||||
func readStoreFile(path string) ([]byte, error) {
|
||||
// O_NONBLOCK keeps a FIFO planted in the directory from blocking the open; the
|
||||
// regular-file check below then refuses it.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
if err := checkTrustedOwner(info); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if info.Size() > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", path, info.Size(), maxStoreFileSize)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(data) > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s grew past the %d byte limit while being read", path, maxStoreFileSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// checkStoreDir refuses a PEM directory that someone other than root or this process's
|
||||
// user could add files to or rename files in. The configured path may be a symlink, as
|
||||
// distributions place certificate directories behind them, but only one root or this
|
||||
// process's user owns: otherwise whoever owns the link could point it at any directory.
|
||||
func checkStoreDir(dir string) error {
|
||||
link, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if link.Mode()&os.ModeSymlink != 0 {
|
||||
if err := checkTrustedUID(link); err != nil {
|
||||
return fmt.Errorf("symlink %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
if err := checkTrustedOwner(info); err != nil {
|
||||
return fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTrustedOwner requires info to belong to root or to this process's user and to be
|
||||
// writable by nobody else. Group write is refused even for root's group, which ordinary
|
||||
// users may be members of. An owner that cannot be read is refused.
|
||||
func checkTrustedOwner(info os.FileInfo) error {
|
||||
if err := checkTrustedUID(info); err != nil {
|
||||
return err
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o022 != 0 {
|
||||
return fmt.Errorf("writable by group or other users (mode %#o)", perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTrustedUID requires info to belong to root or to this process's user.
|
||||
func checkTrustedUID(info os.FileInfo) error {
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("owner cannot be determined")
|
||||
}
|
||||
if st.Uid != 0 && int(st.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("owned by uid %d, not by root", st.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
)
|
||||
|
||||
// devicePair writes a certificate and its key as separate files into dir.
|
||||
func devicePair(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
key := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "device.crt", certtest.CertPEM(ca.Issue(t, key, "device")))
|
||||
writeFile(t, dir, "device.key", certtest.KeyPEM(t, key))
|
||||
}
|
||||
|
||||
func candidates(t *testing.T, dir string) ([]Candidate, error) {
|
||||
t.Helper()
|
||||
return NewFileStore(dir).Candidates(context.Background())
|
||||
}
|
||||
|
||||
func TestFileStore_AcceptsAPrivatePair(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a certificate and key only their owner can write are used")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesASymlinkedKey(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
|
||||
// The real key moves elsewhere and the directory only links to it, as a user able
|
||||
// to write the directory would do to point the daemon at a key it should not use.
|
||||
elsewhere := filepath.Join(storeDir(t), "other.key")
|
||||
require.NoError(t, os.Rename(filepath.Join(dir, "device.key"), elsewhere))
|
||||
require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "device.key")))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a key reached through a symlink is never used")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesASymlinkedCertificateFile(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
other := storeDir(t)
|
||||
devicePair(t, other)
|
||||
require.NoError(t, os.Symlink(filepath.Join(other, "device.crt"), filepath.Join(dir, "device.crt")))
|
||||
require.NoError(t, os.Symlink(filepath.Join(other, "device.key"), filepath.Join(dir, "device.key")))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a certificate file that is a symlink is skipped")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesFilesOthersCanWrite(t *testing.T) {
|
||||
for name, mode := range map[string]os.FileMode{"world-writable": 0o602, "group-writable": 0o620} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(filepath.Join(dir, "device.key"), mode))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a key %s may have been replaced by someone else, even when the group is root's", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesADirectoryOthersCanWrite(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777))
|
||||
|
||||
_, err := candidates(t, dir)
|
||||
assert.ErrorContains(t, err, "refusing certificate store", "files in a directory anyone can write are not trusted")
|
||||
}
|
||||
|
||||
func TestFileStore_SkipsAFIFOWithoutBlocking(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "trap.pem"), 0o600))
|
||||
devicePair(t, dir)
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a FIFO is skipped and the real pair still loads")
|
||||
}
|
||||
|
||||
func TestReadStoreFile_RefusesOversizedFiles(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
path := filepath.Join(dir, "huge.pem")
|
||||
require.NoError(t, os.WriteFile(path, make([]byte, maxStoreFileSize+1), 0o600))
|
||||
|
||||
_, err := readStoreFile(path)
|
||||
assert.ErrorContains(t, err, "limit")
|
||||
}
|
||||
|
||||
func TestFileStore_AcceptsASymlinkedDirectoryItsOwnerControls(t *testing.T) {
|
||||
target := storeDir(t)
|
||||
devicePair(t, target)
|
||||
link := filepath.Join(storeDir(t), "certs")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
// The link belongs to this process's user, as one root creates belongs to root.
|
||||
got, err := candidates(t, link)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a certificate directory behind a symlink the owner controls is read")
|
||||
}
|
||||
|
||||
func TestFileStore_ChecksTheDirectoryBeforeListingIt(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777))
|
||||
|
||||
paths, err := certFiles(dir)
|
||||
assert.ErrorContains(t, err, "refusing certificate store")
|
||||
assert.Nil(t, paths, "nothing in a directory anyone can write to is listed")
|
||||
}
|
||||
|
||||
func TestFileStore_MissingDirectoryIsEmpty(t *testing.T) {
|
||||
paths, err := certFiles(filepath.Join(storeDir(t), "missing"))
|
||||
assert.NoError(t, err, "a store directory that does not exist yet is not an error")
|
||||
assert.Empty(t, paths)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
personalStore = "MY"
|
||||
intermediateStore = "CA"
|
||||
|
||||
cryptAcquireSilentFlag = 0x00000040
|
||||
cryptAcquirePreferNCryptKeyFlag = 0x00020000
|
||||
certNCryptKeySpec = 0xFFFFFFFF
|
||||
atKeyExchange = 1
|
||||
atSignature = 2
|
||||
bcryptPadPSS = 0x00000008
|
||||
)
|
||||
|
||||
// errLegacyKey reports a private key CNG cannot open: it lives in a legacy CryptoAPI
|
||||
// provider, which only offers PKCS#1 v1.5 and no RSA-PSS or ECDSA.
|
||||
var errLegacyKey = errors.New("private key is held by a legacy CryptoAPI provider, which certificate posture cannot sign with")
|
||||
|
||||
var (
|
||||
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
|
||||
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
|
||||
|
||||
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
|
||||
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
|
||||
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
|
||||
)
|
||||
|
||||
type bcryptPSSPaddingInfo struct {
|
||||
algID *uint16
|
||||
salt uint32
|
||||
}
|
||||
|
||||
// DefaultStore is the local machine's personal certificate store, where device
|
||||
// certificates enrolled through AD or Intune are kept.
|
||||
func DefaultStore() Store {
|
||||
return NewSystemStore()
|
||||
}
|
||||
|
||||
// SystemStore yields the identities of a personal certificate store, completing their
|
||||
// chains from the matching intermediate CA store. Keys are used through CNG and never
|
||||
// exported.
|
||||
//
|
||||
// The location decides whose certificates these are. The local machine store is the one
|
||||
// a service reads; the current user store lives in the signed-in user's registry hive
|
||||
// with keys protected against their profile, so it is only readable while running as
|
||||
// that user.
|
||||
type SystemStore struct {
|
||||
location uint32
|
||||
}
|
||||
|
||||
// NewSystemStore reads the local machine store, which is what the daemon uses.
|
||||
func NewSystemStore() *SystemStore {
|
||||
return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
|
||||
}
|
||||
|
||||
// NewUserStore reads the calling user's personal store. It is only useful in a process
|
||||
// already running as that user, which is what the posture helper is.
|
||||
func NewUserStore() *SystemStore {
|
||||
return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
|
||||
}
|
||||
|
||||
func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
leaves, err := storeCertificates(s.location, personalStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intermediates, err := storeCertificates(s.location, intermediateStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debugf("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
|
||||
if len(leaves) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pool := slices.Concat(intermediates, leaves)
|
||||
candidates := make([]Candidate, 0, len(leaves))
|
||||
for _, leaf := range leaves {
|
||||
chain := buildChain(leaf, pool)
|
||||
log.Debugf("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
|
||||
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}, Intermediates: pool})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// String names the store location the way the Windows documentation does.
|
||||
func (s *SystemStore) String() string {
|
||||
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
|
||||
return "CurrentUser"
|
||||
}
|
||||
return "LocalMachine"
|
||||
}
|
||||
|
||||
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
|
||||
// at signing time so no handles outlive a call.
|
||||
type systemStoreSigner struct {
|
||||
leaf *x509.Certificate
|
||||
location uint32
|
||||
}
|
||||
|
||||
func (s *systemStoreSigner) Public() crypto.PublicKey {
|
||||
return s.leaf.PublicKey
|
||||
}
|
||||
|
||||
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := openStore(s.location, personalStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = windows.CertCloseStore(store, 0) }()
|
||||
|
||||
var signature []byte
|
||||
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
|
||||
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
|
||||
return false, nil
|
||||
}
|
||||
signature, err = signWithContext(ctx, scheme, digest)
|
||||
return true, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if signature == nil {
|
||||
return nil, errors.New("certificate is no longer in the personal store")
|
||||
}
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
|
||||
var key uintptr
|
||||
var keySpec uint32
|
||||
var callerFree int32
|
||||
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
|
||||
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
|
||||
if ok == 0 {
|
||||
return nil, fmt.Errorf("acquire private key: %w", err)
|
||||
}
|
||||
if keySpec != certNCryptKeySpec {
|
||||
if callerFree != 0 {
|
||||
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
|
||||
}
|
||||
return nil, legacyKeyError(keySpec)
|
||||
}
|
||||
if callerFree != 0 {
|
||||
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
|
||||
}
|
||||
|
||||
var padding unsafe.Pointer
|
||||
var flags uintptr
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
algID, _ := windows.UTF16PtrFromString("SHA256")
|
||||
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
|
||||
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
|
||||
}
|
||||
size, err := ncryptSignHash(key, padding, digest, nil, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature := make([]byte, size)
|
||||
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature = signature[:size]
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
return signature, nil
|
||||
}
|
||||
return ecdsaSignatureASN1(signature)
|
||||
}
|
||||
|
||||
// legacyKeyError describes a key acquired through CryptoAPI rather than CNG, naming its
|
||||
// key spec and the remedy, since the certificate otherwise looks usable in the store.
|
||||
func legacyKeyError(keySpec uint32) error {
|
||||
spec := fmt.Sprintf("key spec %d", keySpec)
|
||||
switch keySpec {
|
||||
case atKeyExchange:
|
||||
spec = "AT_KEYEXCHANGE"
|
||||
case atSignature:
|
||||
spec = "AT_SIGNATURE"
|
||||
}
|
||||
return fmt.Errorf("%w (%s); re-enrol it with a certificate template whose provider is a CNG key storage provider", errLegacyKey, spec)
|
||||
}
|
||||
|
||||
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
|
||||
var result uint32
|
||||
var signaturePtr uintptr
|
||||
if len(signature) > 0 {
|
||||
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
|
||||
}
|
||||
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
|
||||
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
|
||||
if uint32(status) != 0 {
|
||||
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
|
||||
store, err := openStore(location, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = windows.CertCloseStore(store, 0) }()
|
||||
|
||||
var certs []*x509.Certificate
|
||||
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
|
||||
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
|
||||
if err != nil {
|
||||
log.Warnf("skipping certificate in %s store: %v", name, err)
|
||||
return false, nil
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
return false, nil
|
||||
})
|
||||
return certs, err
|
||||
}
|
||||
|
||||
func openStore(location uint32, name string) (windows.Handle, error) {
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
|
||||
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
|
||||
var ctx *windows.CertContext
|
||||
for {
|
||||
next, err := windows.CertEnumCertificatesInStore(store, ctx)
|
||||
if next == nil {
|
||||
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("enumerate certificates: %w", err)
|
||||
}
|
||||
ctx = next
|
||||
if stop, err := fn(ctx); stop || err != nil {
|
||||
_ = windows.CertFreeCertificateContext(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodedCert(ctx *windows.CertContext) []byte {
|
||||
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLegacyKeyError_NamesTheKeySpecAndTheRemedy(t *testing.T) {
|
||||
for spec, want := range map[uint32]string{atKeyExchange: "AT_KEYEXCHANGE", atSignature: "AT_SIGNATURE", 7: "key spec 7"} {
|
||||
err := legacyKeyError(spec)
|
||||
assert.ErrorIs(t, err, errLegacyKey, "callers can tell a legacy key from other failures")
|
||||
assert.ErrorContains(t, err, want, "the key spec found is named")
|
||||
assert.ErrorContains(t, err, "key storage provider", "the error says how to fix the enrolment")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -75,6 +76,9 @@ type ConnectClient struct {
|
||||
// netMgr gates every reconnection loop on OS-reported network
|
||||
// availability and sweeps connections on network change.
|
||||
netMgr *netevents.Manager
|
||||
|
||||
profileOwner string
|
||||
profileOwnerUnknown bool
|
||||
}
|
||||
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
@@ -85,6 +89,18 @@ func WithNetEvents(events *netevents.Manager) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netMgr = events }
|
||||
}
|
||||
|
||||
// WithProfileOwner names the OS account the active profile belongs to, whose own
|
||||
// certificate store answers user certificate posture checks.
|
||||
func WithProfileOwner(username string) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.profileOwner = username }
|
||||
}
|
||||
|
||||
// WithUnknownProfileOwner records that the active profile's owner could not be
|
||||
// determined, so no user's certificate store answers certificate posture checks.
|
||||
func WithUnknownProfileOwner() ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.profileOwnerUnknown = true }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *profilemanager.Config,
|
||||
@@ -420,6 +436,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return wrapErr(err)
|
||||
}
|
||||
engineConfig.TempDir = mobileDependency.TempDir
|
||||
engineConfig.CertStore.ProfileOwner = c.profileOwner
|
||||
engineConfig.CertStore.OwnerUnknown = c.profileOwnerUnknown
|
||||
// Leave StateDir empty when there is no state path so a disk-backed
|
||||
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
|
||||
if path != "" {
|
||||
@@ -675,6 +693,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
|
||||
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
||||
|
||||
CertStore: certStoreConfig(config),
|
||||
|
||||
MTU: selectMTU(config.MTU, peerConfig.Mtu),
|
||||
LogPath: logPath,
|
||||
|
||||
@@ -701,6 +721,22 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
return engineConf, nil
|
||||
}
|
||||
|
||||
// certStoreConfig reads where certificate posture finds certificates from the daemon's
|
||||
// environment, NB_CERT_STORE_DIR and NB_CERT_PKCS11_URI with NB_TPM_PIN. The profile
|
||||
// config fields that once held them are ignored, and a value left there is reported
|
||||
// once, so a setup relying on it does not silently stop proving.
|
||||
func certStoreConfig(config *profilemanager.Config) certproof.Config {
|
||||
if config.CertStoreDir != "" || config.CertPKCS11URI != "" {
|
||||
legacyCertConfigOnce.Do(func() {
|
||||
log.Warnf("certificate posture: CertStoreDir and CertPKCS11URI in the profile config are ignored, set %s and %s in the daemon's environment instead",
|
||||
certproof.StoreDirEnv, certproof.PKCS11URIEnv)
|
||||
})
|
||||
}
|
||||
return certproof.Config{PKCS11: certproof.PKCS11FromEnv()}
|
||||
}
|
||||
|
||||
var legacyCertConfigOnce sync.Once
|
||||
|
||||
func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
var finalMTU uint16 = iface.DefaultMTU
|
||||
if localMTU > 0 {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// TestCertStoreConfig_ReadsEnvironmentNotProfile: the token URI may carry the PIN, so it
|
||||
// and the store directory come from the daemon's environment, and values left in the
|
||||
// profile config from an older version are ignored.
|
||||
func TestCertStoreConfig_ReadsEnvironmentNotProfile(t *testing.T) {
|
||||
t.Setenv(certproof.PKCS11URIEnv, "pkcs11:token=env")
|
||||
t.Setenv(certproof.PINEnv, "1234")
|
||||
|
||||
cfg := certStoreConfig(&profilemanager.Config{
|
||||
CertStoreDir: "/from/profile",
|
||||
CertPKCS11URI: "pkcs11:token=profile?pin-value=9999",
|
||||
})
|
||||
|
||||
assert.Equal(t, "pkcs11:token=env", cfg.PKCS11.URI, "the URI comes from the environment")
|
||||
assert.Equal(t, "1234", cfg.PKCS11.PIN, "the PIN comes from the environment")
|
||||
}
|
||||
@@ -647,7 +647,7 @@ const (
|
||||
jsonKeyServiceEnv = "service_env_vars"
|
||||
)
|
||||
|
||||
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential"}
|
||||
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential", "pin", "pkcs11"}
|
||||
|
||||
// addServiceParams reads the service.json file and adds a sanitized version to the bundle.
|
||||
// Non-NB_ env vars and vars with sensitive names are masked. Other NB_ values are anonymized.
|
||||
|
||||
@@ -458,6 +458,9 @@ func TestIsSensitiveEnvVar(t *testing.T) {
|
||||
{"NB_CLIENT_SECRET", true},
|
||||
{"NB_PASSWORD", true},
|
||||
{"NB_CREDENTIAL", true},
|
||||
// The token PIN, and the token URI, which may carry the PIN as pin-value.
|
||||
{"NB_TPM_PIN", true},
|
||||
{"NB_CERT_PKCS11_URI", true},
|
||||
{"NB_LOG_LEVEL", false},
|
||||
{"NB_MANAGEMENT_URL", false},
|
||||
{"NB_HOSTNAME", false},
|
||||
@@ -848,6 +851,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||
"probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with",
|
||||
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
|
||||
"CertPKCS11URI": "deprecated and ignored; sensitive: the URI may carry the token PIN as pin-value",
|
||||
"CertStoreDir": "deprecated and ignored: the directory comes from NB_CERT_STORE_DIR",
|
||||
}
|
||||
|
||||
mURL, _ := url.Parse("https://api.example.com:443")
|
||||
@@ -891,6 +896,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
ClientCertKeyPath: "/tmp/key",
|
||||
LazyConnection: "on",
|
||||
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
|
||||
CertPKCS11URI: "pkcs11:token=netbird?pin-value=pin-secret",
|
||||
MTU: 1280,
|
||||
DisableIPv6: true,
|
||||
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||
@@ -915,6 +921,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
|
||||
assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")
|
||||
|
||||
// A leftover CertPKCS11URI may carry the token PIN as pin-value.
|
||||
assert.NotContains(t, rendered, "CertPKCS11URI", "PKCS#11 URI field must not be serialized into the debug bundle")
|
||||
assert.NotContains(t, rendered, "pin-secret", "PKCS#11 PIN must not leak into the debug bundle")
|
||||
|
||||
val := reflect.ValueOf(cfg).Elem()
|
||||
typ := val.Type()
|
||||
var missing []string
|
||||
|
||||
+80
-14
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
@@ -170,6 +171,8 @@ type EngineConfig struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
CertStore certproof.Config
|
||||
|
||||
// for debug bundle generation
|
||||
ProfileConfig *profilemanager.Config
|
||||
|
||||
@@ -279,8 +282,26 @@ type Engine struct {
|
||||
|
||||
dnsServer dns.Server
|
||||
|
||||
// checks are the client-applied posture checks that need to be evaluated on the client
|
||||
checks []*mgmProto.Checks
|
||||
// checks are the client-applied posture checks that need to be evaluated on the client.
|
||||
// Writers hold syncMsgMux and checksMu; readers hold either, see appliedChecks.
|
||||
checks []*mgmProto.Checks
|
||||
checksMu sync.RWMutex
|
||||
// pendingChecks are the newest received checks whose meta sync failed; the posture
|
||||
// watcher retries them. Both are guarded by syncMsgMux, and
|
||||
// hasPendingChecks lets the watcher skip the lock when nothing is pending.
|
||||
pendingChecks []*mgmProto.Checks
|
||||
hasPendingChecks atomic.Bool
|
||||
// infoTimeout overrides systemInfoTimeout when set.
|
||||
infoTimeout time.Duration
|
||||
|
||||
// certProofs answers the certificate challenges in checks within a bounded time, and
|
||||
// certState remembers what it last proved.
|
||||
certProofs certproof.Collector
|
||||
certState certPostureState
|
||||
// certWake wakes the posture watcher, which owns proof collection; certSendMu orders
|
||||
// the meta syncs that carry proofs.
|
||||
certWake chan struct{}
|
||||
certSendMu sync.Mutex
|
||||
|
||||
infoSource system.InfoSource
|
||||
|
||||
@@ -385,6 +406,7 @@ func NewEngine(
|
||||
stateManager: services.StateManager,
|
||||
portForwardManager: portforward.NewManager(),
|
||||
checks: services.Checks,
|
||||
certWake: make(chan struct{}, 1),
|
||||
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
||||
jobExecutor: jobexec.NewExecutor(),
|
||||
clientMetrics: services.ClientMetrics,
|
||||
@@ -679,6 +701,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, peerConfig *mgmPro
|
||||
e.portForwardManager.Start(e.ctx, uint16(e.config.WgPort))
|
||||
}()
|
||||
|
||||
e.shutdownWg.Add(1)
|
||||
go func(ctx context.Context) {
|
||||
defer e.shutdownWg.Done()
|
||||
e.watchCertificatePosture(ctx)
|
||||
}(e.ctx)
|
||||
|
||||
// Set the WireGuard interface for rosenpass after interface is up
|
||||
if e.rpManager != nil {
|
||||
e.rpManager.SetInterface(e.wgInterface)
|
||||
@@ -1274,23 +1302,54 @@ func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error
|
||||
func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
// if checks are equal, we skip the update
|
||||
if isChecksEqual(e.checks, checks) {
|
||||
e.clearPendingChecks()
|
||||
return nil
|
||||
}
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
|
||||
return nil
|
||||
if err := e.syncChecksMeta(checks); err != nil {
|
||||
// The newest checks become the pending ones whatever failed, so the watcher
|
||||
// never retries a set they superseded.
|
||||
e.pendingChecks = checks
|
||||
e.hasPendingChecks.Store(true)
|
||||
if errors.Is(err, errSystemInfoTimeout) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
|
||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||
return fmt.Errorf("could not sync meta: error %s", err)
|
||||
}
|
||||
e.checks = checks
|
||||
e.setAppliedChecks(checks)
|
||||
e.clearPendingChecks()
|
||||
return nil
|
||||
}
|
||||
|
||||
// appliedChecks returns the posture checks in effect, for callers that do not hold
|
||||
// syncMsgMux. The slice is replaced, never modified, so it may be read after return.
|
||||
func (e *Engine) appliedChecks() []*mgmProto.Checks {
|
||||
e.checksMu.RLock()
|
||||
defer e.checksMu.RUnlock()
|
||||
return e.checks
|
||||
}
|
||||
|
||||
// setAppliedChecks replaces the posture checks in effect. The caller holds syncMsgMux.
|
||||
func (e *Engine) setAppliedChecks(checks []*mgmProto.Checks) {
|
||||
e.checksMu.Lock()
|
||||
e.checks = checks
|
||||
e.checksMu.Unlock()
|
||||
e.wakeCertificatePosture()
|
||||
}
|
||||
|
||||
// clearPendingChecks drops checks whose meta sync was still owed. The caller holds
|
||||
// syncMsgMux.
|
||||
func (e *Engine) clearPendingChecks() {
|
||||
e.pendingChecks = nil
|
||||
e.hasPendingChecks.Store(false)
|
||||
}
|
||||
|
||||
func (e *Engine) infoGatherTimeout() time.Duration {
|
||||
if e.infoTimeout > 0 {
|
||||
return e.infoTimeout
|
||||
}
|
||||
return systemInfoTimeout
|
||||
}
|
||||
|
||||
// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
|
||||
func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
info.SetFlags(
|
||||
@@ -1317,6 +1376,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
|
||||
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
|
||||
e.applyInfoFlags(info)
|
||||
e.attachCertificateProofs(info, e.appliedChecks())
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -1332,6 +1392,7 @@ func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context)
|
||||
info := refreshed
|
||||
refreshed = nil
|
||||
e.applyInfoFlags(info)
|
||||
e.attachCertificateProofs(info, e.appliedChecks())
|
||||
return info
|
||||
}
|
||||
}
|
||||
@@ -1534,7 +1595,7 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.shutdownWg.Add(1)
|
||||
go func() {
|
||||
defer e.shutdownWg.Done()
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), e.appliedChecks(), e.overlayAddresses()...)
|
||||
if !ok {
|
||||
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
|
||||
}
|
||||
@@ -2796,6 +2857,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||
sortedFiles := slices.Clone(check.Files)
|
||||
sort.Strings(sortedFiles)
|
||||
normalized[i] = strings.Join(sortedFiles, "|")
|
||||
if challenge := check.GetCertificateChallenge(); challenge != nil {
|
||||
sortedCAs := slices.Clone(challenge.GetCaCertificates())
|
||||
sort.Strings(sortedCAs)
|
||||
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(normalized)
|
||||
|
||||
@@ -88,7 +88,7 @@ func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.T
|
||||
return time.Time{}, errors.New("management client is not initialised")
|
||||
}
|
||||
|
||||
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||
info, err := system.GetInfoWithChecks(ctx, e.appliedChecks())
|
||||
if err != nil {
|
||||
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||
info = system.GetInfo(ctx)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// certContextPollInterval is how often the engine checks whether the user who can
|
||||
// answer certificate challenges has changed, such as a login after an autostart.
|
||||
certContextPollInterval = time.Minute
|
||||
// certRetryInterval is how long after a collection that proved nothing it is tried
|
||||
// again with the same user, for stores that come up late: a keychain unlocked after
|
||||
// login, or a TPM resource manager started after the daemon.
|
||||
certRetryInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
// undeliveredContext never matches a real user context, so a collection whose meta sync
|
||||
// failed is seen as stale on the next poll.
|
||||
const undeliveredContext = "\x00undelivered"
|
||||
|
||||
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
|
||||
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
|
||||
var errSystemInfoTimeout = errors.New("system info gathering timed out")
|
||||
|
||||
// certPostureState remembers what the last certificate proof collection saw, so the
|
||||
// engine can collect again when it went stale and tell the user when proofs go missing.
|
||||
//
|
||||
// Collection runs only on the posture watcher, never on the sync path, because a token,
|
||||
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
|
||||
// last collection cached. delivered identifies the chains management last received, so
|
||||
// a collection that proves the same chains is not sent again: every meta sync makes
|
||||
// management recompute and push network maps to the peer and its neighbours.
|
||||
type certPostureState struct {
|
||||
mu sync.Mutex
|
||||
attempted bool
|
||||
attemptedAt time.Time
|
||||
userContext string
|
||||
proven bool
|
||||
delivered string
|
||||
hasDelivery bool
|
||||
cached []certposture.Proof
|
||||
cachedFor string
|
||||
// stuck is whether the collector was last seen with every slot held by a lost
|
||||
// collection.
|
||||
stuck bool
|
||||
}
|
||||
|
||||
// record stores the outcome of a collection for the challenges identified by
|
||||
// challengesKey and reports whether it changed from proven to unproven or back. The first
|
||||
// collection only counts as a change when it proved nothing.
|
||||
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
proven := len(proofs) > 0
|
||||
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
|
||||
s.attempted = true
|
||||
s.attemptedAt = now
|
||||
s.userContext = userContext
|
||||
s.proven = proven
|
||||
s.cached = proofs
|
||||
s.cachedFor = challengesKey
|
||||
return changed
|
||||
}
|
||||
|
||||
// undelivered marks the last collection as not having reached management, so the next
|
||||
// poll collects and sends again instead of treating it as current.
|
||||
func (s *certPostureState) undelivered() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.attempted {
|
||||
s.userContext = undeliveredContext
|
||||
}
|
||||
s.hasDelivery = false
|
||||
}
|
||||
|
||||
// markDelivered records the chains of proofs as the ones management holds.
|
||||
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.delivered = provenChainsKey(proofs)
|
||||
s.hasDelivery = true
|
||||
}
|
||||
|
||||
// sameAsDelivered reports whether proofs prove exactly the chains management already
|
||||
// holds. Nonces and signatures are left out: they differ on every signing while the
|
||||
// result management stores, the verified chains, stays the same.
|
||||
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
|
||||
}
|
||||
|
||||
// needsCollection reports whether the cached proofs no longer reflect what the device
|
||||
// can prove for the challenges identified by challengesKey: they answer other
|
||||
// challenges, the user who can answer has changed, or nothing was proven and the retry
|
||||
// interval has passed.
|
||||
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case !s.attempted || s.cachedFor != challengesKey:
|
||||
return true
|
||||
case userContext != s.userContext:
|
||||
return true
|
||||
default:
|
||||
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
|
||||
}
|
||||
}
|
||||
|
||||
// setStuck records whether the collector is stuck and reports whether it just became so.
|
||||
func (s *certPostureState) setStuck(stuck bool) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
became := stuck && !s.stuck
|
||||
s.stuck = stuck
|
||||
return became
|
||||
}
|
||||
|
||||
// cachedProofs returns the cached proofs management still accepts for the challenges in
|
||||
// checks: those whose nonce is current, or from an adjacent window, for one of them. Proofs
|
||||
// signed for the previous nonce bridge the time until the watcher has signed the new one.
|
||||
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var nonces [][]byte
|
||||
for _, check := range checks {
|
||||
if challenge := check.GetCertificateChallenge(); challenge != nil {
|
||||
nonces = append(nonces, challenge.GetNonce())
|
||||
}
|
||||
}
|
||||
var accepted []certposture.Proof
|
||||
for _, proof := range s.cached {
|
||||
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
|
||||
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
|
||||
}) {
|
||||
accepted = append(accepted, proof)
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
// provenChainsKey identifies the set of chains in proofs, independent of their order.
|
||||
func provenChainsKey(proofs []certposture.Proof) string {
|
||||
keys := make([]string, 0, len(proofs))
|
||||
for _, proof := range proofs {
|
||||
h := sha256.New()
|
||||
for _, cert := range proof.Chain {
|
||||
writeLengthPrefixed(h, cert)
|
||||
}
|
||||
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
|
||||
func challengesKey(checks []*mgmProto.Checks) string {
|
||||
h := sha256.New()
|
||||
for _, check := range checks {
|
||||
challenge := check.GetCertificateChallenge()
|
||||
if challenge == nil {
|
||||
continue
|
||||
}
|
||||
writeLengthPrefixed(h, challenge.GetNonce())
|
||||
for _, ca := range challenge.GetCaCertificates() {
|
||||
writeLengthPrefixed(h, []byte(ca))
|
||||
}
|
||||
writeLengthPrefixed(h, nil)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write(data)
|
||||
}
|
||||
|
||||
// attachCertificateProofs attaches the cached proofs management accepts for the
|
||||
// challenges in checks, without collecting: the caller is on the sync path. The info is
|
||||
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
|
||||
// count as delivered. The watcher is woken to collect when the cache is out of date.
|
||||
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
|
||||
if !certproof.HasChallenges(checks) {
|
||||
info.CertificateProofs = nil
|
||||
return
|
||||
}
|
||||
info.CertificateProofs = e.certState.cachedProofs(checks)
|
||||
e.certState.markDelivered(info.CertificateProofs)
|
||||
e.wakeCertificatePosture()
|
||||
}
|
||||
|
||||
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
|
||||
// its next tick. It never blocks.
|
||||
func (e *Engine) wakeCertificatePosture() {
|
||||
if e.certWake == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case e.certWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// publishCertificatePostureEvent tells the user when the device stops proving any
|
||||
// certificate, which management treats as failing every certificate posture check, and
|
||||
// when it proves one again. Without it the loss of access would have no visible cause.
|
||||
func (e *Engine) publishCertificatePostureEvent(proven bool) {
|
||||
if e.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
if proven {
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: a certificate is proven again",
|
||||
"A certificate required by your organization's device policy is available again.", nil)
|
||||
return
|
||||
}
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: no certificate could be proven",
|
||||
"NetBird could not use a certificate required by your organization's device policy. "+
|
||||
"Access to some resources may be blocked until one is available.", nil)
|
||||
}
|
||||
|
||||
// publishCertificateStoreStuckEvent tells the user that reading the certificate store
|
||||
// stopped answering. Collection resumes only once a stuck call returns, which a store
|
||||
// that hangs for good never does short of a restart.
|
||||
func (e *Engine) publishCertificateStoreStuckEvent() {
|
||||
if e.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: the certificate store stopped responding",
|
||||
"NetBird cannot read the certificates required by your organization's device policy because "+
|
||||
"the certificate store stopped responding. If this persists, restart the NetBird service.", nil)
|
||||
}
|
||||
|
||||
// watchCertificatePosture owns certificate proof collection until ctx is done. It
|
||||
// collects when woken by new checks or a sync, and on every tick when the cached proofs
|
||||
// went stale, such as after a login following an autostart.
|
||||
func (e *Engine) watchCertificatePosture(ctx context.Context) {
|
||||
ticker := time.NewTicker(certContextPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
// A pending update that times out again stays pending; the applied checks'
|
||||
// proofs are still refreshed so a lost certificate is reported meanwhile.
|
||||
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
||||
log.Warnf("failed to sync posture checks that timed out before: %v", err)
|
||||
}
|
||||
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
||||
log.Warnf("failed to refresh certificate posture proofs: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-e.certWake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
|
||||
// gathering the system info, and applies them once it succeeds. Without it they would
|
||||
// wait for the next network map that changes the checks.
|
||||
func (e *Engine) retryPendingChecks() error {
|
||||
if !e.hasPendingChecks.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.stopping() || !e.hasPendingChecks.Load() {
|
||||
return nil
|
||||
}
|
||||
checks := e.pendingChecks
|
||||
log.Debugf("posture checks: retrying the meta sync that timed out")
|
||||
if err := e.syncChecksMeta(checks); err != nil {
|
||||
return err
|
||||
}
|
||||
e.setAppliedChecks(checks)
|
||||
e.clearPendingChecks()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCertificateProofs collects the proofs for the applied checks when the cached
|
||||
// ones are out of date, and sends them to management when they prove other chains than
|
||||
// it holds. It holds no engine lock while collecting.
|
||||
func (e *Engine) refreshCertificateProofs() error {
|
||||
checks := e.appliedChecks()
|
||||
if e.stopping() || !certproof.HasChallenges(checks) {
|
||||
return nil
|
||||
}
|
||||
key := challengesKey(checks)
|
||||
userContext := certproof.UserContext(e.config.CertStore)
|
||||
if !e.certState.needsCollection(key, userContext, time.Now()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("certificate posture: collecting proofs")
|
||||
peerKey := e.config.WgPrivateKey.PublicKey()
|
||||
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
|
||||
// A collection cut short by the engine stopping proved nothing about the device, and
|
||||
// must not be reported to the user as a lost certificate.
|
||||
if e.stopping() {
|
||||
return nil
|
||||
}
|
||||
if e.certState.record(key, userContext, proofs, time.Now()) {
|
||||
e.publishCertificatePostureEvent(len(proofs) > 0)
|
||||
}
|
||||
if e.certState.setStuck(e.certProofs.Stuck()) {
|
||||
e.publishCertificateStoreStuckEvent()
|
||||
}
|
||||
|
||||
if e.certState.sameAsDelivered(proofs) {
|
||||
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
|
||||
return nil
|
||||
}
|
||||
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
e.certState.undelivered()
|
||||
return errSystemInfoTimeout
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
return e.sendMetaWithProofs(info, checks)
|
||||
}
|
||||
|
||||
// stopping reports whether the engine is shutting down.
|
||||
func (e *Engine) stopping() bool {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
|
||||
// with the cached certificate proofs. The caller holds syncMsgMux.
|
||||
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
|
||||
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
|
||||
e.certState.undelivered()
|
||||
return errSystemInfoTimeout
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
return e.sendMetaWithProofs(info, checks)
|
||||
}
|
||||
|
||||
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
|
||||
// are serialized and read the cache under that lock, so a send from the sync path cannot
|
||||
// overwrite newer proofs the watcher sent in the meantime.
|
||||
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
|
||||
e.certSendMu.Lock()
|
||||
defer e.certSendMu.Unlock()
|
||||
|
||||
info.CertificateProofs = e.certState.cachedProofs(checks)
|
||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||
e.certState.undelivered()
|
||||
return fmt.Errorf("sync meta: %w", err)
|
||||
}
|
||||
e.certState.markDelivered(info.CertificateProofs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestCertPostureState_Record(t *testing.T) {
|
||||
now := time.Now()
|
||||
one := []certposture.Proof{{}}
|
||||
|
||||
var proven certPostureState
|
||||
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
|
||||
|
||||
var unproven certPostureState
|
||||
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
|
||||
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
|
||||
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
|
||||
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
|
||||
}
|
||||
|
||||
// TestCertPostureState_ReportsStuckOnce: a store that stopped answering is reported to
|
||||
// the user once when it wedges the collector, not on every collection that follows, and
|
||||
// again if it wedges anew after recovering.
|
||||
func TestCertPostureState_ReportsStuckOnce(t *testing.T) {
|
||||
var s certPostureState
|
||||
assert.True(t, s.setStuck(true), "becoming stuck is reported")
|
||||
assert.False(t, s.setStuck(true), "staying stuck is not reported again")
|
||||
assert.False(t, s.setStuck(false), "recovering is not a stuck report")
|
||||
assert.True(t, s.setStuck(true), "getting stuck again is reported again")
|
||||
}
|
||||
|
||||
func TestCertPostureState_NeedsCollection(t *testing.T) {
|
||||
now := time.Now()
|
||||
one := []certposture.Proof{{}}
|
||||
var s certPostureState
|
||||
|
||||
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
|
||||
|
||||
s.record("k", "501:alice", one, now)
|
||||
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
|
||||
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
|
||||
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
|
||||
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
|
||||
|
||||
s.record("k", "", nil, now)
|
||||
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
|
||||
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
|
||||
|
||||
s.record("k", "501:alice", one, now)
|
||||
s.undelivered()
|
||||
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
|
||||
}
|
||||
|
||||
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
|
||||
// window and accepts the previous one, so a cached proof keeps being attached across one
|
||||
// rotation, while the watcher signs the new nonce, and not across two.
|
||||
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
||||
now := time.Now()
|
||||
checksAt := func(at time.Time) []*mgmProto.Checks {
|
||||
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
|
||||
}
|
||||
|
||||
var s certPostureState
|
||||
current := checksAt(now)
|
||||
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
|
||||
|
||||
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
|
||||
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
|
||||
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
|
||||
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
|
||||
}
|
||||
|
||||
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
|
||||
// whose management client records the proofs of every meta sync.
|
||||
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
|
||||
t.Helper()
|
||||
// The store refuses a group-writable directory, which t.TempDir yields under a
|
||||
// user-private-group umask.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o700))
|
||||
t.Setenv(certproof.StoreDirEnv, dir)
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
e := &Engine{
|
||||
ctx: context.Background(),
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: &EngineConfig{WgPrivateKey: key},
|
||||
statusRecorder: recorder,
|
||||
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
|
||||
}
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
peerKey := key.PublicKey()
|
||||
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
|
||||
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
return e, dir, ca
|
||||
}
|
||||
|
||||
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
|
||||
t.Helper()
|
||||
deviceKey := certtest.ECDSAKey(t)
|
||||
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
||||
return pem
|
||||
}
|
||||
|
||||
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
|
||||
// watcher collected, so a certificate that appears in the store is not proven on the sync
|
||||
// path until the watcher ran.
|
||||
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
|
||||
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
|
||||
writeDeviceCert(t, dir, ca)
|
||||
|
||||
info := &system.Info{}
|
||||
e.attachCertificateProofs(info, e.checks)
|
||||
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
e.attachCertificateProofs(info, e.checks)
|
||||
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
|
||||
}
|
||||
|
||||
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
|
||||
// certificate gets one warning, and one notice when it can again.
|
||||
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
|
||||
recorder := peer.NewRecorder("")
|
||||
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
|
||||
warnings := func() int {
|
||||
n := 0
|
||||
for _, ev := range recorder.GetEventHistory() {
|
||||
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
|
||||
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
|
||||
|
||||
writeDeviceCert(t, dir, ca)
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
events := recorder.GetEventHistory()
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
|
||||
}
|
||||
|
||||
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
|
||||
// and push network maps, so a collection that proves the same chains as before must not
|
||||
// send, while a new certificate or a failed delivery must.
|
||||
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
|
||||
var sent [][]certposture.Proof
|
||||
failSync := false
|
||||
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
|
||||
if failSync {
|
||||
return errors.New("management unavailable")
|
||||
}
|
||||
sent = append(sent, info.CertificateProofs)
|
||||
return nil
|
||||
})
|
||||
// expire makes the last collection due for the unproven retry.
|
||||
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
||||
// switchUser makes the last collection stale as if another user signed in.
|
||||
switchUser := func() { e.certState.userContext = "someone else" }
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
require.Len(t, sent, 1, "the first collection is sent")
|
||||
assert.Empty(t, sent[0], "nothing to prove yet")
|
||||
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 1, "still proving nothing is not sent again")
|
||||
|
||||
pem := writeDeviceCert(t, dir, ca)
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
require.Len(t, sent, 2, "a newly proven certificate is sent")
|
||||
assert.Len(t, sent[1], 1, "the new proof is attached")
|
||||
|
||||
switchUser()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
|
||||
|
||||
failSync = true
|
||||
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
|
||||
switchUser()
|
||||
require.Error(t, e.refreshCertificateProofs())
|
||||
failSync = false
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
||||
|
||||
// Management missed the empty proof set, so it still holds the chain the device proves
|
||||
// again now; after a failed delivery the state is unknown, and it is sent regardless.
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
|
||||
}
|
||||
@@ -359,6 +359,111 @@ func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
|
||||
assert.Equal(t, 2, syncMetaCalls)
|
||||
}
|
||||
|
||||
// TestEngine_FailedUpdateReplacesOlderPendingChecks: checks A time out and stay pending,
|
||||
// then checks B fail to sync for another reason. B must replace A as pending, or the
|
||||
// watcher would later apply the superseded A.
|
||||
func TestEngine_FailedUpdateReplacesOlderPendingChecks(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun107",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33102,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: &mgmt.MockClient{SyncMetaFunc: func(*system.Info) error { return errors.New("management unavailable") }},
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
|
||||
checksA := []*mgmtProto.Checks{{Files: []string{"/checks/a"}}}
|
||||
checksB := []*mgmtProto.Checks{{Files: []string{"/checks/b"}}}
|
||||
|
||||
engine.infoTimeout = time.Nanosecond
|
||||
require.NoError(t, engine.updateChecksIfNew(checksA))
|
||||
require.Equal(t, checksA, engine.pendingChecks, "precondition: the timed-out checks are pending")
|
||||
|
||||
// Let the abandoned gathering finish so B gets as far as the meta sync.
|
||||
engine.infoTimeout = 0
|
||||
require.Eventually(t, func() bool {
|
||||
_, ok := engine.infoSource.Refresh(ctx, 10*time.Second, nil)
|
||||
return ok
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
|
||||
require.Error(t, engine.updateChecksIfNew(checksB), "the meta sync of B fails")
|
||||
assert.Equal(t, checksB, engine.pendingChecks, "the newest checks replace the older pending ones")
|
||||
}
|
||||
|
||||
// TestEngine_PendingChecksRetriedAfterInfoTimeout covers a check update whose system info
|
||||
// gathering times out: the update is kept pending rather than dropped, and the posture
|
||||
// watcher's retry sends it and applies the checks, without waiting for management to
|
||||
// send different checks.
|
||||
func TestEngine_PendingChecksRetriedAfterInfoTimeout(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
var synced []*system.Info
|
||||
mgmClient := &mgmt.MockClient{
|
||||
SyncMetaFunc: func(info *system.Info) error {
|
||||
synced = append(synced, info)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun106",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33101,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: mgmClient,
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
|
||||
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
|
||||
|
||||
// Gathering cannot finish within a nanosecond, so the sync times out.
|
||||
engine.infoTimeout = time.Nanosecond
|
||||
require.NoError(t, engine.updateChecksIfNew(checks), "a timed-out gathering is not an error")
|
||||
assert.Empty(t, synced, "nothing is sent when gathering timed out")
|
||||
assert.Nil(t, engine.checks, "timed-out checks are not applied")
|
||||
require.True(t, engine.hasPendingChecks.Load(), "timed-out checks are kept pending")
|
||||
|
||||
// The timed-out gathering keeps running in the background, and no new one starts
|
||||
// until it exits, so the retry may time out again before it goes through.
|
||||
engine.infoTimeout = 0
|
||||
var retryErr error
|
||||
require.Eventually(t, func() bool {
|
||||
retryErr = engine.retryPendingChecks()
|
||||
return retryErr == nil || !errors.Is(retryErr, errSystemInfoTimeout)
|
||||
}, 10*time.Second, 10*time.Millisecond, "the pending checks are sent once the earlier gathering exits")
|
||||
require.NoError(t, retryErr)
|
||||
require.Len(t, synced, 1, "the retry sends the meta sync")
|
||||
assert.Len(t, synced[0].Files, 1, "the retry evaluates the pending checks")
|
||||
assert.Equal(t, checks, engine.checks, "the retried checks are applied")
|
||||
assert.False(t, engine.hasPendingChecks.Load(), "nothing is pending after the retry")
|
||||
|
||||
require.NoError(t, engine.retryPendingChecks())
|
||||
require.NoError(t, engine.updateChecksIfNew(checks))
|
||||
assert.Len(t, synced, 1, "applied checks are not sent again")
|
||||
}
|
||||
|
||||
func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
// test setup
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
@@ -1179,6 +1284,32 @@ func Test_CheckFilesEqual(t *testing.T) {
|
||||
},
|
||||
expectedBool: true,
|
||||
},
|
||||
{
|
||||
name: "Same files with rotated certificate challenge nonce should return false",
|
||||
inputChecks1: []*mgmtProto.Checks{
|
||||
{
|
||||
Files: []string{"testfile1"},
|
||||
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a"}},
|
||||
},
|
||||
},
|
||||
inputChecks2: []*mgmtProto.Checks{
|
||||
{
|
||||
Files: []string{"testfile1"},
|
||||
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{2}, CaCertificates: []string{"ca-a"}},
|
||||
},
|
||||
},
|
||||
expectedBool: false,
|
||||
},
|
||||
{
|
||||
name: "Same certificate challenge with CA certificates in different order should return true",
|
||||
inputChecks1: []*mgmtProto.Checks{
|
||||
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a", "ca-b"}}},
|
||||
},
|
||||
inputChecks2: []*mgmtProto.Checks{
|
||||
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-b", "ca-a"}}},
|
||||
},
|
||||
expectedBool: true,
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//go:build pkcs11 && linux && !android && (amd64 || arm64)
|
||||
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
|
||||
// this file builds for. The struct layouts below assume that width and natural alignment.
|
||||
type ulong = uintptr
|
||||
|
||||
const (
|
||||
unavailableInformation = ^ulong(0)
|
||||
|
||||
flagOSLockingOK = 0x2
|
||||
flagRWSession = 0x2
|
||||
flagSerialSession = 0x4
|
||||
userTypeUser = 0x1
|
||||
|
||||
findBatch = 32
|
||||
|
||||
// Upper bounds on sizes a module reports, so a buggy module cannot make the daemon
|
||||
// allocate without limit. Certificates and keys are far smaller; an RSA-16384
|
||||
// signature is 2 KiB.
|
||||
maxAttributeLen = 1 << 20
|
||||
maxSignatureLen = 1 << 16
|
||||
)
|
||||
|
||||
type version struct {
|
||||
major byte
|
||||
minor byte
|
||||
}
|
||||
|
||||
type attribute struct {
|
||||
typ ulong
|
||||
value unsafe.Pointer
|
||||
len ulong
|
||||
}
|
||||
|
||||
type mechanism struct {
|
||||
typ ulong
|
||||
parameter unsafe.Pointer
|
||||
len ulong
|
||||
}
|
||||
|
||||
type pssParams struct {
|
||||
hashAlg ulong
|
||||
mgf ulong
|
||||
saltLen ulong
|
||||
}
|
||||
|
||||
type tokenInfo struct {
|
||||
label [32]byte
|
||||
manufacturerID [32]byte
|
||||
model [16]byte
|
||||
serialNumber [16]byte
|
||||
flags ulong
|
||||
counters [10]ulong
|
||||
hardware version
|
||||
firmware version
|
||||
utcTime [16]byte
|
||||
}
|
||||
|
||||
type initializeArgs struct {
|
||||
createMutex uintptr
|
||||
destroyMutex uintptr
|
||||
lockMutex uintptr
|
||||
unlockMutex uintptr
|
||||
flags ulong
|
||||
reserved unsafe.Pointer
|
||||
}
|
||||
|
||||
// functionList mirrors CK_FUNCTION_LIST: a CK_VERSION padded to pointer alignment, then
|
||||
// the PKCS#11 v2.40 entry points in specification order.
|
||||
type functionList struct {
|
||||
version version
|
||||
_ [6]byte
|
||||
fn [68]uintptr
|
||||
}
|
||||
|
||||
const (
|
||||
fnInitialize = 0
|
||||
fnGetSlotList = 4
|
||||
fnGetTokenInfo = 6
|
||||
fnOpenSession = 12
|
||||
fnCloseSession = 13
|
||||
fnLogin = 18
|
||||
fnLogout = 19
|
||||
fnCreateObject = 20
|
||||
fnGetAttributeValue = 24
|
||||
fnFindObjectsInit = 26
|
||||
fnFindObjects = 27
|
||||
fnFindObjectsFinal = 28
|
||||
fnSignInit = 42
|
||||
fnSign = 43
|
||||
)
|
||||
|
||||
// module holds the entry points of one loaded library, bound straight from its
|
||||
// CK_FUNCTION_LIST.
|
||||
type module struct {
|
||||
cInitialize func(args *initializeArgs) ulong
|
||||
cGetSlotList func(tokenPresent byte, slots *ulong, count *ulong) ulong
|
||||
cGetTokenInfo func(slot ulong, info *tokenInfo) ulong
|
||||
cOpenSession func(slot ulong, flags ulong, application unsafe.Pointer, notify uintptr, session *ulong) ulong
|
||||
cCloseSession func(session ulong) ulong
|
||||
cLogin func(session ulong, userType ulong, pin *byte, pinLen ulong) ulong
|
||||
cLogout func(session ulong) ulong
|
||||
cCreateObject func(session ulong, template *attribute, count ulong, object *ulong) ulong
|
||||
cGetAttributeValue func(session ulong, object ulong, template *attribute, count ulong) ulong
|
||||
cFindObjectsInit func(session ulong, template *attribute, count ulong) ulong
|
||||
cFindObjects func(session ulong, objects *ulong, max ulong, count *ulong) ulong
|
||||
cFindObjectsFinal func(session ulong) ulong
|
||||
cSignInit func(session ulong, mech *mechanism, key ulong) ulong
|
||||
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
|
||||
}
|
||||
|
||||
// Supported reports whether this build can load PKCS#11 modules.
|
||||
func Supported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func load(path string) (_ driver, err error) {
|
||||
lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open PKCS#11 module %s: %w", path, err)
|
||||
}
|
||||
// A module that loads but cannot be used is not cached, so each later attempt opens
|
||||
// it again; close it here or every attempt keeps another reference to the library.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
if closeErr := purego.Dlclose(lib); closeErr != nil {
|
||||
log.Debugf("failed closing PKCS#11 module %s: %v", path, closeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
|
||||
}
|
||||
var getFunctionList func(list **functionList) ulong
|
||||
purego.RegisterFunc(&getFunctionList, symbol)
|
||||
var list *functionList
|
||||
if rv := getFunctionList(&list); rv != rvOK || list == nil {
|
||||
return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)}
|
||||
}
|
||||
|
||||
m := &module{}
|
||||
for _, entry := range []struct {
|
||||
fn any
|
||||
index int
|
||||
}{
|
||||
{&m.cInitialize, fnInitialize},
|
||||
{&m.cGetSlotList, fnGetSlotList},
|
||||
{&m.cGetTokenInfo, fnGetTokenInfo},
|
||||
{&m.cOpenSession, fnOpenSession},
|
||||
{&m.cCloseSession, fnCloseSession},
|
||||
{&m.cLogin, fnLogin},
|
||||
{&m.cLogout, fnLogout},
|
||||
{&m.cCreateObject, fnCreateObject},
|
||||
{&m.cGetAttributeValue, fnGetAttributeValue},
|
||||
{&m.cFindObjectsInit, fnFindObjectsInit},
|
||||
{&m.cFindObjects, fnFindObjects},
|
||||
{&m.cFindObjectsFinal, fnFindObjectsFinal},
|
||||
{&m.cSignInit, fnSignInit},
|
||||
{&m.cSign, fnSign},
|
||||
} {
|
||||
if list.fn[entry.index] == 0 {
|
||||
return nil, fmt.Errorf("%s lacks PKCS#11 entry point %d", path, entry.index)
|
||||
}
|
||||
purego.RegisterFunc(entry.fn, list.fn[entry.index])
|
||||
}
|
||||
|
||||
args := &initializeArgs{flags: flagOSLockingOK}
|
||||
if rv := m.cInitialize(args); rv != rvOK && rv != rvAlreadyInitialized {
|
||||
return nil, Error{Op: "C_Initialize", Code: uint(rv)}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *module) tokens() ([]Token, error) {
|
||||
var count ulong
|
||||
if rv := m.cGetSlotList(1, nil, &count); rv != rvOK {
|
||||
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
slots := make([]ulong, count)
|
||||
if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK {
|
||||
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
|
||||
}
|
||||
|
||||
tokens := make([]Token, 0, count)
|
||||
for _, slot := range slots[:count] {
|
||||
var info tokenInfo
|
||||
if rv := m.cGetTokenInfo(slot, &info); rv != rvOK {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, Token{Slot: uint(slot), Label: strings.TrimRight(string(info.label[:]), " \x00")})
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (m *module) openSession(slot uint, readWrite bool) (uint, error) {
|
||||
flags := ulong(flagSerialSession)
|
||||
if readWrite {
|
||||
flags |= flagRWSession
|
||||
}
|
||||
var session ulong
|
||||
if rv := m.cOpenSession(ulong(slot), flags, nil, 0, &session); rv != rvOK {
|
||||
return 0, Error{Op: "C_OpenSession", Code: uint(rv)}
|
||||
}
|
||||
return uint(session), nil
|
||||
}
|
||||
|
||||
func (m *module) closeSession(session uint) {
|
||||
m.cCloseSession(ulong(session))
|
||||
}
|
||||
|
||||
func (m *module) login(session uint, pin []byte) error {
|
||||
var pinPtr *byte
|
||||
if len(pin) > 0 {
|
||||
pinPtr = &pin[0]
|
||||
}
|
||||
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
|
||||
runtime.KeepAlive(pin)
|
||||
if rv != rvOK {
|
||||
return Error{Op: "C_Login", Code: uint(rv)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) logout(session uint) {
|
||||
m.cLogout(ulong(session))
|
||||
}
|
||||
|
||||
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
|
||||
var pinner runtime.Pinner
|
||||
attrs := toAttributes(template, &pinner)
|
||||
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
|
||||
pinner.Unpin()
|
||||
if rv != rvOK {
|
||||
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
|
||||
}
|
||||
defer m.cFindObjectsFinal(ulong(session))
|
||||
|
||||
var objects []Object
|
||||
for {
|
||||
var batch [findBatch]ulong
|
||||
var count ulong
|
||||
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
|
||||
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
|
||||
}
|
||||
if count > findBatch {
|
||||
return nil, fmt.Errorf("C_FindObjects reported %d handles for a batch of %d", count, findBatch)
|
||||
}
|
||||
for _, handle := range batch[:count] {
|
||||
objects = append(objects, Object(handle))
|
||||
}
|
||||
if count < findBatch {
|
||||
return objects, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
|
||||
attr := attribute{typ: ulong(typ)}
|
||||
if rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1); rv != rvOK {
|
||||
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
|
||||
}
|
||||
if attr.len == unavailableInformation {
|
||||
return nil, fmt.Errorf("attribute 0x%x is unavailable", typ)
|
||||
}
|
||||
if attr.len == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if attr.len > maxAttributeLen {
|
||||
return nil, fmt.Errorf("attribute 0x%x reports %d bytes, over the %d byte limit", typ, attr.len, maxAttributeLen)
|
||||
}
|
||||
value := make([]byte, attr.len)
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(&value[0])
|
||||
attr.value = unsafe.Pointer(&value[0])
|
||||
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
|
||||
pinner.Unpin()
|
||||
if rv != rvOK {
|
||||
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
|
||||
}
|
||||
if attr.len > ulong(len(value)) {
|
||||
return nil, fmt.Errorf("attribute 0x%x grew from %d to %d bytes between calls", typ, len(value), attr.len)
|
||||
}
|
||||
return value[:attr.len], nil
|
||||
}
|
||||
|
||||
func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("nothing to sign")
|
||||
}
|
||||
native := mechanism{typ: ulong(mech.Type)}
|
||||
var pinner runtime.Pinner
|
||||
if mech.PSS != nil {
|
||||
params := &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
|
||||
pinner.Pin(params)
|
||||
native.parameter = unsafe.Pointer(params)
|
||||
native.len = ulong(unsafe.Sizeof(*params))
|
||||
}
|
||||
rv := m.cSignInit(ulong(session), &native, ulong(key))
|
||||
pinner.Unpin()
|
||||
if rv != rvOK {
|
||||
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
|
||||
}
|
||||
|
||||
var size ulong
|
||||
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
|
||||
return nil, Error{Op: "C_Sign", Code: uint(rv)}
|
||||
}
|
||||
if size == 0 || size > maxSignatureLen {
|
||||
return nil, fmt.Errorf("C_Sign reports a %d byte signature", size)
|
||||
}
|
||||
signature := make([]byte, size)
|
||||
rv = m.cSign(ulong(session), &data[0], ulong(len(data)), &signature[0], &size)
|
||||
runtime.KeepAlive(data)
|
||||
if rv != rvOK {
|
||||
return nil, Error{Op: "C_Sign", Code: uint(rv)}
|
||||
}
|
||||
if size > ulong(len(signature)) {
|
||||
return nil, fmt.Errorf("C_Sign wrote %d bytes into a %d byte buffer", size, len(signature))
|
||||
}
|
||||
return signature[:size], nil
|
||||
}
|
||||
|
||||
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
|
||||
var pinner runtime.Pinner
|
||||
attrs := toAttributes(template, &pinner)
|
||||
var object ulong
|
||||
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
|
||||
pinner.Unpin()
|
||||
if rv != rvOK {
|
||||
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
|
||||
}
|
||||
return Object(object), nil
|
||||
}
|
||||
|
||||
// toAttributes builds the C template for template. The values stay Go memory referenced
|
||||
// from Go memory passed to C, so each is pinned; the caller unpins after the call.
|
||||
func toAttributes(template []Attribute, pinner *runtime.Pinner) []attribute {
|
||||
attrs := make([]attribute, len(template))
|
||||
for i, a := range template {
|
||||
attrs[i].typ = ulong(a.Type)
|
||||
if len(a.Value) > 0 {
|
||||
pinner.Pin(&a.Value[0])
|
||||
attrs[i].value = unsafe.Pointer(&a.Value[0])
|
||||
attrs[i].len = ulong(len(a.Value))
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func first(attrs []attribute) *attribute {
|
||||
if len(attrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &attrs[0]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build pkcs11 && linux && !android && (amd64 || arm64)
|
||||
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStructLayoutsMatchTheCABI(t *testing.T) {
|
||||
assert.Equal(t, uintptr(24), unsafe.Sizeof(attribute{}), "CK_ATTRIBUTE")
|
||||
assert.Equal(t, uintptr(24), unsafe.Sizeof(mechanism{}), "CK_MECHANISM")
|
||||
assert.Equal(t, uintptr(24), unsafe.Sizeof(pssParams{}), "CK_RSA_PKCS_PSS_PARAMS")
|
||||
assert.Equal(t, uintptr(208), unsafe.Sizeof(tokenInfo{}), "CK_TOKEN_INFO")
|
||||
assert.Equal(t, uintptr(48), unsafe.Sizeof(initializeArgs{}), "CK_C_INITIALIZE_ARGS")
|
||||
assert.Equal(t, uintptr(8), unsafe.Offsetof(functionList{}.fn), "entry points follow the padded CK_VERSION")
|
||||
assert.Equal(t, uintptr(8+68*8), unsafe.Sizeof(functionList{}), "CK_FUNCTION_LIST v2.40")
|
||||
}
|
||||
|
||||
// TestTrustModule_ListsSystemCertificates drives a real module through the binding:
|
||||
// p11-kit's trust module exposes the system CA store as certificate objects with no login.
|
||||
func TestTrustModule_ListsSystemCertificates(t *testing.T) {
|
||||
module := loadFirst(t,
|
||||
"/usr/lib/pkcs11/p11-kit-trust.so",
|
||||
"/usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so",
|
||||
"/usr/lib/aarch64-linux-gnu/pkcs11/p11-kit-trust.so",
|
||||
"/usr/lib64/pkcs11/p11-kit-trust.so",
|
||||
)
|
||||
|
||||
tokens, err := module.Tokens()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, tokens, "the trust module must present at least one token")
|
||||
|
||||
parsed := 0
|
||||
for _, token := range tokens {
|
||||
session, err := module.OpenSession(token.Label, nil)
|
||||
require.NoError(t, err, token.Label)
|
||||
objects, err := session.FindObjects(
|
||||
Attribute{Type: AttrClass, Value: ULong(ClassCertificate)},
|
||||
Attribute{Type: AttrCertificateType, Value: ULong(CertificateX509)},
|
||||
)
|
||||
require.NoError(t, err, token.Label)
|
||||
for _, object := range objects {
|
||||
der, err := session.Attribute(object, AttrValue)
|
||||
require.NoError(t, err)
|
||||
_, err = x509.ParseCertificate(der)
|
||||
require.NoError(t, err, "CKA_VALUE must be a DER certificate")
|
||||
parsed++
|
||||
}
|
||||
session.Close()
|
||||
}
|
||||
assert.Positive(t, parsed, "system trust anchors must be readable through the binding")
|
||||
}
|
||||
|
||||
func loadFirst(t *testing.T, paths ...string) *Module {
|
||||
t.Helper()
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
module, err := Load(path)
|
||||
require.NoError(t, err, path)
|
||||
return module
|
||||
}
|
||||
t.Skip("p11-kit trust module not installed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !(pkcs11 && linux && !android && (amd64 || arm64))
|
||||
|
||||
package pkcs11
|
||||
|
||||
// Supported reports whether this build can load PKCS#11 modules.
|
||||
func Supported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func load(string) (driver, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPINRejected(t *testing.T) {
|
||||
for _, code := range []uint{rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked} {
|
||||
err := fmt.Errorf("open session: %w", Error{Op: "C_Login", Code: code})
|
||||
assert.True(t, PINRejected(err), "CKR 0x%x refuses the PIN, also when wrapped", code)
|
||||
}
|
||||
assert.False(t, PINRejected(Error{Op: "C_Login", Code: 0x30}), "a device error says nothing about the PIN")
|
||||
assert.False(t, PINRejected(errors.New("CKR_PIN_INCORRECT")), "only a PKCS#11 return value counts")
|
||||
assert.False(t, PINRejected(nil))
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Package pkcs11 is a minimal PKCS#11 client. It loads a module at runtime without cgo,
|
||||
// opens a token session, lists objects and signs with keys the token holds. It exists so
|
||||
// certificates whose keys live in a TPM behind tpm2-pkcs11 can be proven; whatever a
|
||||
// certificate store does not need is left out.
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Object classes, attribute types, mechanisms and generators from PKCS#11 v2.40.
|
||||
const (
|
||||
ClassCertificate = 0x1
|
||||
ClassPublicKey = 0x2
|
||||
ClassPrivateKey = 0x3
|
||||
|
||||
CertificateX509 = 0x0
|
||||
|
||||
AttrClass = 0x0
|
||||
AttrToken = 0x1
|
||||
AttrLabel = 0x3
|
||||
AttrValue = 0x11
|
||||
AttrCertificateType = 0x80
|
||||
AttrKeyType = 0x100
|
||||
AttrSubject = 0x101
|
||||
AttrID = 0x102
|
||||
AttrModulus = 0x120
|
||||
AttrPublicExponent = 0x122
|
||||
AttrECParams = 0x180
|
||||
AttrECPoint = 0x181
|
||||
|
||||
KeyRSA = 0x0
|
||||
KeyEC = 0x3
|
||||
|
||||
MechRSAPKCSPSS = 0xd
|
||||
MechSHA256 = 0x250
|
||||
MechSHA384 = 0x260
|
||||
MechECDSA = 0x1041
|
||||
|
||||
MGF1SHA256 = 0x2
|
||||
MGF1SHA384 = 0x3
|
||||
|
||||
rvOK = 0x0
|
||||
rvPINIncorrect = 0xa0
|
||||
rvPINInvalid = 0xa1
|
||||
rvPINLenRange = 0xa2
|
||||
rvPINExpired = 0xa3
|
||||
rvPINLocked = 0xa4
|
||||
rvUserAlreadyLoggedIn = 0x100
|
||||
rvAlreadyInitialized = 0x191
|
||||
)
|
||||
|
||||
var ErrUnsupported = errors.New("PKCS#11 modules need a build with the pkcs11 tag on linux/amd64 or linux/arm64")
|
||||
|
||||
// Error is a PKCS#11 return value other than CKR_OK.
|
||||
type Error struct {
|
||||
Op string
|
||||
Code uint
|
||||
}
|
||||
|
||||
func (e Error) Error() string {
|
||||
if name, ok := returnValueNames[e.Code]; ok {
|
||||
return fmt.Sprintf("%s: %s", e.Op, name)
|
||||
}
|
||||
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
|
||||
}
|
||||
|
||||
// PINRejected reports whether err is the token refusing the user PIN. Retrying the same
|
||||
// PIN cannot succeed, and each attempt counts towards the token's lockout.
|
||||
func PINRejected(err error) bool {
|
||||
var e Error
|
||||
if !errors.As(err, &e) {
|
||||
return false
|
||||
}
|
||||
switch e.Code {
|
||||
case rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCode(err error, code uint) bool {
|
||||
var e Error
|
||||
return errors.As(err, &e) && e.Code == code
|
||||
}
|
||||
|
||||
var returnValueNames = map[uint]string{
|
||||
0x2: "CKR_HOST_MEMORY",
|
||||
0x3: "CKR_SLOT_ID_INVALID",
|
||||
0x5: "CKR_GENERAL_ERROR",
|
||||
0x7: "CKR_ARGUMENTS_BAD",
|
||||
0x12: "CKR_ATTRIBUTE_TYPE_INVALID",
|
||||
0x13: "CKR_ATTRIBUTE_VALUE_INVALID",
|
||||
0x30: "CKR_DEVICE_ERROR",
|
||||
0x54: "CKR_FUNCTION_NOT_SUPPORTED",
|
||||
0x68: "CKR_KEY_FUNCTION_NOT_PERMITTED",
|
||||
0x70: "CKR_MECHANISM_INVALID",
|
||||
0x71: "CKR_MECHANISM_PARAM_INVALID",
|
||||
0x82: "CKR_OBJECT_HANDLE_INVALID",
|
||||
0xa0: "CKR_PIN_INCORRECT",
|
||||
0xa1: "CKR_PIN_INVALID",
|
||||
0xa2: "CKR_PIN_LEN_RANGE",
|
||||
0xa3: "CKR_PIN_EXPIRED",
|
||||
0xa4: "CKR_PIN_LOCKED",
|
||||
0xb3: "CKR_SESSION_HANDLE_INVALID",
|
||||
0xd0: "CKR_TEMPLATE_INCOMPLETE",
|
||||
0xd1: "CKR_TEMPLATE_INCONSISTENT",
|
||||
0xe0: "CKR_TOKEN_NOT_PRESENT",
|
||||
0x101: "CKR_USER_NOT_LOGGED_IN",
|
||||
0x150: "CKR_BUFFER_TOO_SMALL",
|
||||
0x190: "CKR_CRYPTOKI_NOT_INITIALIZED",
|
||||
}
|
||||
|
||||
// Attribute is one entry of a PKCS#11 template. Integer-valued attributes such as the
|
||||
// object class are encoded with ULong.
|
||||
type Attribute struct {
|
||||
Type uint
|
||||
Value []byte
|
||||
}
|
||||
|
||||
// Mechanism selects a signing algorithm. PSS carries the parameters CKM_RSA_PKCS_PSS needs.
|
||||
type Mechanism struct {
|
||||
Type uint
|
||||
PSS *PSSParams
|
||||
}
|
||||
|
||||
type PSSParams struct {
|
||||
Hash uint
|
||||
MGF uint
|
||||
SaltLen uint
|
||||
}
|
||||
|
||||
// Object is a handle the token issued for one of its objects.
|
||||
type Object uint
|
||||
|
||||
// Token is a slot with a token present.
|
||||
type Token struct {
|
||||
Slot uint
|
||||
Label string
|
||||
}
|
||||
|
||||
// Module is a loaded and initialised PKCS#11 library. A module is loaded once per path
|
||||
// and never finalised: tokens such as tpm2-pkcs11 do real work in C_Initialize, and the
|
||||
// process exit releases everything anyway.
|
||||
type Module struct {
|
||||
d driver
|
||||
|
||||
// loginMu guards logins, the number of open sessions relying on the user login of
|
||||
// each slot, and serializes logging in so a PIN is never sent twice at once.
|
||||
loginMu sync.Mutex
|
||||
logins map[uint]int
|
||||
}
|
||||
|
||||
var (
|
||||
modulesMu sync.Mutex
|
||||
modules = map[string]*Module{}
|
||||
)
|
||||
|
||||
// Load opens the shared library at path and initialises it, or returns the module already
|
||||
// loaded from that path.
|
||||
func Load(path string) (*Module, error) {
|
||||
modulesMu.Lock()
|
||||
defer modulesMu.Unlock()
|
||||
if m, ok := modules[path]; ok {
|
||||
return m, nil
|
||||
}
|
||||
d, err := load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &Module{d: d}
|
||||
modules[path] = m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Module) Tokens() ([]Token, error) {
|
||||
return m.d.tokens()
|
||||
}
|
||||
|
||||
// OpenSession opens a read-only session with the token carrying label, or with the first
|
||||
// token when label is empty, and logs in as the user when pin is not nil. An empty,
|
||||
// non-nil pin still logs in.
|
||||
func (m *Module) OpenSession(label string, pin []byte) (*Session, error) {
|
||||
return m.openSession(label, pin, false)
|
||||
}
|
||||
|
||||
// OpenReadWriteSession is OpenSession for callers that create objects on the token.
|
||||
func (m *Module) OpenReadWriteSession(label string, pin []byte) (*Session, error) {
|
||||
return m.openSession(label, pin, true)
|
||||
}
|
||||
|
||||
func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session, error) {
|
||||
token, err := m.token(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := m.d.openSession(token.Slot, readWrite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Session{d: m.d, handle: handle}
|
||||
if pin == nil {
|
||||
return s, nil
|
||||
}
|
||||
if err := m.acquireLogin(token.Slot, handle, pin); err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.module, s.slot = m, token.Slot
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// acquireLogin makes sure the user is logged in to the token in slot and counts the
|
||||
// session as one relying on it. PKCS#11 login state belongs to the application, not to
|
||||
// a session: every session with the token shares it, and logging out from any of them
|
||||
// ends it for all. So the PIN is sent only when no session holds the login yet, and the
|
||||
// last session to close logs out.
|
||||
func (m *Module) acquireLogin(slot, handle uint, pin []byte) error {
|
||||
m.loginMu.Lock()
|
||||
defer m.loginMu.Unlock()
|
||||
if m.logins[slot] > 0 {
|
||||
m.logins[slot]++
|
||||
return nil
|
||||
}
|
||||
if err := m.d.login(handle, pin); err != nil && !isCode(err, rvUserAlreadyLoggedIn) {
|
||||
return err
|
||||
}
|
||||
if m.logins == nil {
|
||||
m.logins = make(map[uint]int)
|
||||
}
|
||||
m.logins[slot] = 1
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseLogin ends the session's share of the login, logging out when it is the last.
|
||||
func (m *Module) releaseLogin(slot, handle uint) {
|
||||
m.loginMu.Lock()
|
||||
defer m.loginMu.Unlock()
|
||||
m.logins[slot]--
|
||||
if m.logins[slot] > 0 {
|
||||
return
|
||||
}
|
||||
delete(m.logins, slot)
|
||||
m.d.logout(handle)
|
||||
}
|
||||
|
||||
func (m *Module) token(label string) (Token, error) {
|
||||
tokens, err := m.Tokens()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if label == "" || token.Label == label {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
if label == "" {
|
||||
return Token{}, errors.New("no token present")
|
||||
}
|
||||
return Token{}, fmt.Errorf("no token labelled %q among %d tokens", label, len(tokens))
|
||||
}
|
||||
|
||||
// Session is an open session with one token. Close releases the session's share of the
|
||||
// login, logging out when no other session relies on it.
|
||||
type Session struct {
|
||||
d driver
|
||||
handle uint
|
||||
// module is set when the session relies on the token's login.
|
||||
module *Module
|
||||
slot uint
|
||||
}
|
||||
|
||||
func (s *Session) Close() {
|
||||
if s.module != nil {
|
||||
s.module.releaseLogin(s.slot, s.handle)
|
||||
}
|
||||
s.d.closeSession(s.handle)
|
||||
}
|
||||
|
||||
// FindObjects returns the handles of every object matching all attributes of template.
|
||||
func (s *Session) FindObjects(template ...Attribute) ([]Object, error) {
|
||||
return s.d.findObjects(s.handle, template)
|
||||
}
|
||||
|
||||
// Attribute reads one attribute of an object.
|
||||
func (s *Session) Attribute(obj Object, typ uint) ([]byte, error) {
|
||||
return s.d.attribute(s.handle, obj, typ)
|
||||
}
|
||||
|
||||
// Sign signs data, normally a digest, with the token-held key in a single operation.
|
||||
func (s *Session) Sign(mech Mechanism, key Object, data []byte) ([]byte, error) {
|
||||
return s.d.sign(s.handle, mech, key, data)
|
||||
}
|
||||
|
||||
// CreateObject stores a new object described by template on the token.
|
||||
func (s *Session) CreateObject(template ...Attribute) (Object, error) {
|
||||
return s.d.createObject(s.handle, template)
|
||||
}
|
||||
|
||||
type driver interface {
|
||||
tokens() ([]Token, error)
|
||||
openSession(slot uint, readWrite bool) (uint, error)
|
||||
closeSession(session uint)
|
||||
login(session uint, pin []byte) error
|
||||
logout(session uint)
|
||||
findObjects(session uint, template []Attribute) ([]Object, error)
|
||||
attribute(session uint, obj Object, typ uint) ([]byte, error)
|
||||
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
|
||||
createObject(session uint, template []Attribute) (Object, error)
|
||||
}
|
||||
|
||||
// ulongSize is the width of CK_ULONG on the 64-bit platforms the driver builds for.
|
||||
const ulongSize = 8
|
||||
|
||||
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
|
||||
func ULong(v uint) []byte {
|
||||
return binary.NativeEndian.AppendUint64(nil, uint64(v))
|
||||
}
|
||||
|
||||
func ulongValue(b []byte) (uint, error) {
|
||||
if len(b) != ulongSize {
|
||||
return 0, fmt.Errorf("CK_ULONG value has %d bytes", len(b))
|
||||
}
|
||||
return uint(binary.NativeEndian.Uint64(b)), nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/asn1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
var curvesByOID = map[string]elliptic.Curve{
|
||||
"1.2.840.10045.3.1.7": elliptic.P256(),
|
||||
"1.3.132.0.34": elliptic.P384(),
|
||||
"1.3.132.0.35": elliptic.P521(),
|
||||
}
|
||||
|
||||
// PublicKey reads a CKO_PUBLIC_KEY object as a Go public key. RSA and EC keys are
|
||||
// supported, the two kinds a certificate posture proof can be signed with.
|
||||
func (s *Session) PublicKey(obj Object) (crypto.PublicKey, error) {
|
||||
raw, err := s.Attribute(obj, AttrKeyType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyType, err := ulongValue(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CKA_KEY_TYPE: %w", err)
|
||||
}
|
||||
switch keyType {
|
||||
case KeyRSA:
|
||||
modulus, exponent, err := s.attributes(obj, AttrModulus, AttrPublicExponent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rsaPublicKey(modulus, exponent)
|
||||
case KeyEC:
|
||||
params, point, err := s.attributes(obj, AttrECParams, AttrECPoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ecPublicKey(params, point)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported key type 0x%x", keyType)
|
||||
}
|
||||
|
||||
func (s *Session) attributes(obj Object, first, second uint) ([]byte, []byte, error) {
|
||||
a, err := s.Attribute(obj, first)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
b, err := s.Attribute(obj, second)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return a, b, nil
|
||||
}
|
||||
|
||||
func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) {
|
||||
e := new(big.Int).SetBytes(exponent)
|
||||
if e.Sign() <= 0 || e.Cmp(big.NewInt(math.MaxInt32)) > 0 {
|
||||
return nil, errors.New("CKA_PUBLIC_EXPONENT is out of range")
|
||||
}
|
||||
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: int(e.Int64())}, nil
|
||||
}
|
||||
|
||||
// ecPublicKey decodes CKA_EC_PARAMS, the named curve OID, and CKA_EC_POINT, the
|
||||
// uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare.
|
||||
func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
|
||||
var oid asn1.ObjectIdentifier
|
||||
if _, err := asn1.Unmarshal(params, &oid); err != nil {
|
||||
return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err)
|
||||
}
|
||||
curve, ok := curvesByOID[oid.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported curve %s", oid)
|
||||
}
|
||||
size := (curve.Params().BitSize + 7) / 8
|
||||
raw := point
|
||||
if len(raw) != 1+2*size {
|
||||
if _, err := asn1.Unmarshal(point, &raw); err != nil {
|
||||
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
|
||||
}
|
||||
}
|
||||
if len(raw) != 1+2*size || raw[0] != 4 {
|
||||
return nil, errors.New("CKA_EC_POINT is not an uncompressed point")
|
||||
}
|
||||
key, err := ecdsa.ParseUncompressedPublicKey(curve, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/asn1"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestECPublicKey(t *testing.T) {
|
||||
curves := []struct {
|
||||
name string
|
||||
curve elliptic.Curve
|
||||
oid asn1.ObjectIdentifier
|
||||
}{
|
||||
{"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}},
|
||||
{"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}},
|
||||
}
|
||||
for _, tt := range curves {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(tt.curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
params, err := asn1.Marshal(tt.oid)
|
||||
require.NoError(t, err)
|
||||
point := uncompressedPoint(key)
|
||||
wrapped, err := asn1.Marshal(point)
|
||||
require.NoError(t, err)
|
||||
|
||||
// PKCS#11 wraps the point in an OCTET STRING, but some modules return it bare.
|
||||
for form, encoded := range map[string][]byte{"DER octet string": wrapped, "bare point": point} {
|
||||
pub, err := ecPublicKey(params, encoded)
|
||||
require.NoError(t, err, form)
|
||||
assert.True(t, key.PublicKey.Equal(pub), "%s must decode to the generated key", form)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestECPublicKey_Rejections(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
p256, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7})
|
||||
require.NoError(t, err)
|
||||
brainpool, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 36, 3, 3, 2, 8, 1, 1, 7})
|
||||
require.NoError(t, err)
|
||||
point := uncompressedPoint(key)
|
||||
|
||||
_, err = ecPublicKey(brainpool, point)
|
||||
assert.Error(t, err, "curves the proof cannot use must be rejected")
|
||||
_, err = ecPublicKey(p256, point[:len(point)-1])
|
||||
assert.Error(t, err, "a truncated point must be rejected")
|
||||
_, err = ecPublicKey([]byte("junk"), point)
|
||||
assert.Error(t, err, "malformed parameters must be rejected")
|
||||
}
|
||||
|
||||
func TestRSAPublicKey(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
pub, err := rsaPublicKey(key.N.Bytes(), big.NewInt(int64(key.E)).Bytes())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, key.PublicKey.Equal(pub), "modulus and exponent must decode to the generated key")
|
||||
|
||||
_, err = rsaPublicKey(key.N.Bytes(), nil)
|
||||
assert.Error(t, err, "a missing exponent must be rejected")
|
||||
}
|
||||
|
||||
func TestULongRoundTrip(t *testing.T) {
|
||||
v, err := ulongValue(ULong(ClassPrivateKey))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint(ClassPrivateKey), v)
|
||||
|
||||
_, err = ulongValue([]byte{1, 2, 3})
|
||||
assert.Error(t, err, "a value of the wrong width must be rejected")
|
||||
}
|
||||
|
||||
func uncompressedPoint(key *ecdsa.PrivateKey) []byte {
|
||||
size := (key.Curve.Params().BitSize + 7) / 8
|
||||
point := append([]byte{4}, key.X.FillBytes(make([]byte, size))...)
|
||||
return append(point, key.Y.FillBytes(make([]byte, size))...)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeDriver stands in for a loaded module and records the calls a session makes.
|
||||
type fakeDriver struct {
|
||||
loginErr error
|
||||
logins int
|
||||
logouts int
|
||||
closes int
|
||||
}
|
||||
|
||||
func (f *fakeDriver) tokens() ([]Token, error) { return []Token{{Slot: 1, Label: "netbird"}}, nil }
|
||||
func (f *fakeDriver) openSession(uint, bool) (uint, error) { return 7, nil }
|
||||
func (f *fakeDriver) closeSession(uint) { f.closes++ }
|
||||
func (f *fakeDriver) login(uint, []byte) error { f.logins++; return f.loginErr }
|
||||
func (f *fakeDriver) logout(uint) { f.logouts++ }
|
||||
func (f *fakeDriver) findObjects(uint, []Attribute) ([]Object, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeDriver) attribute(uint, Object, uint) ([]byte, error) { return nil, nil }
|
||||
func (f *fakeDriver) sign(uint, Mechanism, Object, []byte) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeDriver) createObject(uint, []Attribute) (Object, error) { return 0, nil }
|
||||
|
||||
func TestOpenSession_LogsOutOnlyALoginItOwns(t *testing.T) {
|
||||
t.Run("own login is logged out on close", func(t *testing.T) {
|
||||
d := &fakeDriver{}
|
||||
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
|
||||
require.NoError(t, err)
|
||||
s.Close()
|
||||
assert.Equal(t, 1, d.logouts, "the session that logged in logs out again")
|
||||
})
|
||||
|
||||
t.Run("a login shared by sessions ends with the last one", func(t *testing.T) {
|
||||
d := &fakeDriver{}
|
||||
m := &Module{d: d}
|
||||
first, err := m.OpenSession("netbird", []byte("1234"))
|
||||
require.NoError(t, err)
|
||||
second, err := m.OpenSession("netbird", []byte("1234"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, d.logins, "the second session reuses the login instead of sending the PIN again")
|
||||
|
||||
first.Close()
|
||||
assert.Zero(t, d.logouts, "logging out now would end the login the second session still uses")
|
||||
second.Close()
|
||||
assert.Equal(t, 1, d.logouts, "the last session to close logs out")
|
||||
assert.Equal(t, 2, d.closes, "both sessions are closed")
|
||||
})
|
||||
|
||||
t.Run("an existing login is taken over", func(t *testing.T) {
|
||||
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvUserAlreadyLoggedIn}}
|
||||
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
|
||||
require.NoError(t, err, "an existing login is good enough to use the token")
|
||||
s.Close()
|
||||
assert.Equal(t, 1, d.logouts, "no session of this client holds the login any more, so it ends")
|
||||
})
|
||||
|
||||
t.Run("rejected pin closes the session", func(t *testing.T) {
|
||||
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvPINIncorrect}}
|
||||
_, err := (&Module{d: d}).OpenSession("netbird", []byte("0000"))
|
||||
assert.True(t, PINRejected(err), "the PIN error reaches the caller")
|
||||
assert.Zero(t, d.logouts, "nothing to log out after a failed login")
|
||||
assert.Equal(t, 1, d.closes, "the session opened for the login is closed")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultModule is p11-kit's proxy, which exposes every module the system has registered,
|
||||
// tpm2-pkcs11 included, so a URI without module-path works on a stock p11-kit setup.
|
||||
const DefaultModule = "p11-kit-proxy.so"
|
||||
|
||||
// URI is the subset of an RFC 7512 PKCS#11 URI this client understands: the token label,
|
||||
// the module to load and where the user PIN comes from.
|
||||
type URI struct {
|
||||
Token string
|
||||
ModulePath string
|
||||
pinValue *string
|
||||
pinSource string
|
||||
}
|
||||
|
||||
// ParseURI parses raw following RFC 7512 section 2.3. A path attribute other than token
|
||||
// is refused rather than ignored: the path narrows which token is used, and ignoring a
|
||||
// constraint such as serial would widen the match to whichever token is listed first,
|
||||
// where the RFC calls for no match at all. Duplicate attributes are refused, a
|
||||
// module-path must be absolute and a module-name must be a bare name. Unknown query
|
||||
// attributes are ignored, as the RFC asks.
|
||||
func ParseURI(raw string) (*URI, error) {
|
||||
rest, ok := strings.CutPrefix(raw, "pkcs11:")
|
||||
if !ok {
|
||||
return nil, errors.New("PKCS#11 URI does not start with the pkcs11 scheme")
|
||||
}
|
||||
path, query, _ := strings.Cut(rest, "?")
|
||||
|
||||
u := &URI{}
|
||||
if err := eachAttribute(path, ";", u.setPathAttribute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := eachAttribute(query, "&", u.setQueryAttribute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// RFC 7512 section 2.4: a URI with both pin-value and pin-source should be refused.
|
||||
if u.pinValue != nil && u.pinSource != "" {
|
||||
return nil, errors.New("PKCS#11 URI carries both pin-value and pin-source")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (u *URI) setPathAttribute(name, value string) error {
|
||||
if name != "token" {
|
||||
return fmt.Errorf("PKCS#11 URI path attribute %q is not supported, only token selects a token", name)
|
||||
}
|
||||
u.Token = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *URI) setQueryAttribute(name, value string) error {
|
||||
switch name {
|
||||
case "module-path":
|
||||
if !filepath.IsAbs(value) {
|
||||
return fmt.Errorf("PKCS#11 URI module-path %q must be absolute", value)
|
||||
}
|
||||
u.ModulePath = value
|
||||
case "module-name":
|
||||
if value == "" || strings.ContainsAny(value, `/\`) || strings.Contains(value, "..") {
|
||||
return fmt.Errorf("PKCS#11 URI module-name %q must be a module name, not a path", value)
|
||||
}
|
||||
u.ModulePath = "lib" + value + ".so"
|
||||
case "pin-value":
|
||||
u.pinValue = &value
|
||||
case "pin-source":
|
||||
u.pinSource = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Module is the library to load, DefaultModule when the URI names none.
|
||||
func (u *URI) Module() string {
|
||||
if u.ModulePath == "" {
|
||||
return DefaultModule
|
||||
}
|
||||
return u.ModulePath
|
||||
}
|
||||
|
||||
// HasPIN reports whether the URI carries a PIN, inline or as a pin-source.
|
||||
func (u *URI) HasPIN() bool {
|
||||
return u.pinValue != nil || u.pinSource != ""
|
||||
}
|
||||
|
||||
// PIN returns the user PIN, or nil when the URI carries none and no login should happen.
|
||||
// A pin-source names a file whose single line is the PIN.
|
||||
func (u *URI) PIN() ([]byte, error) {
|
||||
if u.pinValue != nil {
|
||||
return []byte(*u.pinValue), nil
|
||||
}
|
||||
if u.pinSource == "" {
|
||||
return nil, nil
|
||||
}
|
||||
path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:")
|
||||
pin, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read PIN: %w", err)
|
||||
}
|
||||
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
|
||||
}
|
||||
|
||||
// eachAttribute splits list on sep and calls fn for every name=value pair, refusing a
|
||||
// name that appears twice.
|
||||
func eachAttribute(list, sep string, fn func(name, value string) error) error {
|
||||
if list == "" {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, pair := range strings.Split(list, sep) {
|
||||
name, value, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
return fmt.Errorf("PKCS#11 URI attribute %s appears more than once", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
value, err := url.PathUnescape(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
|
||||
}
|
||||
if err := fn(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package pkcs11
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantToken string
|
||||
wantModule string
|
||||
wantPIN []byte
|
||||
}{
|
||||
{
|
||||
name: "token with module path and pin value",
|
||||
raw: "pkcs11:token=netbird?module-path=" + absModule("libtpm2_pkcs11.so") + "&pin-value=1234",
|
||||
wantToken: "netbird",
|
||||
wantModule: absModule("libtpm2_pkcs11.so"),
|
||||
wantPIN: []byte("1234"),
|
||||
},
|
||||
{
|
||||
name: "module name becomes a library file",
|
||||
raw: "pkcs11:token=netbird?module-name=tpm2_pkcs11",
|
||||
wantToken: "netbird",
|
||||
wantModule: "libtpm2_pkcs11.so",
|
||||
},
|
||||
{
|
||||
name: "percent encoding, unknown query attributes are ignored",
|
||||
raw: "pkcs11:token=my%20token?max-sessions=1&vendor-flag=on",
|
||||
wantToken: "my token",
|
||||
wantModule: DefaultModule,
|
||||
},
|
||||
{
|
||||
name: "bare scheme uses the p11-kit proxy and no login",
|
||||
raw: "pkcs11:",
|
||||
wantModule: DefaultModule,
|
||||
},
|
||||
{
|
||||
name: "empty pin value still logs in",
|
||||
raw: "pkcs11:?pin-value=",
|
||||
wantModule: DefaultModule,
|
||||
wantPIN: []byte{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
uri, err := ParseURI(tt.raw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantToken, uri.Token, "token label")
|
||||
assert.Equal(t, tt.wantModule, uri.Module(), "module to load")
|
||||
pin, err := uri.PIN()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURI_Rejections(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"no scheme": "pkcs11",
|
||||
"other scheme": "https://example.com",
|
||||
"attribute no value": "pkcs11:token",
|
||||
"bad percent encoding": "pkcs11:token=%zz",
|
||||
// RFC 7512 2.3: an unrecognized path attribute matches nothing. Ignoring it
|
||||
// instead would let the token listed first answer for the one serial names.
|
||||
"unsupported serial": "pkcs11:token=netbird;serial=1234",
|
||||
"unsupported model": "pkcs11:model=SoftHSM%20v2;token=netbird",
|
||||
"object selector": "pkcs11:token=netbird;object=device",
|
||||
"vendor path attribute": "pkcs11:token=netbird;vendor-slot=2",
|
||||
"duplicate token": "pkcs11:token=a;token=b",
|
||||
"duplicate module-path": "pkcs11:token=a?module-path=" + absModule("a.so") + "&module-path=" + absModule("b.so"),
|
||||
"duplicate pin-value": "pkcs11:token=a?pin-value=1&pin-value=2",
|
||||
"relative module-path": "pkcs11:token=a?module-path=lib/x.so",
|
||||
"bare module-path": "pkcs11:token=a?module-path=libtpm2_pkcs11.so",
|
||||
"module-name with path": "pkcs11:token=a?module-name=../../tmp/evil",
|
||||
"module-name with slash": "pkcs11:token=a?module-name=tmp/evil",
|
||||
"module-name with dotdot": "pkcs11:token=a?module-name=..",
|
||||
"empty module-name": "pkcs11:token=a?module-name=",
|
||||
"pin-value and pin-source": "pkcs11:token=a?pin-value=1&pin-source=file:/etc/pin",
|
||||
}
|
||||
for name, raw := range tests {
|
||||
_, err := ParseURI(raw)
|
||||
assert.Error(t, err, "%s: %s must be refused", name, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURI_PINFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pin")
|
||||
require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600))
|
||||
|
||||
for _, source := range []string{path, "file:" + path, "file://" + path} {
|
||||
uri, err := ParseURI("pkcs11:token=netbird?pin-source=" + source)
|
||||
require.NoError(t, err)
|
||||
pin, err := uri.PIN()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("secret"), pin, "PIN from %s must drop the trailing newline", source)
|
||||
}
|
||||
|
||||
uri, err := ParseURI("pkcs11:?pin-source=" + filepath.Join(t.TempDir(), "missing"))
|
||||
require.NoError(t, err)
|
||||
_, err = uri.PIN()
|
||||
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
|
||||
}
|
||||
|
||||
// absModule is an absolute module path on the platform the test runs on, as module-path
|
||||
// must be absolute.
|
||||
func absModule(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\lib\` + name
|
||||
}
|
||||
return "/lib/" + name
|
||||
}
|
||||
@@ -185,6 +185,16 @@ type Config struct {
|
||||
|
||||
ClientCertKeyPair *tls.Certificate `json:"-"`
|
||||
|
||||
// CertStoreDir is no longer read: certificate posture takes the directory from
|
||||
// NB_CERT_STORE_DIR in the daemon's environment. The field is kept only to report a
|
||||
// value left from an earlier version.
|
||||
CertStoreDir string `json:",omitempty"`
|
||||
|
||||
// CertPKCS11URI is no longer read, as the URI may carry the token PIN: certificate
|
||||
// posture takes it from NB_CERT_PKCS11_URI in the daemon's environment. The field is
|
||||
// kept only to report a value left from an earlier version.
|
||||
CertPKCS11URI string `json:",omitempty"`
|
||||
|
||||
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
|
||||
// Runtime-only: re-derived from MDM policy on each load, never persisted.
|
||||
LazyConnection string `json:"-"`
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/google/go-tpm/tpmutil"
|
||||
)
|
||||
|
||||
// The kernel resource manager multiplexes clients and flushes what they leave behind,
|
||||
// so it is tried before the raw device.
|
||||
var devicePaths = []string{"/dev/tpmrm0", "/dev/tpm0"}
|
||||
|
||||
func open() (io.ReadWriteCloser, error) {
|
||||
if path := os.Getenv(DeviceEnv); path != "" {
|
||||
return tpmutil.OpenTPM(path)
|
||||
}
|
||||
var errs error
|
||||
for _, path := range devicePaths {
|
||||
rwc, err := tpmutil.OpenTPM(path)
|
||||
if err == nil {
|
||||
return rwc, nil
|
||||
}
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
return nil, fmt.Errorf("open TPM: %w", errs)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package tpm
|
||||
|
||||
import "io"
|
||||
|
||||
func open() (io.ReadWriteCloser, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/asn1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
legacy "github.com/google/go-tpm/legacy/tpm2"
|
||||
"github.com/google/go-tpm/tpmutil"
|
||||
)
|
||||
|
||||
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
|
||||
// draft-bottomley-tpm2-keys and written by tpm2-openssl and tpm2-tss-engine.
|
||||
const KeyPEMType = "TSS2 PRIVATE KEY"
|
||||
|
||||
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
|
||||
|
||||
// eccSRKTemplate is the TCG reference ECC-P256 storage root key. A key whose parent is
|
||||
// a hierarchy rather than a persistent handle was wrapped by the primary this template
|
||||
// derives, and tpm2-openssl and tpm2-tss-engine derive the same one, so the TPM
|
||||
// reproduces the identical parent from the hierarchy seed without anything being stored.
|
||||
var eccSRKTemplate = legacy.Public{
|
||||
Type: legacy.AlgECC,
|
||||
NameAlg: legacy.AlgSHA256,
|
||||
Attributes: legacy.FlagStorageDefault | legacy.FlagNoDA,
|
||||
ECCParameters: &legacy.ECCParams{
|
||||
Symmetric: &legacy.SymScheme{Alg: legacy.AlgAES, KeyBits: 128, Mode: legacy.AlgCFB},
|
||||
Sign: &legacy.SigScheme{Alg: legacy.AlgNull},
|
||||
CurveID: legacy.CurveNISTP256,
|
||||
},
|
||||
}
|
||||
|
||||
// ParseKey reads a TSS2 key file and returns a signer that produces every signature
|
||||
// inside the TPM; only the digest goes in and only the signature comes out. Keys guarded
|
||||
// by an authorization value are rejected, since nothing can supply it without prompting.
|
||||
func ParseKey(der []byte) (crypto.Signer, error) {
|
||||
key, err := parseTSS2(der)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !key.EmptyAuth {
|
||||
return nil, ErrKeyNeedsAuth
|
||||
}
|
||||
|
||||
public, err := legacy.DecodePublic(key.PublicKey[2:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode TSS2 public area: %w", err)
|
||||
}
|
||||
pub, err := public.Key()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
|
||||
}
|
||||
return &keySigner{key: key, public: pub}, nil
|
||||
}
|
||||
|
||||
type keySigner struct {
|
||||
key *tss2Key
|
||||
public crypto.PublicKey
|
||||
}
|
||||
|
||||
func (s *keySigner) Public() crypto.PublicKey {
|
||||
return s.public
|
||||
}
|
||||
|
||||
// Sign loads the key under its parent, signs, and releases both handles. The TPM is
|
||||
// opened per signature so no handle outlives the call, which matters on a device whose
|
||||
// transient object slots are few and shared with everything else on the host.
|
||||
func (s *keySigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
rwc, err := Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rwc.Close() }()
|
||||
|
||||
parent := tpmutil.Handle(s.key.Parent)
|
||||
if !persistentHandle(s.key.Parent) {
|
||||
parent, _, err = legacy.CreatePrimary(rwc, parent, legacy.PCRSelection{}, "", "", eccSRKTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create TPM primary: %w", err)
|
||||
}
|
||||
defer func() { _ = legacy.FlushContext(rwc, parent) }()
|
||||
}
|
||||
|
||||
public, private := s.key.blobs()
|
||||
handle, _, err := legacy.Load(rwc, parent, "", public, private)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load TPM key: %w", err)
|
||||
}
|
||||
defer func() { _ = legacy.FlushContext(rwc, handle) }()
|
||||
|
||||
switch pub := s.public.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return signECDSA(rwc, handle, digest, pub.Curve)
|
||||
case *rsa.PublicKey:
|
||||
return signRSA(rwc, handle, digest, opts)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported TPM key type %T", s.public)
|
||||
}
|
||||
}
|
||||
|
||||
// signECDSA returns the signature as the ASN.1 sequence crypto.Signer is defined to
|
||||
// return; the TPM hands back the two integers on their own.
|
||||
func signECDSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, curve elliptic.Curve) ([]byte, error) {
|
||||
hash, err := eccHash(curve)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := legacy.Sign(rw, handle, "", digest, nil, &legacy.SigScheme{Alg: legacy.AlgECDSA, Hash: hash})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TPM ECDSA signature: %w", err)
|
||||
}
|
||||
if sig.ECC == nil {
|
||||
return nil, fmt.Errorf("TPM returned a %v signature for an ECDSA key", sig.Alg)
|
||||
}
|
||||
return asn1.Marshal(struct{ R, S *big.Int }{sig.ECC.R, sig.ECC.S})
|
||||
}
|
||||
|
||||
func signRSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
hash, err := legacy.HashToAlgorithm(opts.HashFunc())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TPM hash algorithm: %w", err)
|
||||
}
|
||||
|
||||
scheme := &legacy.SigScheme{Alg: legacy.AlgRSASSA, Hash: hash}
|
||||
if pss, ok := opts.(*rsa.PSSOptions); ok {
|
||||
// The TPM chooses the salt length itself, the digest length on most chips, so
|
||||
// only a request for that length is taken. PSSSaltLengthAuto asks for the
|
||||
// largest salt the key allows and is refused. Verify with PSSSaltLengthAuto.
|
||||
if pss.SaltLength != rsa.PSSSaltLengthEqualsHash &&
|
||||
pss.SaltLength != len(digest) {
|
||||
return nil, fmt.Errorf("TPM cannot produce a PSS signature with salt length %d", pss.SaltLength)
|
||||
}
|
||||
scheme.Alg = legacy.AlgRSAPSS
|
||||
}
|
||||
|
||||
sig, err := legacy.Sign(rw, handle, "", digest, nil, scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TPM RSA signature: %w", err)
|
||||
}
|
||||
if sig.RSA == nil {
|
||||
return nil, fmt.Errorf("TPM returned a %v signature for an RSA key", sig.Alg)
|
||||
}
|
||||
return sig.RSA.Signature, nil
|
||||
}
|
||||
|
||||
func eccHash(curve elliptic.Curve) (legacy.Algorithm, error) {
|
||||
switch curve {
|
||||
case elliptic.P256():
|
||||
return legacy.AlgSHA256, nil
|
||||
case elliptic.P384():
|
||||
return legacy.AlgSHA384, nil
|
||||
case elliptic.P521():
|
||||
return legacy.AlgSHA512, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported curve %s", curve.Params().Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
|
||||
)
|
||||
|
||||
func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
|
||||
key := newP256Key(t)
|
||||
|
||||
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey)))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, key.PublicKey.Equal(signer.Public()), "signer must expose the key the TPM holds")
|
||||
}
|
||||
|
||||
func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) {
|
||||
key := newP256Key(t)
|
||||
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithAuth())))
|
||||
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
|
||||
}
|
||||
|
||||
func TestParseKey_RejectsMalformedKey(t *testing.T) {
|
||||
_, err := ParseKey([]byte("not a TSS2 key"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
|
||||
t.Setenv(DeviceEnv, filepath.Join(t.TempDir(), "missing"))
|
||||
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &newP256Key(t).PublicKey)))
|
||||
require.NoError(t, err)
|
||||
|
||||
digest := sha256.Sum256([]byte("challenge"))
|
||||
_, err = signer.Sign(rand.Reader, digest[:], crypto.SHA256)
|
||||
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
|
||||
}
|
||||
|
||||
// TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose checks the salt-length gate, which
|
||||
// runs before the TPM is touched: the TPM picks the salt itself, so a request for the
|
||||
// maximum salt (PSSSaltLengthAuto) or any other explicit length is refused.
|
||||
func TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose(t *testing.T) {
|
||||
digest := sha256.Sum256([]byte("challenge"))
|
||||
for _, salt := range []int{rsa.PSSSaltLengthAuto, 20, 222} {
|
||||
_, err := signRSA(nil, 0, digest[:], &rsa.PSSOptions{SaltLength: salt, Hash: crypto.SHA256})
|
||||
assert.ErrorContains(t, err, "salt length", "salt length %d must be refused", salt)
|
||||
}
|
||||
}
|
||||
|
||||
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func decodePEM(t *testing.T, pemData string) []byte {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode([]byte(pemData))
|
||||
require.NotNil(t, block)
|
||||
require.Equal(t, KeyPEMType, block.Type)
|
||||
return block.Bytes
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package tpm is the client's one door to the platform TPM 2.0. It opens the device
|
||||
// and turns TPM-held key files into signers; every operation opens the TPM, runs and
|
||||
// closes it, so no handle outlives a call.
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// DeviceEnv overrides the TPM device path, which also lets tests point at a swtpm socket.
|
||||
const DeviceEnv = "NB_TPM_DEVICE"
|
||||
|
||||
var ErrUnsupported = errors.New("TPM is not supported on this platform")
|
||||
|
||||
// Open connects to the platform TPM 2.0. The caller closes it after one operation.
|
||||
func Open() (io.ReadWriteCloser, error) {
|
||||
return open()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package tpmtest builds TSS2 key files for tests, with or without a TPM behind them.
|
||||
package tpmtest
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-tpm/legacy/tpm2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
p256Bytes = 32
|
||||
|
||||
// KeyPEMType is the PEM block type of a TPM 2.0 key file.
|
||||
KeyPEMType = "TSS2 PRIVATE KEY"
|
||||
)
|
||||
|
||||
// oidLoadableKey is the key type of draft-bottomley-tpm2-keys that a parent wraps.
|
||||
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
|
||||
|
||||
// tss2KeyDER is the ASN.1 container, written out independently of the parser under test
|
||||
// so that an encoder bug and a decoder bug cannot cancel out. Policy, secret and auth
|
||||
// policy are left out entirely: they are optional, and a key that carries them is
|
||||
// refused anyway.
|
||||
type tss2KeyDER struct {
|
||||
Type asn1.ObjectIdentifier
|
||||
EmptyAuth bool `asn1:"optional,explicit,tag:0"`
|
||||
Parent int64
|
||||
PublicKey []byte
|
||||
PrivateKey []byte
|
||||
}
|
||||
|
||||
// Option adjusts a key before it is encoded.
|
||||
type Option func(*tss2KeyDER)
|
||||
|
||||
// WithParent names the handle the key is wrapped by, instead of the owner hierarchy.
|
||||
func WithParent(handle int64) Option {
|
||||
return func(k *tss2KeyDER) { k.Parent = handle }
|
||||
}
|
||||
|
||||
// WithAuth marks the key as guarded by an authorization value, which this client refuses
|
||||
// because nothing can supply one without prompting.
|
||||
func WithAuth() Option {
|
||||
return func(k *tss2KeyDER) { k.EmptyAuth = false }
|
||||
}
|
||||
|
||||
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
|
||||
// scheme, the shape tpm2-openssl creates certificate keys in.
|
||||
func SigningTemplate() tpm2.Public {
|
||||
return tpm2.Public{
|
||||
Type: tpm2.AlgECC,
|
||||
NameAlg: tpm2.AlgSHA256,
|
||||
Attributes: tpm2.FlagSign | tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagNoDA,
|
||||
ECCParameters: &tpm2.ECCParams{CurveID: tpm2.CurveNISTP256},
|
||||
}
|
||||
}
|
||||
|
||||
// KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses
|
||||
// and reports pub, but no TPM can load it.
|
||||
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...Option) string {
|
||||
t.Helper()
|
||||
require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256")
|
||||
area := SigningTemplate()
|
||||
// The uncompressed encoding is 0x04 followed by the two coordinates, which is
|
||||
// what the TPM wants them as.
|
||||
point, err := pub.Bytes()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, point, 1+2*p256Bytes)
|
||||
area.ECCParameters.Point = tpm2.ECPoint{
|
||||
XRaw: point[1 : 1+p256Bytes],
|
||||
YRaw: point[1+p256Bytes:],
|
||||
}
|
||||
encoded, encErr := area.Encode()
|
||||
require.NoError(t, encErr)
|
||||
return EncodePEM(t, encoded, []byte("placeholder"), opts...)
|
||||
}
|
||||
|
||||
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE
|
||||
// KEY, giving each the TPM2B length prefix the format carries them with.
|
||||
func EncodePEM(t *testing.T, public, private []byte, opts ...Option) string {
|
||||
t.Helper()
|
||||
|
||||
key := tss2KeyDER{
|
||||
Type: oidLoadableKey,
|
||||
EmptyAuth: true,
|
||||
Parent: int64(tpm2.HandleOwner),
|
||||
PublicKey: prefixTPM2B(public),
|
||||
PrivateKey: prefixTPM2B(private),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&key)
|
||||
}
|
||||
|
||||
der, err := asn1.Marshal(key)
|
||||
require.NoError(t, err)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: KeyPEMType, Bytes: der}))
|
||||
}
|
||||
|
||||
func prefixTPM2B(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b)+2)
|
||||
out = append(out, byte(len(b)>>8), byte(len(b)))
|
||||
return append(out, b...)
|
||||
}
|
||||
|
||||
// ECCSRKTemplate is the TCG reference ECC-P256 storage root key, the parent a key under
|
||||
// a hierarchy is wrapped by. Tests that create a key in a real TPM have to use the same
|
||||
// template the signer re-derives it with.
|
||||
var ECCSRKTemplate = tpm2.Public{
|
||||
Type: tpm2.AlgECC,
|
||||
NameAlg: tpm2.AlgSHA256,
|
||||
Attributes: tpm2.FlagStorageDefault | tpm2.FlagNoDA,
|
||||
ECCParameters: &tpm2.ECCParams{
|
||||
Symmetric: &tpm2.SymScheme{Alg: tpm2.AlgAES, KeyBits: 128, Mode: tpm2.AlgCFB},
|
||||
Sign: &tpm2.SigScheme{Alg: tpm2.AlgNull},
|
||||
CurveID: tpm2.CurveNISTP256,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"encoding/asn1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
legacy "github.com/google/go-tpm/legacy/tpm2"
|
||||
)
|
||||
|
||||
// oidLoadableKey marks a key wrapped by a parent inside the TPM, which is the only kind
|
||||
// enrollment tooling writes for a signing key and the only kind that can be loaded under
|
||||
// an SRK. The sealed and importable variants carry different material and are refused.
|
||||
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
|
||||
|
||||
var (
|
||||
errNotLoadable = errors.New("TSS2 key is not a loadable key")
|
||||
errKeyHasPolicy = errors.New("TSS2 key carries a policy, which is not supported")
|
||||
errKeyHasSecret = errors.New("TSS2 key carries a secret, which is not supported")
|
||||
errBadParent = errors.New("TSS2 key names a parent that is neither persistent nor a hierarchy")
|
||||
errBadBlob = errors.New("TSS2 key blob is malformed")
|
||||
)
|
||||
|
||||
// tss2KeyASN1 is the ASN.1 container of draft-bottomley-tpm2-keys, the format
|
||||
// tpm2-openssl, tpm2-tss-engine and tpm2_encodeobject write. PublicKey and PrivateKey
|
||||
// hold TPM2B structures, so each is its own two-byte length followed by that many bytes.
|
||||
//
|
||||
// EmptyAuth is decoded raw: OpenSSL-based tools write BOOLEAN TRUE as 0x01, which BER
|
||||
// allows but encoding/asn1 rejects, as it accepts only the DER form 0xff. Parent is an
|
||||
// int64 so a persistent handle such as 0x81000001 still fits on 32-bit platforms.
|
||||
type tss2KeyASN1 struct {
|
||||
Type asn1.ObjectIdentifier
|
||||
EmptyAuth asn1.RawValue `asn1:"optional,explicit,tag:0"`
|
||||
Policy []asn1.RawValue `asn1:"optional,explicit,tag:1"`
|
||||
Secret []byte `asn1:"optional,explicit,tag:2"`
|
||||
AuthPolicy []asn1.RawValue `asn1:"optional,explicit,tag:3"`
|
||||
Parent int64
|
||||
PublicKey []byte
|
||||
PrivateKey []byte
|
||||
}
|
||||
|
||||
// tss2Key is a decoded key file the client can ask the TPM to load.
|
||||
type tss2Key struct {
|
||||
EmptyAuth bool
|
||||
Parent uint32
|
||||
PublicKey []byte
|
||||
PrivateKey []byte
|
||||
}
|
||||
|
||||
// parseTSS2 decodes a TSS2 key file and rejects everything this client cannot honour,
|
||||
// so a key that parses here is one the TPM can be asked to load.
|
||||
func parseTSS2(der []byte) (*tss2Key, error) {
|
||||
raw := new(tss2KeyASN1)
|
||||
rest, err := asn1.Unmarshal(der, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TSS2 key: %w", err)
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
return nil, fmt.Errorf("parse TSS2 key: %d trailing bytes", len(rest))
|
||||
}
|
||||
emptyAuth, err := berBoolean(raw.EmptyAuth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TSS2 key emptyAuth: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case !raw.Type.Equal(oidLoadableKey):
|
||||
return nil, fmt.Errorf("%w: %s", errNotLoadable, raw.Type)
|
||||
case len(raw.Policy) > 0 || len(raw.AuthPolicy) > 0:
|
||||
return nil, errKeyHasPolicy
|
||||
case len(raw.Secret) > 0:
|
||||
return nil, errKeyHasSecret
|
||||
case raw.Parent < 0 || raw.Parent > math.MaxUint32 || !validParent(uint32(raw.Parent)):
|
||||
return nil, fmt.Errorf("%w: %d", errBadParent, raw.Parent)
|
||||
case !validTPM2B(raw.PublicKey) || !validTPM2B(raw.PrivateKey):
|
||||
return nil, errBadBlob
|
||||
}
|
||||
return &tss2Key{
|
||||
EmptyAuth: emptyAuth,
|
||||
Parent: uint32(raw.Parent),
|
||||
PublicKey: raw.PublicKey,
|
||||
PrivateKey: raw.PrivateKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// berBoolean decodes an optional BOOLEAN, absent meaning false. Any non-zero content
|
||||
// byte is true, as BER (X.690 section 8.2.2) allows and OpenSSL writes.
|
||||
func berBoolean(v asn1.RawValue) (bool, error) {
|
||||
if len(v.FullBytes) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
// The field is explicitly tagged, so v is the [0] wrapper and the BOOLEAN is inside it.
|
||||
if v.Class == asn1.ClassContextSpecific && v.IsCompound {
|
||||
var inner asn1.RawValue
|
||||
rest, err := asn1.Unmarshal(v.Bytes, &inner)
|
||||
if err != nil || len(rest) > 0 {
|
||||
return false, errors.New("malformed explicit tag")
|
||||
}
|
||||
v = inner
|
||||
}
|
||||
if v.Class != asn1.ClassUniversal || v.Tag != asn1.TagBoolean || v.IsCompound || len(v.Bytes) != 1 {
|
||||
return false, errors.New("not a BOOLEAN")
|
||||
}
|
||||
return v.Bytes[0] != 0, nil
|
||||
}
|
||||
|
||||
// blobs returns the public and private areas with their TPM2B length prefix removed,
|
||||
// which is the form the load command takes them in.
|
||||
func (k *tss2Key) blobs() (public, private []byte) {
|
||||
return k.PublicKey[2:], k.PrivateKey[2:]
|
||||
}
|
||||
|
||||
// validParent accepts a persistent handle, under which the key was wrapped directly, or
|
||||
// one of the four hierarchies, under which the key is wrapped by a primary the TPM
|
||||
// re-derives from the hierarchy seed.
|
||||
func validParent(parent uint32) bool {
|
||||
return persistentHandle(parent) ||
|
||||
parent == uint32(legacy.HandleOwner) ||
|
||||
parent == uint32(legacy.HandleNull) ||
|
||||
parent == uint32(legacy.HandleEndorsement) ||
|
||||
parent == uint32(legacy.HandlePlatform)
|
||||
}
|
||||
|
||||
func persistentHandle(h uint32) bool {
|
||||
return h>>24 == uint32(legacy.HandleTypePersistent)
|
||||
}
|
||||
|
||||
// validTPM2B reports whether b is a TPM2B structure: a two-byte big-endian length
|
||||
// followed by exactly that many bytes.
|
||||
func validTPM2B(b []byte) bool {
|
||||
return len(b) >= 2 && len(b)-2 == int(b[0])<<8+int(b[1])
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tpm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/asn1"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
|
||||
)
|
||||
|
||||
// derEmptyAuthTrue is the explicit [0] tag around a DER BOOLEAN TRUE, as encoding/asn1
|
||||
// writes emptyAuth; OpenSSL writes the same element with 0x01 as the content byte.
|
||||
var (
|
||||
derEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0xff}
|
||||
opensslEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0x01}
|
||||
)
|
||||
|
||||
func p256(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
|
||||
func TestParseKey_AcceptsOpenSSLBoolean(t *testing.T) {
|
||||
key := p256(t)
|
||||
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey))
|
||||
require.Equal(t, 1, bytes.Count(der, derEmptyAuthTrue), "fixture carries one DER emptyAuth TRUE")
|
||||
|
||||
// The same key as tpm2-openssl and tpm2-tss-engine write it: identical apart from
|
||||
// the BOOLEAN content byte.
|
||||
openssl := bytes.Replace(der, derEmptyAuthTrue, opensslEmptyAuthTrue, 1)
|
||||
|
||||
signer, err := ParseKey(openssl)
|
||||
require.NoError(t, err, "BER allows any non-zero byte for TRUE, and OpenSSL writes 0x01")
|
||||
assert.True(t, key.PublicKey.Equal(signer.Public()), "the key is the same either way")
|
||||
}
|
||||
|
||||
func TestParseKey_AcceptsPersistentParent(t *testing.T) {
|
||||
key := p256(t)
|
||||
// 0x81000001 overflows a 32-bit int, the width the parent used to be decoded into.
|
||||
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(0x81000001)))
|
||||
|
||||
parsed, err := parseTSS2(der)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint32(0x81000001), parsed.Parent, "persistent parent handle")
|
||||
assert.True(t, persistentHandle(parsed.Parent), "the key is loaded under the persistent parent directly")
|
||||
}
|
||||
|
||||
func TestParseKey_RejectsParentOutOfRange(t *testing.T) {
|
||||
key := p256(t)
|
||||
for _, parent := range []int64{-1, 0x1_0000_0000, 0x02000000} {
|
||||
_, err := parseTSS2(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(parent))))
|
||||
assert.ErrorIs(t, err, errBadParent, "parent %#x is neither persistent nor a hierarchy", parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBERBoolean(t *testing.T) {
|
||||
absent, err := berBoolean(asn1.RawValue{})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, absent, "an absent emptyAuth means the key needs authorization")
|
||||
|
||||
for content, want := range map[byte]bool{0x00: false, 0x01: true, 0xff: true} {
|
||||
v := asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagBoolean, Bytes: []byte{content}, FullBytes: []byte{0x01, 0x01, content}}
|
||||
got, err := berBoolean(v)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got, "content byte %#x", content)
|
||||
}
|
||||
|
||||
_, err = berBoolean(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagInteger, Bytes: []byte{1}, FullBytes: []byte{0x02, 0x01, 0x01}})
|
||||
assert.Error(t, err, "an INTEGER is not a BOOLEAN")
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -9,9 +11,24 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type jwtStoringMDMFetcher struct {
|
||||
cache *jwtCache
|
||||
caller ipcauth.Identity
|
||||
generation uint64
|
||||
}
|
||||
|
||||
func (f *jwtStoringMDMFetcher) Fetch() map[string]any {
|
||||
f.generation = f.cache.currentGeneration()
|
||||
f.cache.store("previous-profile-token", f.caller, time.Minute, f.generation)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubOAuthFlow struct {
|
||||
token auth.TokenInfo
|
||||
onWait func()
|
||||
@@ -123,6 +140,110 @@ func TestSwitchProfile_DropsAccountPromptAndPendingFlow(t *testing.T) {
|
||||
require.False(t, pending, "the previous profile's extend flow leaked across a profile switch")
|
||||
}
|
||||
|
||||
func TestLogin_ProfileSwitchDropsAccountPromptAndPendingFlow(t *testing.T) {
|
||||
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
other := "other-profile"
|
||||
otherPath := filepath.Join(filepath.Dir(cfgPath), other+".json")
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: otherPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
breakProfilePrivateKey(t, otherPath)
|
||||
|
||||
s.forceAccountPrompt = true
|
||||
cancelled := false
|
||||
s.oauthAuthFlow = oauthAuthFlow{
|
||||
flow: &stubOAuthFlow{},
|
||||
hint: "user@example.com",
|
||||
waitCancel: func() { cancelled = true },
|
||||
}
|
||||
|
||||
extendCancelled := false
|
||||
s.extendAuthSessionFlow.Set(&stubOAuthFlow{}, auth.AuthFlowInfo{DeviceCode: "device"})
|
||||
s.extendAuthSessionFlow.SetWaitCancel(func() { extendCancelled = true })
|
||||
|
||||
generation := s.jwtCache.currentGeneration()
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{ProfileName: &other, Username: &username})
|
||||
require.Error(t, err, "the broken key must stop the login before a flow is built")
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(other), active.ID, "the login did not switch the profile")
|
||||
|
||||
require.False(t, s.forceAccountPrompt, "the prompt flag leaked across a login-driven profile switch")
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "the previous profile's flow leaked across a login-driven profile switch")
|
||||
require.Empty(t, s.oauthAuthFlow.hint)
|
||||
require.True(t, cancelled, "the pending wait was not cancelled")
|
||||
|
||||
require.True(t, extendCancelled, "the pending extend wait was not cancelled")
|
||||
_, _, pending := s.extendAuthSessionFlow.Get()
|
||||
require.False(t, pending, "the previous profile's extend flow leaked across a login-driven profile switch")
|
||||
|
||||
require.Greater(t, s.jwtCache.currentGeneration(), generation, "the previous profile's JWT cache survived a login-driven profile switch")
|
||||
}
|
||||
|
||||
func TestLogin_ProfileSwitchRejectsJWTObtainedUnderPreviousConfig(t *testing.T) {
|
||||
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return false, errors.New("stop once the config is swapped")
|
||||
}
|
||||
|
||||
other := "other-profile"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(filepath.Dir(cfgPath), other+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// getConfig loads the MDM policy right before Login swaps s.config, so the
|
||||
// fetcher runs where a RequestJWTAuth racing the login would land: after the
|
||||
// switch dropped the previous profile's state, while s.config still belongs
|
||||
// to the previous profile. It caches a token under the generation current at
|
||||
// that point, as WaitJWTToken would.
|
||||
generation := s.jwtCache.currentGeneration()
|
||||
fetcher := &jwtStoringMDMFetcher{cache: s.jwtCache, caller: unprivilegedIdentity()}
|
||||
s.mdmLoader = mdm.NewLoader(fetcher)
|
||||
|
||||
_, err = s.Login(userCtx(), &proto.LoginRequest{ProfileName: &other, Username: &username})
|
||||
require.Error(t, err)
|
||||
|
||||
require.Greater(t, fetcher.generation, generation, "the token was not cached after the switch dropped the previous profile's state")
|
||||
_, found := s.jwtCache.get(fetcher.caller)
|
||||
require.False(t, found, "a JWT obtained under the previous profile's config survived the login-driven switch")
|
||||
}
|
||||
|
||||
func TestLogin_SameProfileKeepsPendingFlow(t *testing.T) {
|
||||
s, _, profName, username, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
breakProfilePrivateKey(t, cfgPath)
|
||||
|
||||
cancelled := false
|
||||
flow := &stubOAuthFlow{}
|
||||
s.oauthAuthFlow = oauthAuthFlow{
|
||||
flow: flow,
|
||||
hint: "user@example.com",
|
||||
waitCancel: func() { cancelled = true },
|
||||
}
|
||||
|
||||
_, err := s.Login(userCtx(), &proto.LoginRequest{ProfileName: &profName, Username: &username})
|
||||
require.Error(t, err, "the broken key must stop the login before a flow is built")
|
||||
|
||||
require.Equal(t, flow, s.oauthAuthFlow.flow, "a login on the same profile dropped the flow a second client could join")
|
||||
require.Equal(t, "user@example.com", s.oauthAuthFlow.hint)
|
||||
require.False(t, cancelled, "a login on the same profile cancelled the pending wait")
|
||||
}
|
||||
|
||||
func TestWaitSSOLogin_JudgesTheFlowThatProducedTheToken(t *testing.T) {
|
||||
s := newSSOTestServer(t, "user@example.com", false, "user@example.com")
|
||||
attempts := 0
|
||||
|
||||
+84
-50
@@ -722,7 +722,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
|
||||
ctx, activeProf, switched, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
|
||||
if err != nil {
|
||||
// The RPC boundary is where this gets recorded: nothing logs handler
|
||||
// errors for us, and a caller that retries would otherwise leave no
|
||||
@@ -752,6 +752,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
s.mutex.Lock()
|
||||
s.config = config
|
||||
if switched {
|
||||
s.jwtCache.clear()
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
|
||||
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
||||
@@ -1192,11 +1195,15 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
}
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
switched, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf)
|
||||
if err != nil {
|
||||
s.mutex.Unlock()
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if switched {
|
||||
s.dropPendingAuthFlows()
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
@@ -1334,12 +1341,12 @@ func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.
|
||||
}
|
||||
|
||||
// switchProfileIfNeeded resolves the user-supplied handle, updates the
|
||||
// active profile state if it differs from the current one, and returns
|
||||
// the resolved profile so callers can include its ID in RPC responses.
|
||||
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
|
||||
// active profile state if it differs from the current one, and reports
|
||||
// whether the active profile changed.
|
||||
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (bool, error) {
|
||||
if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
|
||||
log.Errorf("profile name is set to %s, but username is not provided", handle)
|
||||
return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
|
||||
return false, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
|
||||
}
|
||||
|
||||
var username string
|
||||
@@ -1349,26 +1356,48 @@ func (s *Server) switchProfileIfNeeded(handle string, userName *string, activePr
|
||||
|
||||
resolved, err := s.resolveProfileHandle(handle, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resolved.ID != activeProf.ID || username != activeProf.Username {
|
||||
if s.checkProfilesDisabled() {
|
||||
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
|
||||
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
|
||||
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: resolved.ID,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
log.Errorf("failed to set active profile state: %v", err)
|
||||
return nil, fmt.Errorf("failed to set active profile state: %w", err)
|
||||
}
|
||||
if resolved.ID == activeProf.ID && username == activeProf.Username {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
if s.checkProfilesDisabled() {
|
||||
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
|
||||
return false, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
||||
}
|
||||
|
||||
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
|
||||
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: resolved.ID,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
log.Errorf("failed to set active profile state: %v", err)
|
||||
return false, fmt.Errorf("failed to set active profile state: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Server) dropPendingAuthFlows() {
|
||||
// A pending login flow and the account-prompt flag describe the previous
|
||||
// profile's login; carried across a switch they would judge the new
|
||||
// profile's token against the old profile's account. CancelFunc is
|
||||
// non-blocking, so calling it under the mutex is safe.
|
||||
if cancel := s.oauthAuthFlow.waitCancel; cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.oauthAuthFlow = oauthAuthFlow{}
|
||||
s.forceAccountPrompt = false
|
||||
|
||||
// A pending session extend belongs to the previous profile too: its device
|
||||
// code was issued by that profile's IdP client, and WaitExtendAuthSession
|
||||
// would submit the resulting token against the new profile's engine.
|
||||
s.extendAuthSessionFlow.CancelWait()
|
||||
s.extendAuthSessionFlow.Clear()
|
||||
|
||||
s.jwtCache.clear()
|
||||
}
|
||||
|
||||
// SwitchProfile switches the active profile in the daemon.
|
||||
@@ -1402,23 +1431,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
s.config = config
|
||||
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
||||
|
||||
s.jwtCache.clear()
|
||||
|
||||
// A pending login flow and the account-prompt flag describe the previous
|
||||
// profile's login; carried across a switch they would judge the new
|
||||
// profile's token against the old profile's account. CancelFunc is
|
||||
// non-blocking, so calling it under the mutex is safe.
|
||||
if cancel := s.oauthAuthFlow.waitCancel; cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.oauthAuthFlow = oauthAuthFlow{}
|
||||
s.forceAccountPrompt = false
|
||||
|
||||
// A pending session extend belongs to the previous profile too: its device
|
||||
// code was issued by that profile's IdP client, and WaitExtendAuthSession
|
||||
// would submit the resulting token against the new profile's engine.
|
||||
s.extendAuthSessionFlow.CancelWait()
|
||||
s.extendAuthSessionFlow.Clear()
|
||||
s.dropPendingAuthFlows()
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
s.publishProfileListChanged(*msg.ProfileName)
|
||||
@@ -2704,9 +2717,23 @@ func (s *Server) checkDisableAdvancedView() *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
// profileOwnerOption passes the OS account of the active profile to the connect client,
|
||||
// which reads that account's certificate store for user certificate posture checks.
|
||||
func (s *Server) profileOwnerOption() []internal.ConnectClientOption {
|
||||
if s.profileManager == nil {
|
||||
return nil
|
||||
}
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
log.Warnf("failed to read the active profile owner, no user certificate store is used for certificate posture: %v", err)
|
||||
return []internal.ConnectClientOption{internal.WithUnknownProfileOwner()}
|
||||
}
|
||||
return []internal.ConnectClientOption{internal.WithProfileOwner(activeProf.Username)}
|
||||
}
|
||||
|
||||
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
|
||||
log.Tracef("running client connection")
|
||||
client := internal.NewConnectClient(ctx, config, statusRecorder)
|
||||
client := internal.NewConnectClient(ctx, config, statusRecorder, s.profileOwnerOption()...)
|
||||
client.SetUpdateManager(s.updateManager)
|
||||
client.SetSyncResponsePersistence(s.persistSyncResponse)
|
||||
|
||||
@@ -2862,7 +2889,7 @@ var afterLoginPreCheck func()
|
||||
// of this is reached; this one exists because that check is not synchronized
|
||||
// against a concurrent privileged request that enables the SSH server, and a
|
||||
// caller refused here must not have cancelled or switched anything either.
|
||||
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
|
||||
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, bool, error) {
|
||||
if afterLoginPreCheck != nil {
|
||||
afterLoginPreCheck()
|
||||
}
|
||||
@@ -2872,10 +2899,10 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
|
||||
stored, err := s.storedLoginConfig(activeProf, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
// The update-settings decision is re-taken here for the same reason as the
|
||||
@@ -2884,7 +2911,7 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
// authoritative check, and it is the last read before persistLoginOverrides
|
||||
// writes.
|
||||
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) {
|
||||
return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
|
||||
return nil, nil, false, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
@@ -2902,19 +2929,26 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
log.Warnf(errRestoreResidualState, err)
|
||||
}
|
||||
|
||||
switched := false
|
||||
if msg.ProfileName != nil {
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
return nil, nil, fmt.Errorf("switch profile: %w", err)
|
||||
switched, err = s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
if switched {
|
||||
s.mutex.Lock()
|
||||
s.dropPendingAuthFlows()
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
activeProf, err = s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("active profile state: %w", err)
|
||||
return nil, nil, false, fmt.Errorf("active profile state: %w", err)
|
||||
}
|
||||
|
||||
if err := persistLoginOverrides(activeProf, msg); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
|
||||
return nil, nil, false, fmt.Errorf("persist login overrides: %w", err)
|
||||
}
|
||||
|
||||
// Provisioning under the same lock as the decision above, and next to the
|
||||
@@ -2923,10 +2957,10 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
|
||||
// had already answered its caller would be overwritten by the config this
|
||||
// login read before it landed.
|
||||
if _, _, err := provisionProfileIdentity(activeProf); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
return ctx, activeProf, nil
|
||||
return ctx, activeProf, switched, nil
|
||||
}
|
||||
|
||||
// persistLoginOverrides writes the config fields a login request is allowed to
|
||||
|
||||
+21
-1
@@ -11,6 +11,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,7 @@ type Info struct {
|
||||
SystemManufacturer string
|
||||
Environment Environment
|
||||
Files []File // for posture checks
|
||||
CertificateProofs []certposture.Proof
|
||||
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
@@ -204,12 +206,27 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
|
||||
// The buffered channel lets the abandoned goroutine finish and exit once its blocking call
|
||||
// returns, so it does not leak beyond the duration of that call.
|
||||
func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
|
||||
return getInfoWithChecksTimeout(ctx, timeout, checks, nil, excludeIPs...)
|
||||
}
|
||||
|
||||
// gatherInfoWithChecks is the gathering getInfoWithChecksTimeout bounds. Tests replace it
|
||||
// to control when a gathering finishes.
|
||||
var gatherInfoWithChecks = GetInfoWithChecks
|
||||
|
||||
// getInfoWithChecksTimeout is GetInfoWithChecksTimeout that calls done, when not nil, once
|
||||
// the gathering is over: on return when it finished in time, else when the goroutine
|
||||
// exits, which may be well after the timeout. done may be called twice and must be
|
||||
// idempotent.
|
||||
func getInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, done func(), excludeIPs ...netip.Addr) (*Info, bool) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
infoCh := make(chan *Info, 1)
|
||||
go func() {
|
||||
info, err := GetInfoWithChecks(ctx, checks, excludeIPs...)
|
||||
if done != nil {
|
||||
defer done()
|
||||
}
|
||||
info, err := gatherInfoWithChecks(ctx, checks, excludeIPs...)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -223,6 +240,9 @@ func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks
|
||||
|
||||
select {
|
||||
case info := <-infoCh:
|
||||
if done != nil {
|
||||
done()
|
||||
}
|
||||
return info, true
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
|
||||
+129
-10
@@ -4,35 +4,154 @@ import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// infoLostAfter is how many timeouts an abandoned gathering is waited for before
|
||||
// another may start beside it. A system call that is merely slow returns well within
|
||||
// that; only one blocked for good, such as a stat on a dead network mount, is worked
|
||||
// around.
|
||||
infoLostAfter = 10
|
||||
|
||||
// maxAbandoned bounds how many gatherings may be left blocked, so a call that never
|
||||
// returns cannot leak a goroutine on every refresh for the life of the daemon.
|
||||
maxAbandoned = 2
|
||||
)
|
||||
|
||||
// InfoSource gathers the system info sent to management, keeping the posture
|
||||
// check results from the last Refresh for the cheap Current snapshots.
|
||||
// check results from the last Refresh for the cheap Current snapshots. The zero value is
|
||||
// ready to use.
|
||||
type InfoSource struct {
|
||||
files atomic.Pointer[[]File]
|
||||
// started numbers Refresh calls in the order they began.
|
||||
started atomic.Uint64
|
||||
// now overrides the clock when set.
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
// abandoned holds the start of each gathering that timed out and is still blocked in
|
||||
// a system call, by Refresh number.
|
||||
abandoned map[uint64]time.Time
|
||||
// files holds the file check results of the latest-started Refresh that succeeded,
|
||||
// filesFrom its number.
|
||||
files []File
|
||||
filesFrom uint64
|
||||
}
|
||||
|
||||
// Refresh gathers the info with the posture checks evaluated, bounded by timeout.
|
||||
// Refresh gathers the info with the posture checks evaluated, bounded by timeout. It may
|
||||
// run concurrently with other calls; the results Current reuses are those of the call
|
||||
// that started last, so an older call finishing late cannot replace them. It reports
|
||||
// false on a timeout, and also while a gathering that timed out earlier is still blocked
|
||||
// in a system call: starting another would only add one more goroutine stuck on it.
|
||||
// That one is given up on once it has run for infoLostAfter timeouts, up to maxAbandoned.
|
||||
func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
|
||||
info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...)
|
||||
if !ok {
|
||||
started := s.clock()
|
||||
if !s.mayStart(started, time.Duration(infoLostAfter)*timeout) {
|
||||
return nil, false
|
||||
}
|
||||
files := slices.Clone(info.Files)
|
||||
s.files.Store(&files)
|
||||
seq := s.started.Add(1)
|
||||
|
||||
var mu sync.Mutex
|
||||
var finished, abandoned bool
|
||||
done := func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if finished {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
if abandoned {
|
||||
s.release(seq)
|
||||
}
|
||||
}
|
||||
info, ok := getInfoWithChecksTimeout(ctx, timeout, checks, done, excludeIPs...)
|
||||
if !ok {
|
||||
mu.Lock()
|
||||
if !finished {
|
||||
abandoned = true
|
||||
s.abandon(seq, started)
|
||||
}
|
||||
mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
s.publishFiles(seq, info.Files)
|
||||
return info, true
|
||||
}
|
||||
|
||||
// mayStart reports whether a gathering may start at now. Every abandoned gathering must
|
||||
// have run for lost, and fewer than maxAbandoned may be left running.
|
||||
func (s *InfoSource) mayStart(now time.Time, lost time.Duration) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.abandoned) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s.abandoned) >= maxAbandoned {
|
||||
log.Warnf("%d system info gatherings are stuck, skipping this one", len(s.abandoned))
|
||||
return false
|
||||
}
|
||||
for _, started := range s.abandoned {
|
||||
if now.Sub(started) < lost {
|
||||
log.Warnf("system info gathering that timed out earlier is still running, skipping this one")
|
||||
return false
|
||||
}
|
||||
}
|
||||
log.Warnf("system info gathering has been stuck for over %s, starting another", lost)
|
||||
return true
|
||||
}
|
||||
|
||||
// abandon records that gathering seq, started at started, timed out but is still running.
|
||||
func (s *InfoSource) abandon(seq uint64, started time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.abandoned == nil {
|
||||
s.abandoned = map[uint64]time.Time{}
|
||||
}
|
||||
s.abandoned[seq] = started
|
||||
}
|
||||
|
||||
// release records that the abandoned gathering seq has returned.
|
||||
func (s *InfoSource) release(seq uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.abandoned, seq)
|
||||
}
|
||||
|
||||
func (s *InfoSource) abandonedCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.abandoned)
|
||||
}
|
||||
|
||||
func (s *InfoSource) clock() time.Time {
|
||||
if s.now != nil {
|
||||
return s.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (s *InfoSource) publishFiles(seq uint64, files []File) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if seq < s.filesFrom {
|
||||
return
|
||||
}
|
||||
s.files = slices.Clone(files)
|
||||
s.filesFrom = seq
|
||||
}
|
||||
|
||||
// Current gathers the info without evaluating the checks, reusing the last Refresh results.
|
||||
func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info {
|
||||
info := GetInfo(ctx)
|
||||
info.removeAddresses(excludeIPs...)
|
||||
if files := s.files.Load(); files != nil {
|
||||
info.Files = *files
|
||||
}
|
||||
s.mu.Lock()
|
||||
info.Files = slices.Clone(s.files)
|
||||
s.mu.Unlock()
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -36,6 +37,106 @@ func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) {
|
||||
assert.Equal(t, refreshed.Files, info.Files)
|
||||
}
|
||||
|
||||
// TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns stands in for a gathering that
|
||||
// timed out and is still blocked in a system call: no second one starts on top of it,
|
||||
// and gathering works again once it exits.
|
||||
func TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns(t *testing.T) {
|
||||
var src InfoSource
|
||||
src.abandoned = map[uint64]time.Time{1: time.Now()}
|
||||
|
||||
_, ok := src.Refresh(context.Background(), 15*time.Second, nil)
|
||||
assert.False(t, ok, "no gathering starts while an earlier one is still running")
|
||||
|
||||
src.abandoned = nil
|
||||
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
|
||||
require.True(t, ok, "gathering runs once the earlier one exited")
|
||||
|
||||
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
|
||||
assert.True(t, ok, "a gathering that finished in time does not block the next one")
|
||||
}
|
||||
|
||||
// TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits checks that a gathering that
|
||||
// timed out releases the source once its goroutine finishes, not before.
|
||||
func TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits(t *testing.T) {
|
||||
var src InfoSource
|
||||
|
||||
_, ok := src.Refresh(context.Background(), time.Nanosecond, nil)
|
||||
require.False(t, ok, "gathering cannot finish within a nanosecond")
|
||||
|
||||
require.Eventually(t, func() bool { return src.abandonedCount() == 0 }, 10*time.Second, 10*time.Millisecond,
|
||||
"the source is released when the abandoned gathering exits")
|
||||
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
|
||||
assert.True(t, ok, "gathering works again after the abandoned one exited")
|
||||
}
|
||||
|
||||
// stubGathering replaces the gathering with one that reports a single file check for the
|
||||
// path the checks name, and blocks a gathering for blockedPath until release is closed.
|
||||
// entered receives once that gathering has started.
|
||||
func stubGathering(t *testing.T, blockedPath string) (entered chan struct{}, release chan struct{}) {
|
||||
t.Helper()
|
||||
entered = make(chan struct{}, 1)
|
||||
release = make(chan struct{})
|
||||
original := gatherInfoWithChecks
|
||||
gatherInfoWithChecks = func(_ context.Context, checks []*proto.Checks, _ ...netip.Addr) (*Info, error) {
|
||||
path := checks[0].Files[0]
|
||||
if path == blockedPath {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
}
|
||||
return &Info{Files: []File{{Path: path, Exist: true}}}, nil
|
||||
}
|
||||
t.Cleanup(func() { gatherInfoWithChecks = original })
|
||||
return entered, release
|
||||
}
|
||||
|
||||
func filesCheck(path string) []*proto.Checks {
|
||||
return []*proto.Checks{{Files: []string{path}}}
|
||||
}
|
||||
|
||||
// TestInfoSource_RefreshRunsConcurrently: only a gathering that timed out holds off new
|
||||
// ones; a caller gathering while another gathering is in progress is served.
|
||||
func TestInfoSource_RefreshRunsConcurrently(t *testing.T) {
|
||||
entered, release := stubGathering(t, "/slow")
|
||||
var src InfoSource
|
||||
|
||||
slowDone := make(chan bool, 1)
|
||||
go func() {
|
||||
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/slow"))
|
||||
slowDone <- ok
|
||||
}()
|
||||
<-entered
|
||||
|
||||
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/fast"))
|
||||
assert.True(t, ok, "a gathering runs while another one is still in progress")
|
||||
|
||||
close(release)
|
||||
assert.True(t, <-slowDone, "the slower gathering completes too")
|
||||
}
|
||||
|
||||
// TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults: a Refresh for the previous
|
||||
// checks that finishes after one for the current checks must not bring back the old
|
||||
// results Current reports.
|
||||
func TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults(t *testing.T) {
|
||||
entered, release := stubGathering(t, "/old")
|
||||
var src InfoSource
|
||||
|
||||
oldDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(oldDone)
|
||||
_, _ = src.Refresh(context.Background(), 15*time.Second, filesCheck("/old"))
|
||||
}()
|
||||
<-entered
|
||||
|
||||
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/new"))
|
||||
require.True(t, ok)
|
||||
close(release)
|
||||
<-oldDone
|
||||
|
||||
files := src.Current(context.Background()).Files
|
||||
require.Len(t, files, 1)
|
||||
assert.Equal(t, "/new", files[0].Path, "the results of the refresh that started last are kept")
|
||||
}
|
||||
|
||||
func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
|
||||
addrs := GetInfo(context.Background()).NetworkAddresses
|
||||
if len(addrs) == 0 {
|
||||
@@ -57,3 +158,41 @@ func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
|
||||
assert.NotEqual(t, excluded, addr.NetIP.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoSource_LostGatheringDoesNotBlockForever: a gathering blocked for good, such as
|
||||
// an os.Stat on a dead network mount, must not stop every later meta sync. Once it has
|
||||
// run for lostAfter timeouts another starts beside it, and no more than maxAbandoned
|
||||
// are ever left running.
|
||||
func TestInfoSource_LostGatheringDoesNotBlockForever(t *testing.T) {
|
||||
entered, release := stubGathering(t, "/wedged")
|
||||
t.Cleanup(func() { close(release) })
|
||||
now := time.Now()
|
||||
src := InfoSource{now: func() time.Time { return now }}
|
||||
const timeout = 10 * time.Millisecond
|
||||
lost := time.Duration(infoLostAfter) * timeout
|
||||
|
||||
_, ok := src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
|
||||
require.False(t, ok, "the wedged gathering times out")
|
||||
<-entered
|
||||
|
||||
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
|
||||
assert.False(t, ok, "a gathering that may still finish holds off the next one")
|
||||
|
||||
now = now.Add(lost)
|
||||
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
|
||||
require.False(t, ok, "the second wedged gathering times out too")
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("once the first gathering is lost another one starts")
|
||||
}
|
||||
|
||||
now = now.Add(lost)
|
||||
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
|
||||
assert.False(t, ok, "no more than maxAbandoned gatherings are left running")
|
||||
select {
|
||||
case <-entered:
|
||||
t.Fatal("a third gathering started beside two lost ones")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user