Collect certificate proofs again when the owner's session changes and report lost proofs

This commit is contained in:
Viktor Liu
2026-10-02 12:37:51 +02:00
parent a91fe94ada
commit 07cf08230c
8 changed files with 322 additions and 20 deletions
+5
View File
@@ -224,6 +224,11 @@ currently open**. Consequences worth designing around:
- **Signing out changes the answer.** Posture can flip between compliant and
non-compliant across a sign-out, so management should treat "no proof" as its own
state rather than as a failed check, or users get disconnected at the sign-in screen.
- **The engine notices the change.** Every minute it checks whether the profile owner's
session came or went, and collects again when it did. A collection that proved nothing
is retried every five minutes, for a keychain unlocked after login or a TPM resource
manager started after the daemon. Losing every proof, and regaining one, is published
as a system event, so the UI and `netbird status` show why access changed.
- **Only the profile owner is asked.** The user certificate belongs to whoever owns the
active NetBird profile. macOS asks the console user only when that user owns the
profile, so a fast-user-switched account never answers for someone else. Windows asks
+5
View File
@@ -108,6 +108,11 @@ func CollectChallenges(ctx context.Context, store Store, challenges []*proto.Cer
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 {
@@ -45,6 +45,20 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
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 {
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,
@@ -17,6 +17,12 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
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 platform default.
func helperStore() Store {
@@ -44,6 +44,21 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
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() {
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 {
+14 -20
View File
@@ -276,8 +276,10 @@ type Engine struct {
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks
// certProofs answers the certificate challenges in checks within a bounded time.
// certProofs answers the certificate challenges in checks within a bounded time, and
// certState remembers what it last proved.
certProofs certproof.Collector
certState certPostureState
infoSource system.InfoSource
@@ -678,6 +680,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
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)
@@ -1258,17 +1266,11 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
if isChecksEqual(e.checks, checks) {
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
}
e.applyInfoFlags(info)
e.attachCertificateProofs(info, checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
if err := e.syncChecksMeta(checks); err != nil {
if errors.Is(err, errSystemInfoTimeout) {
return nil
}
return err
}
e.checks = checks
return nil
@@ -1297,14 +1299,6 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
)
}
// attachCertificateProofs answers the certificate challenges in checks with the
// certificates reachable on this device, signing each challenge nonce for our peer key.
// Collection is bounded in time because callers hold the sync loop while it runs.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
peerKey := e.config.WgPrivateKey.PublicKey()
info.CertificateProofs = e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
+159
View File
@@ -0,0 +1,159 @@
package internal
import (
"context"
"errors"
"fmt"
"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"
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
)
// 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.
type certPostureState struct {
mu sync.Mutex
attempted bool
attemptedAt time.Time
userContext string
proven bool
}
// record stores the outcome of a collection 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(userContext string, proven bool, now time.Time) (changed bool) {
s.mu.Lock()
defer s.mu.Unlock()
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
s.attempted = true
s.attemptedAt = now
s.userContext = userContext
s.proven = proven
return changed
}
// stale reports whether the last collection no longer reflects what the device can
// prove: the user who can answer has changed, or nothing was proven and the retry
// interval has passed. Before the first collection the sync itself collects.
func (s *certPostureState) stale(userContext string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.attempted {
return false
}
if userContext != s.userContext {
return true
}
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
}
// attachCertificateProofs answers the certificate challenges in checks with the
// certificates reachable on this device, signing each challenge nonce for our peer key.
// Collection is bounded in time because callers hold the sync loop while it runs.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
if !certproof.HasChallenges(checks) {
info.CertificateProofs = nil
return
}
userContext := certproof.UserContext(e.config.CertStore)
peerKey := e.config.WgPrivateKey.PublicKey()
info.CertificateProofs = e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
proven := len(info.CertificateProofs) > 0
if e.certState.record(userContext, proven, time.Now()) {
e.publishCertificatePostureEvent(proven)
}
}
// 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)
}
// watchCertificatePosture collects certificate proofs again when the last collection
// went stale, until ctx is done. Proofs are otherwise only collected when the checks
// change or the sync stream reconnects, so a daemon started before anyone logged in
// would not prove a user certificate until the next network map.
func (e *Engine) watchCertificatePosture(ctx context.Context) {
ticker := time.NewTicker(certContextPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := e.recollectCertificateProofsIfStale(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to refresh certificate posture proofs: %v", err)
}
}
}
}
func (e *Engine) recollectCertificateProofsIfStale() error {
userContext := certproof.UserContext(e.config.CertStore)
if !e.certState.stale(userContext, time.Now()) {
return nil
}
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.ctx.Err() != nil || !certproof.HasChallenges(e.checks) {
return nil
}
log.Debugf("certificate posture: proofs are stale, collecting again")
return e.syncChecksMeta(e.checks)
}
// syncChecksMeta gathers the system info that checks evaluate, with its certificate
// proofs, and sends it to management. The caller holds syncMsgMux.
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
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 errSystemInfoTimeout
}
e.applyInfoFlags(info)
e.attachCertificateProofs(info, checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("sync meta: %w", err)
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
//go:build !windows && !darwin
package internal
import (
"context"
"os"
"path/filepath"
"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"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertPostureState_Record(t *testing.T) {
now := time.Now()
var proven certPostureState
assert.False(t, proven.record("", true, now), "a first collection that proves something is not news")
var unproven certPostureState
assert.True(t, unproven.record("", false, now), "a first collection that proves nothing is reported")
assert.False(t, unproven.record("", false, now), "the same outcome again is not reported twice")
assert.True(t, unproven.record("", true, now), "proving again is reported")
assert.True(t, unproven.record("", false, now), "losing the proof again is reported")
}
func TestCertPostureState_Stale(t *testing.T) {
now := time.Now()
var s certPostureState
assert.False(t, s.stale("", now), "before any collection the sync itself collects")
s.record("501:alice", true, now)
assert.False(t, s.stale("501:alice", now.Add(time.Hour)), "a proven collection for the same user stays fresh")
assert.True(t, s.stale("", now), "the user logging out changes what can be proven")
assert.True(t, s.stale("502:bob", now), "another owner session changes what can be proven")
s.record("", false, now)
assert.False(t, s.stale("", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
assert.True(t, s.stale("", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
}
// attachCertificateProofs against a real PEM directory and status recorder: a device that
// cannot prove its certificate gets one warning, and one notice when it can again.
func TestEngine_AttachCertificateProofsReportsLostAndRegainedProofs(t *testing.T) {
dir := t.TempDir()
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
recorder := peer.NewRecorder("")
e := &Engine{
ctx: context.Background(),
config: &EngineConfig{WgPrivateKey: key, CertStore: certproof.Config{Dir: dir}},
statusRecorder: recorder,
}
ca := certtest.NewCA(t, "corp-root")
peerKey := key.PublicKey()
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
checks := []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
warnings := func() int {
n := 0
for _, ev := range recorder.GetEventHistory() {
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
n++
}
}
return n
}
info := &system.Info{}
e.attachCertificateProofs(info, checks)
assert.Empty(t, info.CertificateProofs, "no certificate in the store yet")
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
e.attachCertificateProofs(info, checks)
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
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))
e.attachCertificateProofs(info, checks)
assert.Len(t, info.CertificateProofs, 1, "the certificate is proven once it is in the store")
events := recorder.GetEventHistory()
require.NotEmpty(t, events)
last := events[len(events)-1]
assert.Equal(t, cProto.SystemEvent_INFO, last.Severity, "regaining the proof is reported as good news")
e.attachCertificateProofs(&system.Info{}, []*mgmProto.Checks{{Files: []string{"/bin/agent"}}})
assert.Len(t, recorder.GetEventHistory(), len(events), "checks without a certificate challenge collect and report nothing")
}