Merge remote-tracking branch 'origin/main' into embedded-vnc

# Conflicts:
#	client/cmd/up.go
#	client/internal/engine.go
#	client/internal/profilemanager/config.go
#	client/proto/daemon.pb.go
#	client/server/mdm.go
#	client/server/server.go
#	client/status/status.go
#	client/ui/main.go
#	client/ui/services/windowmanager.go
#	management/internals/shared/grpc/conversion.go
#	management/server/store/sql_store.go
#	management/server/store/sql_store_test.go
#	shared/management/networkmap/envelope.go
#	shared/management/proto/management.pb.go
This commit is contained in:
Viktor Liu
2026-10-19 19:49:54 +09:00
804 changed files with 72029 additions and 25943 deletions
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"encoding/base64"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenInfoMatchesAccount(t *testing.T) {
tests := []struct {
name string
token TokenInfo
hint string
match bool
}{
{
name: "same account",
token: TokenInfo{EmailClaim: "user@example.com"},
hint: "user@example.com",
match: true,
},
{
name: "different account",
token: TokenInfo{EmailClaim: "other@example.com"},
hint: "user@example.com",
match: false,
},
{
name: "case differences are the same account",
token: TokenInfo{EmailClaim: "User@Example.com"},
hint: "user@example.com",
match: true,
},
{
name: "no hint leaves the choice to the IdP",
token: TokenInfo{EmailClaim: "other@example.com"},
hint: "",
match: true,
},
{
name: "token without an email claim is not judged",
token: TokenInfo{EmailClaim: ""},
hint: "user@example.com",
match: true,
},
{
name: "name fallback does not trigger matching",
token: TokenInfo{Email: "Some One"},
hint: "user@example.com",
match: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint))
})
}
}
func TestParseEmailFromIDToken(t *testing.T) {
tests := []struct {
name string
claims map[string]interface{}
wantValue string
wantFromEmail bool
wantErr bool
}{
{
name: "email claim",
claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"},
wantValue: "user@example.com",
wantFromEmail: true,
},
{
name: "name fallback",
claims: map[string]interface{}{"name": "Some One"},
wantValue: "Some One",
},
{
name: "neither claim present",
claims: map[string]interface{}{"sub": "abc"},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
value, fromEmailClaim, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims))
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantValue, value)
assert.Equal(t, tc.wantFromEmail, fromEmailClaim)
})
}
}
func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) {
assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{}))
}
func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string {
t.Helper()
payload, err := json.Marshal(claims)
require.NoError(t, err)
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
}
+11 -7
View File
@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
// Try PKCE flow first
_, err := a.getPKCEFlow(client)
_, err := a.getPKCEFlow(client, false)
if err == nil {
supportsSSO = true
return nil
@@ -136,9 +136,13 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
return supportsSSO, err
}
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
// This avoids creating a new connection to the management server
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection.
// This avoids creating a new connection to the management server.
//
// sessionExtend marks the flow as renewing an existing peer's session rather than
// logging one in; the server needs it to rule out a silent authorization that the
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool, hint string) (OAuthFlow, error) {
var flow OAuthFlow
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
@@ -153,7 +157,7 @@ func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint stri
}
// Try PKCE flow first
pkceFlow, err := a.getPKCEFlow(client)
pkceFlow, err := a.getPKCEFlow(client, sessionExtend)
if err != nil {
// If PKCE not supported, try Device flow
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
@@ -240,8 +244,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
}
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
protoFlow, err := client.GetPKCEAuthorizationFlow()
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
if err != nil {
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
+4 -1
View File
@@ -308,10 +308,13 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
// callers store to send back as the login_hint. Without it a client
// driven through the device flow — Android TV and tvOS — never binds
// an account to its profile and every later login goes out blind.
if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
if email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
log.Warnf("failed to parse email from ID token: %v", err)
} else {
tokenInfo.Email = email
if fromEmailClaim {
tokenInfo.EmailClaim = email
}
}
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
+54 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
@@ -25,6 +26,14 @@ type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// accountPromptForcer is implemented by the PKCE flow only. The device code
// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user
// confirms the code on a page that shows which account signs in, so a silent
// wrong-account answer is not the failure mode there.
type accountPromptForcer interface {
ForceAccountPrompt()
}
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
type AuthFlowInfo struct { //nolint:revive
DeviceCode string `json:"device_code"`
@@ -49,6 +58,23 @@ type TokenInfo struct {
ExpiresIn int `json:"expires_in"`
UseIDToken bool `json:"-"`
Email string `json:"-"`
EmailClaim string `json:"-"`
}
// MatchesAccount reports whether the token belongs to the account a profile is
// bound to. A hint the IdP could not have acted on — no hint stored, or a token
// that carried no email claim — is reported as a match: the check exists to catch a
// login answered from the wrong account, not to block one it cannot judge.
//
// The comparison is case-insensitive. Local-parts are case-sensitive per RFC
// 5321, but no IdP in practice issues two accounts differing only in case, and
// an IdP that echoes a differently-cased address would otherwise fail every
// login.
func (t TokenInfo) MatchesAccount(hint string) bool {
if hint == "" || t.EmailClaim == "" {
return true
}
return strings.EqualFold(t.EmailClaim, hint)
}
// GetTokenToUse returns either the access or id token based on UseIDToken field
@@ -63,19 +89,22 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
}
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration.
//
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
// and if that also fails, the authentication process is deemed unsuccessful
//
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow.
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV).
//
// sessionExtend marks the flow as renewing an existing peer's session rather than
// logging one in. See PKCEAuthorizationFlowRequest for what the server makes of it.
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
return authenticateWithDeviceCodeFlow(ctx, config, hint)
}
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
if err != nil {
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
log.Debug("falling back to device code flow")
@@ -85,14 +114,14 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
}
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
if err != nil {
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
}
@@ -129,3 +158,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
return deviceFlowInfo, nil
}
// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for
// a login answered with an account other than the one hinted. Returns nil when
// the flow cannot ask — the caller then proceeds with the token it has.
//
// Proceeding rather than failing is deliberate. The hint is an email that may
// simply have changed since it was stored, and refusing the login would lock a
// user out of their own profile over a rename. The retry gives the account a
// chance to be corrected; the server still rejects a token that does not own
// the peer.
func RetryFlowForAccount(flow OAuthFlow) OAuthFlow {
forcer, ok := flow.(accountPromptForcer)
if !ok {
return nil
}
forcer.ForceAccountPrompt()
return flow
}
+46 -27
View File
@@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
// PKCEAuthorizationFlow implements the OAuthFlow interface for
// the Authorization Code Flow with PKCE.
type PKCEAuthorizationFlow struct {
providerConfig PKCEAuthProviderConfig
state string
codeVerifier string
oAuthConfig *oauth2.Config
providerConfig PKCEAuthProviderConfig
state string
codeVerifier string
oAuthConfig *oauth2.Config
forceAccountPrompt bool
}
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
@@ -153,11 +154,16 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
}
forceAccountPrompt := p.forceAccountPrompt
p.forceAccountPrompt = false
if !p.providerConfig.DisablePromptLogin {
switch p.providerConfig.LoginFlag {
case common.LoginFlagPromptLogin:
switch {
case forceAccountPrompt:
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
case common.LoginFlagMaxAge0:
case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin:
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0:
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
}
}
@@ -178,6 +184,20 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) {
p.providerConfig.LoginHint = hint
}
// ForceAccountPrompt makes the next authorization request ask the IdP to
// re-authenticate instead of answering from the session it already holds. Used
// to retry a login that came back for an account other than the one hinted.
//
// The next RequestAuthInfo consumes the flag, so a flow that outlives its retry
// goes back to the configured behaviour instead of re-authenticating forever.
//
// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login,
// where retrying with it would replace a wrong-account login with one that
// cannot complete at all.
func (p *PKCEAuthorizationFlow) ForceAccountPrompt() {
p.forceAccountPrompt = true
}
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
// Once the token is received, it is converted to TokenInfo and validated before returning.
@@ -310,49 +330,48 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
}
email, err := parseEmailFromIDToken(tokenInfo.IDToken)
email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken)
if err != nil {
log.Warnf("failed to parse email from ID token: %v", err)
} else {
tokenInfo.Email = email
if fromEmailClaim {
tokenInfo.EmailClaim = email
}
}
return tokenInfo, nil
}
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
// without verifying its signature. The value is best-effort and used only as a
// UX convenience (login hint prefill and display); it never drives an
// authorization decision. The authoritative identity is established server-side
// from the signature-verified token.
func parseEmailFromIDToken(token string) (string, error) {
// without verifying its signature. The value is best-effort: it prefills the
// login hint and is displayed. Account matching (see MatchesAccount) only uses
// it when it came from the email claim, which fromEmailClaim reports. It never
// grants anything — the authoritative identity is established server-side from
// the signature-verified token.
func parseEmailFromIDToken(token string) (value string, fromEmailClaim bool, err error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return "", fmt.Errorf("invalid token format")
return "", false, fmt.Errorf("invalid token format")
}
data, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("failed to decode payload: %w", err)
return "", false, fmt.Errorf("failed to decode payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(data, &claims); err != nil {
return "", fmt.Errorf("json unmarshal error: %w", err)
return "", false, fmt.Errorf("json unmarshal error: %w", err)
}
var email string
if emailValue, ok := claims["email"].(string); ok {
email = emailValue
} else {
val, ok := claims["name"].(string)
if ok {
email = val
} else {
return "", fmt.Errorf("email or name field not found in token payload")
}
if email, ok := claims["email"].(string); ok {
return email, true, nil
}
if name, ok := claims["name"].(string); ok {
return name, false, nil
}
return email, nil
return "", false, fmt.Errorf("email or name field not found in token payload")
}
func createCodeChallenge(codeVerifier string) string {
+26
View File
@@ -76,6 +76,32 @@ func TestPromptLogin(t *testing.T) {
}
}
func TestForceAccountPromptAppliesOnlyToTheRetry(t *testing.T) {
config := PKCEAuthProviderConfig{
ClientID: "test-client-id",
Audience: "test-audience",
TokenEndpoint: "https://test-token-endpoint.com/token",
Scope: "openid email profile",
AuthorizationEndpoint: "https://test-auth-endpoint.com/authorize",
RedirectURLs: []string{"http://127.0.0.1:33992/"},
UseIDToken: true,
LoginFlag: mgm.LoginFlagNone,
}
pkce, err := NewPKCEAuthorizationFlow(config)
require.NoError(t, err)
pkce.ForceAccountPrompt()
retry, err := pkce.RequestAuthInfo(context.Background())
require.NoError(t, err)
require.Contains(t, retry.VerificationURIComplete, "prompt=login")
next, err := pkce.RequestAuthInfo(context.Background())
require.NoError(t, err)
require.NotContains(t, next.VerificationURIComplete, "prompt=login",
"the forced prompt outlived the retry it was armed for")
}
func TestIsPortInExcludedRange(t *testing.T) {
tests := []struct {
name string
+205 -100
View File
@@ -4,12 +4,20 @@
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
// fallback dialog.
//
// The deadline is an absolute wall-clock instant, so the watcher compares
// it against the wall clock on a ticker rather than arming a relative
// timer for it. A relative timer runs on the monotonic clock, which does
// not advance while a device is suspended: it fires once that much awake
// time has passed, which can be long after the deadline, and nothing
// re-evaluates when the device wakes up. Polling makes every tick after a
// resume (or after an NTP correction) see the real remaining time.
//
// The watcher is idempotent: Update may be called as often as the network
// map snapshots arrive. Repeating the same deadline is a no-op; a new
// deadline reschedules the timers and arms a fresh warning cycle.
// deadline starts a fresh warning cycle.
//
// Warning firing is edge-detected. Each unique deadline value fires each
// warning callback at most once.
// Warning firing is edge-detected. Each unique deadline value publishes
// each warning at most once.
package sessionwatch
import (
@@ -28,9 +36,15 @@ const (
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
// glitch, and silently arming a 100-year timer would hide the bug.
// glitch, and silently tracking a 100-year deadline would hide the bug.
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
// defaultEvalInterval is how often the tracked deadline is compared
// against the wall clock. The leads are minutes, so a coarse tick costs
// nothing in accuracy and keeps the wakeup cheap on battery-powered
// devices.
defaultEvalInterval = 10 * time.Second
// WarningLead is how far before expiry the first (interactive)
// warning fires. Drives the T-10 OS notification with
// Extend/Dismiss actions.
@@ -92,18 +106,23 @@ type StatusRecorder interface {
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
// the first one for the same deadline). Safe for concurrent use.
type Watcher struct {
lead time.Duration
finalLead time.Duration
lead time.Duration
finalLead time.Duration
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,27 +134,40 @@ 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 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
return w
}
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
// 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).
@@ -155,7 +187,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()
@@ -173,18 +205,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.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()
@@ -192,6 +228,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
}
@@ -204,9 +262,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() {
@@ -219,35 +277,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
@@ -259,11 +330,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 {
@@ -272,87 +343,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) {
deadline := w.current
if !w.announcedAt.Equal(deadline) {
// Update is still on its way to the recorder with this deadline.
w.mu.Unlock()
return
}
w.firedAt = armedFor
// 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))
switch {
case remaining <= 0:
// Already expired: the post-mortem SessionExpired flow owns it.
w.mu.Unlock()
case w.finalLead > 0 && remaining <= w.finalLead:
w.publishFinalLocked(deadline, remaining)
case remaining <= w.lead:
w.publishWarningLocked(deadline, remaining)
default:
w.mu.Unlock()
}
}
// 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()
return
}
w.firedAt = deadline
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session expiry soon warning fired")
publishWarning(recorder, armedFor, false)
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)
}
// 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) {
// 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
}
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
}
w.finalFiredAt = armedFor
w.firedAt = deadline
w.finalFiredAt = deadline
recorder := w.recorder
w.mu.Unlock()
if recorder == nil {
return
}
log.Infof("auth session final-warning fired")
publishWarning(recorder, armedFor, true)
}
// 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)
}
return time.AfterFunc(delay, cb)
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
@@ -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) {
@@ -117,10 +126,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) {
@@ -411,7 +464,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)
@@ -447,7 +500,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)
@@ -478,7 +531,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)
@@ -508,7 +561,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,3 +581,340 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
}
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
}
// 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()
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)
deadline := start.Add(time.Hour).Round(0)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
return w, clock, deadline
}
func TestResumeInsideWarningWindowWarns(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
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 n := countWhere(events, event.isFinalWarning); n != 0 {
t.Fatalf("final-warning must wait for its own window, got %d: %+v", n, events)
}
}
func TestResumeInsideFinalWindowSendsFinalWarningOnly(t *testing.T) {
r := &fakeRecorder{}
_, clock, deadline := newResumeWatcher(t, r)
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()
clock.set(deadline.Add(-time.Minute))
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 TestCloseStopsTheEvaluationLoop(t *testing.T) {
r := &fakeRecorder{}
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
start := time.Now()
clock := newFakeClock(start)
w.nowFn = clock.now
deadline := start.Add(time.Hour).Round(0)
if err := w.Update(deadline); err != nil {
t.Fatalf("Update: %v", err)
}
w.Close()
clock.set(deadline.Add(-5 * time.Minute))
settle()
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())
}
}
func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
r := &fakeRecorder{}
w := NewDeadlineOnly(r)
defer w.Close()
// 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)
}
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("expected recorder deadline %v, got %v", d, got)
}
time.Sleep(100 * time.Millisecond)
events := r.snapshot()
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)
}
w.mu.Lock()
polling := w.stop != nil
w.mu.Unlock()
if polling {
t.Fatal("expected no evaluation loop in deadline-only mode")
}
}
func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) {
r := &fakeRecorder{}
w := NewDeadlineOnly(r)
defer w.Close()
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
t.Fatalf("Update: %v", err)
}
err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour))
if !errors.Is(err, ErrDeadlineInPast) {
t.Fatalf("expected ErrDeadlineInPast, got %v", err)
}
if got := r.deadline(); !got.IsZero() {
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,
) bool {
select {
case r.entered <- struct{}{}:
default:
}
<-r.release
return 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")
}
}
}
+168
View File
@@ -0,0 +1,168 @@
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 {
ders := make([][]byte, 0, len(chain))
for _, cert := range chain {
ders = append(ders, cert.Raw)
}
return chainDERFingerprint(ders)
}
// chainDERFingerprint identifies a chain of DER certificates by the hashes of all of them,
// in order.
func chainDERFingerprint(chain [][]byte) [sha256.Size]byte {
buf := make([]byte, 0, len(chain)*sha256.Size)
for _, der := range chain {
certHash := sha256.Sum256(der)
buf = append(buf, certHash[:]...)
}
return sha256.Sum256(buf)
}
+137
View File
@@ -0,0 +1,137 @@
//go:build !ios
package certproof
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// userHelperBackoff holds off asking a user's keychain again after it proved nothing.
var userHelperBackoff = newHelperBackoff()
// CollectProofs answers the certificate challenges in checks from every store this Mac
// can reach. The root daemon reads the System keychain itself, which is where MDM
// installs device identities, and reaches the console user's login keychain only by
// launching a helper into that user's session. A Mac sitting at the login window
// therefore yields device proofs alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
// A helper already runs inside the user's session, so it reads its own keychain
// directly and must never launch another one.
if os.Geteuid() != 0 {
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
if cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsConsoleUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Debugf("certificate posture: console user keychain unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the user whose keychain a collection would include: the console
// user when it owns the active profile, or empty when no user keychain would be asked. A
// change means a collection made earlier no longer reflects what this Mac can prove.
func UserContext(cfg Config) string {
if os.Geteuid() != 0 || cfg.OwnerUnknown {
return ""
}
user, ok := currentConsoleUser()
if !ok || !user.isOwner(cfg.ProfileOwner) {
return ""
}
return strconv.FormatUint(uint64(user.UID), 10) + ":" + user.Name
}
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
// which is what launchctl asuser does.
func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := currentConsoleUser()
if !ok {
return nil, nil
}
if !user.isOwner(owner) {
log.Debugf("certificate posture: console user %s does not own the active profile, no user keychain is asked", user.Name)
return nil, nil
}
uid := strconv.FormatUint(uint64(user.UID), 10)
backoffKey := helperBackoffKey(uid, challenges)
if !userHelperBackoff.allow(backoffKey, time.Now()) {
log.Debugf("certificate posture: the keychain of uid %s proved nothing recently, not asking again yet", uid)
return nil, nil
}
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
parent := ctx
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
// Absolute paths, because the daemon's PATH is configurable through the service
// environment, and sudo selects the user by uid so the name never has to round-trip.
cmd := exec.CommandContext(ctx, "/bin/launchctl", "asuser", uid, "/usr/bin/sudo", "-u", "#"+uid, "-H", "--", binary, "posture", "cert-proof")
killHelperGroupOnCancel(cmd)
log.Debugf("certificate posture: asking the desktop session of uid %s to answer %d challenges", uid, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
// A run that completed, or ran into the timeout waiting on a prompt nobody answered,
// tells whether the keychain proves anything. A launch or output failure, or a run the
// caller cut short, says nothing about it and must not hold off the next one.
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) && parent.Err() == nil
if err == nil || timedOut {
userHelperBackoff.record(backoffKey, err == nil && len(proofs) > 0, time.Now())
}
if err != nil {
return nil, fmt.Errorf("run helper as uid %s: %w", uid, err)
}
log.Debugf("certificate posture: desktop session of uid %s returned %d proofs", uid, len(proofs))
return proofs, nil
}
// helperStore is the store the helper reads: the user's login keychain alone. The
// session's search list also holds the System keychain, which the daemon reads itself,
// and using a System keychain key from the user's session would ask for an
// administrator's approval.
func helperStore() Store {
home, err := os.UserHomeDir()
if err != nil {
log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err)
return newKeychainStore()
}
login := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
if _, err := os.Stat(login); err != nil {
// Keychains created before macOS 10.12 keep the old file name.
login = filepath.Join(home, "Library", "Keychains", "login.keychain")
}
return newKeychainStore(login)
}
+31
View File
@@ -0,0 +1,31 @@
//go:build js
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
// store to sign with, so the stores stay out of the WebAssembly build.
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
return nil
}
// UserContext identifies the user whose certificates a collection would include. A
// browser has no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// DefaultStore is empty in a browser.
func DefaultStore() Store {
return Stores{}
}
func helperStore() Store {
return DefaultStore()
}
@@ -0,0 +1,31 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
// per-user certificates out of reach of a privileged daemon, so every other platform
// reads its store in the daemon itself.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
}
// UserContext identifies the user whose certificates a collection would include. These
// platforms have no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// helperStore is the store the helper reads. Nothing launches a helper on these
// platforms, so it is the store the daemon reads, configured from the same environment,
// which lets an administrator check a setup by running the helper by hand.
func helperStore() Store {
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
}
+258
View File
@@ -0,0 +1,258 @@
package certproof
import (
"context"
"crypto/rand"
"crypto/x509"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
corpCA := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := storeDir(t)
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
otherKey := certtest.RSAKey(t)
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
writeFile(t, dir, "notes.txt", "ignored")
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
challenge := func(cas ...string) *proto.Checks {
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
}
checks := []*proto.Checks{
{Files: []string{"/usr/bin/agent"}},
challenge(corpCA.PEM),
challenge(corpCA.PEM),
challenge(otherCA.PEM),
challenge(unrelatedCA.PEM),
challenge("not a pem"),
}
proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey)
require.Len(t, proofs, 2)
var subjects []string
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, time.Now())
require.NoError(t, err)
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
}
func TestCollect_NothingToProve(t *testing.T) {
dir := storeDir(t)
key := certtest.ECDSAKey(t)
ca := certtest.NewCA(t, "root")
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
tests := []struct {
name string
store Store
checks []*proto.Checks
}{
{"no checks", newFileStore(dir), nil},
{"files only", newFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
{"challenge without nonce", newFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", newFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
})
}
}
func TestFileStore_ChainWithIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := newFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1)
require.Len(t, candidates[0].Chain, 2)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
// leaves the directory group-writable on systems with a user-private group umask.
func storeDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
return dir
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
store := newFileStore(dir)
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
tests := []struct {
name string
nonce []byte
peerKey []byte
want int
}{
{"issued nonce and peer key are signed", nonce, peerKey, 1},
{"short nonce is not signed", nonce[:8], peerKey, 0},
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
{"short peer key is not signed", nonce, peerKey[:16], 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
"the device key signs only what management could have issued")
})
}
}
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
// A stale key next to a renewed certificate, sorted before the good pair, must not
// produce a proof that management rejects and stop the search there.
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
goodKey := certtest.ECDSAKey(t)
good := ca.Issue(t, goodKey, "good")
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
candidates, err := newFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
proofs := CollectChallenges(context.Background(), newFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
require.Len(t, proofs, 1)
_, err = challenger.Verify(proofs[0], peerKey, now)
assert.NoError(t, err, "the proof sent is one management accepts")
}
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
type staticStore []Candidate
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
// Renewing a CA with the same key pair leaves two certificates with the same
// subject and key in the store. The expired one sorts first here.
expiredTmpl := *intermediate.Cert
expiredTmpl.SerialNumber = big.NewInt(1)
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
require.NoError(t, err)
expired, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
pool := []*x509.Certificate{expired, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Len(t, chain, 2)
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
verified, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
"management's own check accepts the chain the proof carries")
}
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
rootA := certtest.NewCA(t, "root-a")
rootB := certtest.NewCA(t, "root-b")
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
crossTmpl := *issuer.Cert
crossTmpl.SerialNumber = big.NewInt(2)
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
require.NoError(t, err)
cross, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := issuer.Issue(t, key, "device")
pool := []*x509.Certificate{issuer.Cert, cross}
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
challenges := []*proto.CertificateChallenge{
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
}
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
for _, root := range []*certtest.CA{rootA, rootB} {
matched := false
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, now)
require.NoError(t, err)
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
}
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
}
}
@@ -0,0 +1,108 @@
package certproof
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this
// machine can reach. The service reads the local machine store itself, where AD and
// Intune enrol device certificates, and reaches the signed-in user's store by launching
// a helper with that session's token. A machine at the sign-in screen therefore proves
// device certificates alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
// The helper already runs as the signed-in user, and an ordinary process has no
// right to a session token, so only the service goes looking for one.
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the session whose store a collection would include: a session
// of the profile owner, or empty when no user store would be asked. A change means a
// collection made earlier no longer reflects what this machine can prove.
func UserContext(cfg Config) string {
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return ""
}
user, ok := currentDesktopUser(cfg.ProfileOwner)
if !ok {
return ""
}
defer user.Close()
return fmt.Sprintf("%d:%s", user.Session, user.Name)
}
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
// that user's store rather than the machine store the service already read.
func helperStore() Store {
return newUserStore()
}
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
// directly, so the session token goes straight into the child process.
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := currentDesktopUser(owner)
if !ok {
return nil, nil
}
defer user.Close()
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
// The user's own environment, not the service's: the service environment may carry
// secrets such as a setup key that the signed-in user must not be able to read.
env, err := user.Token.Environ(false)
if err != nil {
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(user.Token),
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
}
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
return proofs, nil
}
+189
View File
@@ -0,0 +1,189 @@
package certproof
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
collectTimeout = 45 * time.Second
// lostAfter is how many deadlines an abandoned collection is waited for before a new
// one may start beside it. A store that is merely slow finishes well within that and
// releases its slot; only one wedged for good is worked around.
lostAfter = 10
// maxInFlight bounds how many abandoned collections may pile up. A store that never
// answers would otherwise leak a goroutine on every collection for the life of the
// daemon.
maxInFlight = 2
)
// collectSlots tracks the collections running in the process. Collectors share one by
// default: a collection abandoned in a token, TPM or keychain call keeps running after
// the engine that started it has stopped, and the next engine must account for it.
type collectSlots struct {
mu sync.Mutex
next uint64
// running holds the start time of each collection still running, by slot id.
running map[uint64]time.Time
}
var processSlots collectSlots
// Collector runs CollectProofs with a deadline and, in the normal case, one collection
// at a time in the process. Token, TPM and keychain calls cannot be interrupted, and on
// Linux there is not even a process to kill, so a collection that overruns is abandoned
// rather than awaited. A single wedged call would then refuse every later collection for
// the life of the daemon, so once an abandoned collection has been running for lostAfter
// deadlines another may start beside it, up to maxInFlight. The zero value is ready to
// use.
type Collector struct {
// slots overrides the process-wide slots when set.
slots *collectSlots
// timeout overrides collectTimeout when set.
timeout time.Duration
// now overrides the clock when set.
now func() time.Time
}
// Collect answers the certificate challenges in checks, returning no proofs when there
// are no challenges, when previous collections still hold the slots, or when this one
// does not finish in time. Missing proofs fail the certificate check on management.
func (c *Collector) Collect(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return c.collect(ctx, checks, func(ctx context.Context) []certposture.Proof {
return CollectProofs(ctx, checks, peerKey, cfg)
})
}
func (c *Collector) collect(ctx context.Context, checks []*proto.Checks, run func(context.Context) []certposture.Proof) []certposture.Proof {
if len(certificateChallenges(checks)) == 0 {
return nil
}
slots := c.slotsInUse()
slot, ok := slots.start(c.clock(), time.Duration(lostAfter)*c.deadline())
if !ok {
return nil
}
ctx, cancel := context.WithTimeout(ctx, c.deadline())
defer cancel()
done := make(chan []certposture.Proof, 1)
go func() {
// The slot is freed before the result is delivered, so a caller that starts the
// next collection right after this one returned is not turned away.
proofs := run(ctx)
slots.finish(slot)
done <- proofs
}()
select {
case proofs := <-done:
return proofs
case <-ctx.Done():
log.Warnf("certificate posture: proof collection did not finish within %s, sending no proofs", c.deadline())
return nil
}
}
// Stuck reports whether lost collections hold every slot. No collection starts again
// until one of them returns, which for a store that never answers means a restart.
func (c *Collector) Stuck() bool {
return c.slotsInUse().allLost(c.clock(), time.Duration(lostAfter)*c.deadline())
}
func (c *Collector) slotsInUse() *collectSlots {
if c.slots != nil {
return c.slots
}
return &processSlots
}
func (c *Collector) clock() time.Time {
if c.now != nil {
return c.now()
}
return time.Now()
}
func (c *Collector) deadline() time.Duration {
if c.timeout > 0 {
return c.timeout
}
return collectTimeout
}
// start claims a slot at now, reporting its id and whether the caller may collect. A
// collection that has held a slot for lost is taken to be wedged.
func (s *collectSlots) start(now time.Time, lost time.Duration) (uint64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.running) > 0 {
oldest := s.oldest()
switch {
case len(s.running) >= maxInFlight:
log.Warnf("certificate posture: %d proof collections are wedged, sending no proofs", len(s.running))
return 0, false
case now.Sub(oldest) < lost:
log.Warnf("certificate posture: previous proof collection is still running, sending no proofs")
return 0, false
default:
log.Warnf("certificate posture: a proof collection has been stuck since %s, starting another", oldest.Format(time.RFC3339))
}
}
if s.running == nil {
s.running = make(map[uint64]time.Time, maxInFlight)
}
s.next++
s.running[s.next] = now
return s.next, true
}
// finish releases the slot with the given id.
func (s *collectSlots) finish(slot uint64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.running, slot)
}
// allLost reports whether every slot is held by a collection that has run for lost.
func (s *collectSlots) allLost(now time.Time, lost time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.running) < maxInFlight {
return false
}
for _, started := range s.running {
if now.Sub(started) < lost {
return false
}
}
return true
}
// idle reports whether no collection is running.
func (s *collectSlots) idle() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.running) == 0
}
// oldest returns the start time of the longest-running collection. The caller holds mu
// and has checked that one is running.
func (s *collectSlots) oldest() time.Time {
var oldest time.Time
for _, started := range s.running {
if oldest.IsZero() || started.Before(oldest) {
oldest = started
}
}
return oldest
}
+207
View File
@@ -0,0 +1,207 @@
package certproof
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
}}}
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
var c Collector
called := false
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no store is touched when no check carries a challenge")
}
func TestCollector_ReturnsProofs(t *testing.T) {
var c Collector
want := []certposture.Proof{{Nonce: []byte("nonce")}}
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
}
func TestCollector_AbandonsStuckCollection(t *testing.T) {
c := Collector{timeout: 50 * time.Millisecond, slots: &collectSlots{}}
release := make(chan struct{})
finished := make(chan struct{})
// A token or keychain call that ignores its context and blocks well past the deadline.
stuck := func(context.Context) []certposture.Proof {
defer close(finished)
<-release
return []certposture.Proof{{Nonce: []byte("late")}}
}
start := time.Now()
proofs := c.collect(context.Background(), challengeChecks, stuck)
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
called := false
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no second collection starts while the first is still running")
close(release)
<-finished
require.Eventually(t, func() bool { return c.slots.idle() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
want := []certposture.Proof{{Nonce: []byte("nonce")}}
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
"collection works again after the stuck call returned")
}
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
c := Collector{timeout: 20 * time.Millisecond, slots: &collectSlots{}}
cancelled := make(chan struct{})
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
<-ctx.Done()
close(cancelled)
return nil
})
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
}
}
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
// collection stuck in a token call outlives the engine that started it, so the next
// engine's Collector must not start another one until it has finished.
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
shared := &collectSlots{}
first := Collector{timeout: 20 * time.Millisecond, slots: shared}
second := Collector{timeout: time.Second, slots: shared}
release := make(chan struct{})
stuck := func(context.Context) []certposture.Proof {
<-release
return nil
}
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
called := false
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return []certposture.Proof{{}}
})
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
assert.False(t, called, "a second collection does not start on top of the abandoned one")
close(release)
require.Eventually(t, func() bool { return shared.idle() }, time.Second, 5*time.Millisecond)
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
"collections resume once the abandoned one finished")
}
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
var a, b Collector
assert.Same(t, a.slotsInUse(), b.slotsInUse(), "separate Collectors share the process slots")
}
// TestCollector_LostCollectionDoesNotBlockForever: a store call that never answers, such
// as a wedged token on Linux where there is no process to kill, must not refuse every
// later collection. Once it has run for lostAfter deadlines another starts beside it,
// and no more than maxInFlight ever run.
func TestCollector_LostCollectionDoesNotBlockForever(t *testing.T) {
now := time.Now()
slots := &collectSlots{}
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
release := make(chan struct{})
defer close(release)
var started atomic.Int32
wedged := func(context.Context) []certposture.Proof {
started.Add(1)
<-release
return nil
}
c.collect(context.Background(), challengeChecks, wedged)
waitStarted(t, &started, 1)
c.collect(context.Background(), challengeChecks, wedged)
assert.Equal(t, int32(1), started.Load(), "a second collection is refused while the first may still finish")
assert.False(t, c.Stuck(), "one slow collection is not stuck yet")
now = now.Add(time.Duration(lostAfter) * c.deadline())
c.collect(context.Background(), challengeChecks, wedged)
waitStarted(t, &started, 2)
assert.False(t, c.Stuck(), "the second collection may still return, so the collector is not stuck yet")
now = now.Add(time.Duration(lostAfter) * c.deadline())
c.collect(context.Background(), challengeChecks, wedged)
assert.Equal(t, int32(2), started.Load(), "no more than maxInFlight collections run")
assert.True(t, c.Stuck(), "with every slot held by a lost collection the collector reports itself stuck")
}
// TestCollector_LostWindowFollowsTheOldestRunningCollection: A wedges, B starts beside it
// once A counts as lost, then A returns. B is recent, so a third collection must wait for
// B to be lost in its own right rather than inherit A's start time.
func TestCollector_LostWindowFollowsTheOldestRunningCollection(t *testing.T) {
now := time.Now()
slots := &collectSlots{}
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
lost := time.Duration(lostAfter) * c.deadline()
releaseA := make(chan struct{})
releaseB := make(chan struct{})
defer close(releaseB)
var started atomic.Int32
blockOn := func(release chan struct{}) func(context.Context) []certposture.Proof {
return func(context.Context) []certposture.Proof {
started.Add(1)
<-release
return nil
}
}
c.collect(context.Background(), challengeChecks, blockOn(releaseA))
waitStarted(t, &started, 1)
now = now.Add(lost)
c.collect(context.Background(), challengeChecks, blockOn(releaseB))
waitStarted(t, &started, 2)
close(releaseA)
require.Eventually(t, func() bool {
slots.mu.Lock()
defer slots.mu.Unlock()
return len(slots.running) == 1
}, time.Second, 5*time.Millisecond, "A frees its slot once it returns")
c.collect(context.Background(), challengeChecks, blockOn(make(chan struct{})))
assert.Equal(t, int32(2), started.Load(), "a third collection waits while B is recent")
}
// waitStarted waits for the collections the test launched to have started. collect may
// return at its deadline before the goroutine running the store call was scheduled.
func waitStarted(t *testing.T, started *atomic.Int32, n int32) {
t.Helper()
require.Eventually(t, func() bool { return started.Load() == n }, time.Second, time.Millisecond,
"%d collections should have started", n)
}
@@ -0,0 +1,123 @@
//go:build !ios
package certproof
import (
"bytes"
"fmt"
"strconv"
"sync"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
encodingUTF8 = 0x08000100
consoleNameBufSize = 256
)
var (
consoleOnce sync.Once
consoleErr error
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
)
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
type ConsoleUser struct {
Name string
UID uint32
GID uint32
}
// currentConsoleUser reports the user sitting at the desktop. The second return value is
// false when nobody is: at the login window macOS either reports no console user at all
// or attributes the session to root, and neither has a login keychain to offer.
func currentConsoleUser() (ConsoleUser, bool) {
if err := loadConsoleUser(); err != nil {
log.Debugf("console user lookup unavailable: %v", err)
return ConsoleUser{}, false
}
var uid, gid uint32
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
if name == 0 {
log.Debug("no console user is logged in, no login keychain is reachable")
return ConsoleUser{}, false
}
defer release(name)
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
if !user.hasDesktop() {
log.Debugf("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
return ConsoleUser{}, false
}
return user, true
}
// hasDesktop reports whether the console session is a real user desktop. The login
// window runs as root and some macOS releases name it "loginwindow" instead.
func (u ConsoleUser) hasDesktop() bool {
switch u.Name {
case "", "root", "loginwindow":
return false
}
return u.UID != 0
}
// isOwner reports whether the console user is owner, the account of the active profile,
// which is recorded as a short user name or, for an account without one, a numeric uid.
// With no owner the console user counts, as macOS has a single console user.
func (u ConsoleUser) isOwner(owner string) bool {
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
}
func cfString(str uintptr) string {
buf := make([]byte, consoleNameBufSize)
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
return ""
}
if end := bytes.IndexByte(buf, 0); end >= 0 {
return string(buf[:end])
}
return string(buf)
}
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
// first because CFRelease is resolved there and released strings depend on it.
func loadConsoleUser() error {
if err := loadKeychain(); err != nil {
return err
}
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
return consoleErr
}
func resolveConsoleUser() error {
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
if err != nil {
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
}
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
if err != nil {
return fmt.Errorf("resolve CFStringGetCString: %w", err)
}
purego.RegisterFunc(&cfStringGetCString, symbol)
return nil
}
@@ -0,0 +1,51 @@
//go:build !ios
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
tests := []struct {
name string
user ConsoleUser
desktop bool
}{
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
{"no console user", ConsoleUser{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
})
}
}
// currentConsoleUser runs against the real SystemConfiguration framework. A machine with
// a desktop open must report a non-root user; a headless runner must report none.
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
user, ok := currentConsoleUser()
if !ok {
t.Log("no console user, running headless")
return
}
assert.NotEmpty(t, user.Name, "a console user must have a name")
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
}
func TestConsoleUser_IsOwner(t *testing.T) {
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
assert.True(t, user.isOwner("maycon"), "the owner by short name")
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
assert.False(t, user.isOwner("502"), "another uid is another account")
}
@@ -0,0 +1,182 @@
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, which x/sys/windows does not export.
wtsCurrentServer = windows.Handle(0)
)
// 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{windows.WTSActive, windows.WTSDisconnected} {
for _, session := range unsafe.Slice(info, count) {
if session.SessionID == servicesSession || session.SessionID == console || session.State != state {
continue
}
sessions = append(sessions, session.SessionID)
}
}
return sessions, nil
}
// runningAsLocalSystem reports whether this process is the service. The helper runs as
// the signed-in user and must read its own store rather than launching another helper.
func runningAsLocalSystem() bool {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
log.Debugf("failed reading own token user: %v", err)
return false
}
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
}
@@ -0,0 +1,61 @@
package certproof
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSameAccountName(t *testing.T) {
tests := []struct {
session, owner string
want bool
}{
{`CORP\alice`, `CORP\alice`, true},
{`CORP\alice`, `corp\ALICE`, true},
{`CORP\alice`, `alice`, true},
{`CORP\alice`, `OTHER\alice`, false},
{`CORP\alice`, `bob`, false},
{`alice`, `alice`, true},
{`CORP\alice`, `CORP\alic`, false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, sameAccountName(tt.session, tt.owner), "session %q, owner %q", tt.session, tt.owner)
}
}
// currentDesktopUser against the real session manager: an owner no session belongs to
// must never yield a session, whoever else is signed in.
func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) {
user, ok := currentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`)
if ok {
user.Close()
}
assert.False(t, ok, "a profile owner without a session must not borrow another user's store")
}
// TestCurrentDesktopUser_FindsTheOwnersSession runs against a real machine as LocalSystem,
// with NB_TEST_DESKTOP_OWNER naming an account that is signed in. That account's session
// must be found, and only that account's.
func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) {
owner := os.Getenv("NB_TEST_DESKTOP_OWNER")
if owner == "" {
t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem")
}
user, ok := currentDesktopUser(owner)
require.True(t, ok, "the signed-in owner %s must have a session", owner)
defer user.Close()
t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name)
assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name)
assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner")
console, consoleOK := currentDesktopUser("")
if consoleOK {
defer console.Close()
t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name)
} else {
t.Log("without an owner nobody counts: no user at the console")
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package certproof answers certificate posture challenges: it finds the certificates a
// peer holds a private key for and signs the challenge nonce, bound to the peer's
// WireGuard key, with that key. Management verifies the signature and the chain against
// the CAs of the check, so only a holder of the key passes and a proof made for one peer
// cannot be replayed by another. Keys in the OS stores, the TPM and a PKCS#11 token are
// used through the platform, which signs; a plain PEM key file is the exception, parsed
// and used in the daemon's memory.
//
// Machine stores are read by the daemon itself: the Windows LocalMachine store, the macOS
// System keychain, and on Linux a directory of PEM files, TSS2 key files the TPM signs
// with, and a PKCS#11 token. On Linux the directory and the token URI come from the
// daemon's environment (NB_CERT_STORE_DIR, NB_CERT_PKCS11_URI), as does the token PIN
// (NB_TPM_PIN); none of them is read from the profile config.
//
// User stores cannot be read by a privileged daemon, so it starts this binary as
// "netbird posture cert-proof" inside the user's session and receives only signatures
// and chains on its stdout. On macOS uid is not what unlocks a login keychain, the
// session's bootstrap namespace is, which is why the helper enters it through launchctl
// asuser. On Windows a service opening CURRENT_USER silently reads its own empty hive, so
// the helper runs with the session's token instead. Its output is untrusted: sizes are
// capped and only proofs for requested nonces are kept.
//
// A user proof shows that some process in that user's session could use a key whose
// certificate chains to the CA. It does not show which binary signed, or that the key is
// hardware-bound; device stores are the stronger signal.
package certproof
+66
View File
@@ -0,0 +1,66 @@
package certproof
import (
"context"
"encoding/json"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// HelperRequest is the work the daemon hands to a helper running in a user session. The
// peer key binds every signature to this machine, so a proof cannot be replayed onto
// another peer.
type HelperRequest struct {
PeerKey []byte `json:"peerKey"`
Challenges []HelperChallenge `json:"challenges"`
}
// HelperChallenge is one certificate challenge in the form the helper needs.
type HelperChallenge struct {
Nonce []byte `json:"nonce"`
CACertificates []string `json:"caCertificates"`
}
// HelperResponse carries the proofs the helper produced from its own keychain.
type HelperResponse struct {
Proofs []certposture.Proof `json:"proofs"`
}
// RunHelper answers the challenges on in from the store of the user running this
// process and writes the proofs to out. It is the child half of the console user
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
// session instead. Only the signature crosses back, never the private key.
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
return runHelper(ctx, helperStore(), in, out)
}
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
var req HelperRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode helper request: %w", err)
}
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
for _, challenge := range req.Challenges {
challenges = append(challenges, &proto.CertificateChallenge{
Nonce: challenge.Nonce,
CaCertificates: challenge.CACertificates,
})
}
var proofs []certposture.Proof
if len(challenges) > 0 {
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
}
log.Debugf("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
return fmt.Errorf("encode helper response: %w", err)
}
return nil
}
@@ -0,0 +1,78 @@
package certproof
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"sync"
"time"
"github.com/netbirdio/netbird/shared/management/proto"
)
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
// every collection would put up a new one each time the user ignored or denied the last.
const helperQuietPeriod = time.Hour
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
// is not launched again because its last run proved nothing.
type helperBackoff struct {
mu sync.Mutex
until map[string]time.Time
}
func newHelperBackoff() *helperBackoff {
return &helperBackoff{until: map[string]time.Time{}}
}
// allow reports whether the helper may be launched for key now.
func (b *helperBackoff) allow(key string, now time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
return !now.Before(b.until[key])
}
// record stores the outcome of a helper run for key: one that proved something clears
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
// map holds only users currently held off.
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
for other, until := range b.until {
if !now.Before(until) {
delete(b.until, other)
}
}
if proven {
delete(b.until, key)
return
}
b.until[key] = now.Add(helperQuietPeriod)
}
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
// order, leaving out the nonces, which rotate without changing what the user is asked to
// prove.
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
sets := make([]string, 0, len(challenges))
for _, challenge := range challenges {
cas := make([]string, 0, len(challenge.GetCaCertificates()))
for _, ca := range challenge.GetCaCertificates() {
sum := sha256.Sum256([]byte(ca))
cas = append(cas, hex.EncodeToString(sum[:]))
}
slices.Sort(cas)
sets = append(sets, strings.Join(cas, ","))
}
slices.Sort(sets)
h := sha256.New()
h.Write([]byte(user))
for _, set := range sets {
h.Write([]byte{0})
h.Write([]byte(set))
}
return hex.EncodeToString(h.Sum(nil))
}
@@ -0,0 +1,69 @@
package certproof
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/proto"
)
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
// success or other CAs are asked at once.
func TestHelperBackoff(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
assert.True(t, b.allow(key, now), "a user never asked is asked")
b.record(key, false, now)
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
b.record(key, true, now)
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
}
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
// the user is asked to prove, so they do not reset the quiet period.
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
assert.Equal(t, a, b, "the key does not depend on the nonce")
}
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
// challenges reordered, are the same question to the user.
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-1", "ca-2"}},
{CaCertificates: []string{"ca-3"}},
})
b := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-3"}},
{CaCertificates: []string{"ca-2", "ca-1"}},
})
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
}
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
// next outcome is recorded, so the map does not grow with every user and CA set seen.
func TestHelperBackoff_PrunesExpired(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
b.record("old", false, now)
b.record("new", false, now.Add(helperQuietPeriod))
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
assert.Contains(t, b.until, "new", "a current entry is kept")
}
@@ -0,0 +1,10 @@
//go:build !unix && !windows
package certproof
import "os/exec"
// startHelper starts cmd.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,35 @@
//go:build unix
package certproof
import (
"errors"
"os/exec"
"syscall"
)
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
// group when cmd's context ends. The helper may run below launchers such as launchctl
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
// only the direct child would leave the helper running and Wait blocked until the
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
func killHelperGroupOnCancel(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.WaitDelay = helperWaitDelay
}
// startHelper starts cmd. Killing the helper with everything below it is arranged by
// killHelperGroupOnCancel where the helper runs under a launcher.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,77 @@
//go:build windows
package certproof
import (
"fmt"
"os/exec"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
// only the helper on Windows leaves its descendants running, and one that holds the
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
func startHelper(cmd *exec.Cmd) (func(), error) {
job, err := newKillOnCloseJob()
if err != nil {
return nil, err
}
closeJob := func() {
if err := windows.CloseHandle(job); err != nil {
log.Debugf("failed to close certificate proof helper job: %v", err)
}
}
cmd.Cancel = func() error {
return windows.TerminateJobObject(job, 1)
}
cmd.WaitDelay = helperWaitDelay
if err := cmd.Start(); err != nil {
closeJob()
return nil, err
}
// A helper outside the job is still killed directly and bounded by WaitDelay; only
// its descendants would outlive it.
if err := assignToJob(job, cmd.Process.Pid); err != nil {
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
cmd.Cancel = func() error { return cmd.Process.Kill() }
}
return closeJob, nil
}
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
// is closed.
func newKillOnCloseJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, fmt.Errorf("create job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return 0, fmt.Errorf("set job object limits: %w", err)
}
return job, nil
}
// assignToJob puts the process pid into job.
func assignToJob(job windows.Handle, pid int) error {
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
if err != nil {
return fmt.Errorf("open process: %w", err)
}
defer func() {
_ = windows.CloseHandle(process)
}()
return windows.AssignProcessToJobObject(job, process)
}
@@ -0,0 +1,29 @@
//go:build windows
package certproof
import (
"context"
"os/exec"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
}
+126
View File
@@ -0,0 +1,126 @@
package certproof
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
)
const (
maxHelperStdout = 1 << 20
maxHelperStderr = 4 << 10
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
helperWaitDelay = 2 * time.Second
)
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
// The helper runs as an unprivileged user who can control its output, so both streams
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
stdout := &cappedBuffer{limit: maxHelperStdout}
stderr := &cappedBuffer{limit: maxHelperStderr}
cmd.Stdin = bytes.NewReader(payload)
cmd.Stdout = stdout
cmd.Stderr = stderr
release, err := startHelper(cmd)
if err != nil {
return nil, fmt.Errorf("start helper: %w", err)
}
defer release()
if err := cmd.Wait(); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
logHelperStderr(stderr.String())
if stdout.truncated {
return nil, errHelperOutputTooLarge
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
return requestedProofs(req, resp.Proofs), nil
}
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
// certificate it could not sign with, which is otherwise only visible in the user's
// session. Each line is quoted because the helper's user controls its content.
func logHelperStderr(stderr string) {
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
if line = strings.TrimSpace(line); line != "" {
log.Debugf("certificate posture helper: %q", line)
}
}
}
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
// most as many as req has challenges.
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
var kept []certposture.Proof
for _, proof := range proofs {
if len(kept) == len(req.Challenges) {
break
}
if !req.asked(proof.Nonce) {
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
continue
}
kept = append(kept, proof)
}
return kept
}
func (r HelperRequest) asked(nonce []byte) bool {
for _, challenge := range r.Challenges {
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
return true
}
}
return false
}
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
// which io.Copy prefers over Write, bypassing the cap.
type cappedBuffer struct {
buf bytes.Buffer
limit int
truncated bool
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if room := b.limit - b.buf.Len(); room < len(p) {
b.truncated = true
if room > 0 {
b.buf.Write(p[:room])
}
return len(p), nil
}
return b.buf.Write(p)
}
func (b *cappedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *cappedBuffer) String() string {
return b.buf.String()
}
@@ -0,0 +1,119 @@
//go:build !windows && !js
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// fakeHelper is a child process that drains its stdin and then prints script's output,
// standing in for a helper running in a user session the daemon cannot trust.
func fakeHelper(t *testing.T, script string) *exec.Cmd {
t.Helper()
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
}
func printJSON(t *testing.T, v any) string {
t.Helper()
out, err := json.Marshal(v)
require.NoError(t, err)
return fmt.Sprintf("printf '%%s' '%s'", out)
}
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
resp := HelperResponse{Proofs: []certposture.Proof{
{Nonce: []byte("injected"), Signature: []byte("x")},
{Nonce: []byte("asked"), Signature: []byte("first")},
{Nonce: []byte("asked"), Signature: []byte("second")},
}}
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
require.NoError(t, err)
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
}
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
_, err := runHelperCmd(fakeHelper(t, script), req)
assert.ErrorIs(t, err, errHelperOutputTooLarge)
}
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
_, err := runHelperCmd(fakeHelper(t, script), req)
require.Error(t, err)
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
}
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
// for one about a certificate it could not sign with: the warning reaches the daemon's
// log, quoted so a helper cannot forge log lines with embedded newlines.
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
var logged bytes.Buffer
log.SetOutput(&logged)
level := log.GetLevel()
log.SetLevel(log.DebugLevel)
t.Cleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(level)
})
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
_, err := runHelperCmd(fakeHelper(t, script), req)
require.NoError(t, err)
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
}
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
// grandchild.
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
killHelperGroupOnCancel(cmd)
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
}
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
assert.ErrorContains(t, err, "decode helper response")
}
+18
View File
@@ -0,0 +1,18 @@
//go:build (darwin && !ios) || windows
package certproof
import (
"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
}
@@ -0,0 +1,29 @@
//go:build (darwin && !ios) || windows
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
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")
}
+66
View File
@@ -0,0 +1,66 @@
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"
)
// TestRunHelper_AnswersAChallengeFromItsStore runs the helper's request and response
// handling in-process: the proof it writes verifies as the daemon will verify it. The
// process boundary itself is covered by the runHelperCmd tests.
func TestRunHelper_AnswersAChallengeFromItsStore(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,490 @@
//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
var attempts signAttempts
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
if !bytes.Equal(der, s.leaf.Raw) {
return false, nil
}
// The same certificate can sit in the keychain with more than one key, of which
// only one may be usable, so a failure moves on to the next match.
var ok bool
signature, ok = attempts.try(func() ([]byte, error) { return signWithIdentity(identity, algorithm, digest) })
return ok, nil
})
if err != nil {
return nil, err
}
if signature == nil {
if err := attempts.err(); err != nil {
return nil, err
}
return nil, errors.New("certificate is no longer in the keychain")
}
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
return signature, nil
}
func keychainAlgorithm(scheme sigScheme) uintptr {
switch scheme {
case schemeECDSASHA384:
return kSecKeyAlgorithmECDSASHA384
case schemeRSAPSSSHA256:
return kSecKeyAlgorithmRSAPSSSHA256
default:
return kSecKeyAlgorithmECDSASHA256
}
}
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
var key uintptr
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
}
defer release(key)
data := cfDataCreate(0, &digest[0], len(digest))
if data == 0 {
return nil, errors.New("CFDataCreate returned NULL")
}
defer release(data)
var cfErr uintptr
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
if signature == 0 {
if cfErr == 0 {
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
}
defer release(cfErr)
code := cfErrorGetCode(cfErr)
if code == errSecInteractionNotAllowed {
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
}
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
}
defer release(signature)
return dataBytes(signature), nil
}
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
// or yields 0, the process's own search list, when paths is empty. The caller calls done
// once it no longer uses the list.
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
if len(paths) == 0 {
return 0, func() {}, nil
}
list, err := openSearchList(paths)
if err != nil {
return 0, nil, err
}
return list, func() { release(list) }, nil
}
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
// caller releases the array.
func openSearchList(paths []string) (uintptr, error) {
refs := make([]uintptr, 0, len(paths))
defer func() {
for _, ref := range refs {
release(ref)
}
}()
for _, path := range paths {
cpath := append([]byte(path), 0)
var keychain uintptr
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
}
refs = append(refs, keychain)
}
// The array retains the keychains, so the references opened here are released.
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
if list == 0 {
return 0, errors.New("CFArrayCreate returned NULL")
}
return list, nil
}
// eachIdentity calls fn with every identity in searchList, or in the process's search
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
// skipped rather than ending the walk.
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
var cert uintptr
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
return false, nil
}
der := certificateDER(cert)
release(cert)
if der == nil {
log.Debug("skipping keychain identity whose certificate has no DER data")
return false, nil
}
return fn(identity, der)
})
}
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var unparsable int
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
certs = append(certs, cert)
return false, nil
}
unparsable++
return false, nil
})
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
return certs, err
}
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
if searchList != 0 {
keys = append(keys, kSecMatchSearchList)
values = append(values, searchList)
}
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
defer release(query)
var items uintptr
switch status := secItemCopyMatching(query, &items); status {
case 0:
case errSecItemNotFound:
log.Debugf("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
return nil
default:
log.Debugf("keychain %s query returned OSStatus %d", name, status)
return fmt.Errorf("SecItemCopyMatching: %d", status)
}
defer release(items)
n := cfArrayGetCount(items)
log.Debugf("keychain %s query returned %d items", name, n)
for i := 0; i < n; i++ {
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
return err
}
}
return nil
}
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
// returns NULL, which it does for an object that is not a valid certificate.
func certificateDER(cert uintptr) []byte {
data := secCertificateCopyData(cert)
if data == 0 {
return nil
}
defer release(data)
return dataBytes(data)
}
func dataBytes(data uintptr) []byte {
n := cfDataGetLength(data)
if n <= 0 {
return nil
}
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
}
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
// several Security calls return NULL on failure, so every release goes through here.
func release(ref uintptr) {
if ref != 0 {
cfRelease(ref)
}
}
func loadKeychain() error {
keychainOnce.Do(func() {
if keychainErr = resolveKeychain(); keychainErr != nil {
log.Debugf("macOS keychain unavailable for certificate posture: %v", keychainErr)
return
}
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
logSearchList()
})
return keychainErr
}
// logSearchList reports the keychains the process searches. The root daemon sees the
// System keychain and System Roots, never a user's login keychain.
func logSearchList() {
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
log.Debug("keychain search list diagnostics unavailable on this macOS version")
return
}
var list uintptr
if status := secKeychainCopySearchList(&list); status != 0 {
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
return
}
defer release(list)
n := cfArrayGetCount(list)
log.Debugf("keychain search list contains %d keychains", n)
for i := 0; i < n; i++ {
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
}
}
func keychainPath(keychain uintptr) string {
path := make([]byte, 1024)
length := uint32(len(path))
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
}
return string(path[:length])
}
func resolveKeychain() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
for _, fn := range []struct {
ptr any
lib uintptr
name string
}{
{&secItemCopyMatching, security, "SecItemCopyMatching"},
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
{&secCertificateCopyData, security, "SecCertificateCopyData"},
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
{&secKeychainOpen, security, "SecKeychainOpen"},
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
{&cfDataCreate, coreFoundation, "CFDataCreate"},
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
{&cfRelease, coreFoundation, "CFRelease"},
} {
symbol, err := purego.Dlsym(fn.lib, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
purego.RegisterFunc(fn.ptr, symbol)
}
for _, global := range []struct {
ptr *uintptr
lib uintptr
name string
deref bool
}{
{&kSecClass, security, "kSecClass", true},
{&kSecClassIdentity, security, "kSecClassIdentity", true},
{&kSecClassCertificate, security, "kSecClassCertificate", true},
{&kSecMatchLimit, security, "kSecMatchLimit", true},
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
{&kSecReturnRef, security, "kSecReturnRef", true},
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
} {
addr, err := purego.Dlsym(global.lib, global.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", global.name, err)
}
if global.deref {
addr = **(**uintptr)(unsafe.Pointer(&addr))
}
*global.ptr = addr
}
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
return nil
}
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
// longer exports it so keychain lookups keep working without it.
func resolveOptional(lib uintptr, name string, ptr any) {
symbol, err := purego.Dlsym(lib, name)
if err != nil {
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
return
}
purego.RegisterFunc(ptr, symbol)
}
+77
View File
@@ -0,0 +1,77 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"slices"
)
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
type sigScheme int
const (
schemeECDSASHA256 sigScheme = iota + 1
schemeECDSASHA384
schemeRSAPSSSHA256
)
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
switch pub.(type) {
case *ecdsa.PublicKey:
switch opts.HashFunc() {
case crypto.SHA256:
return schemeECDSASHA256, nil
case crypto.SHA384:
return schemeECDSASHA384, nil
}
case *rsa.PublicKey:
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
return schemeRSAPSSSHA256, nil
}
}
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
}
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
chain := []*x509.Certificate{leaf}
current := leaf
for current.CheckSignatureFrom(current) != nil {
issuer := issuerIn(current, pool, chain)
if issuer == nil {
break
}
chain = append(chain, issuer)
current = issuer
}
return chain
}
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
for _, candidate := range pool {
if slices.ContainsFunc(seen, candidate.Equal) {
continue
}
if cert.CheckSignatureFrom(candidate) == nil {
return candidate
}
}
return nil
}
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
if len(raw) == 0 || len(raw)%2 != 0 {
return nil, errors.New("malformed raw ECDSA signature")
}
half := len(raw) / 2
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
}
@@ -0,0 +1,91 @@
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},
{"ecdsa sha512", ecKey.Public(), crypto.SHA512, 0},
{"rsa pss sha384", rsaKey.Public(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA384}, 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, "an odd length cannot split into r and s")
_, err = ecdsaSignatureASN1(nil)
assert.Error(t, err, "an empty signature is malformed")
}
+50
View File
@@ -0,0 +1,50 @@
package certproof
import (
"crypto/sha256"
"crypto/x509"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// mergeProofs appends the user session proofs to the device proofs, dropping one whose
// chain the device already proved. Management checks each chain against each check's CAs,
// so the same leaf proven through another chain is kept.
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
if len(user) == 0 {
return device
}
seen := make(map[[sha256.Size]byte]struct{}, len(device)+len(user))
for _, proof := range device {
if len(proof.Chain) > 0 {
seen[chainDERFingerprint(proof.Chain)] = struct{}{}
}
}
merged := device
for _, proof := range user {
if len(proof.Chain) == 0 {
continue
}
fingerprint := chainDERFingerprint(proof.Chain)
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)
}
+54
View File
@@ -0,0 +1,54 @@
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"
)
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 TestMergeProofs_KeepsAnotherChainOfTheSameLeaf(t *testing.T) {
root := certtest.NewCA(t, "corp-root")
interA := root.Issue(t, certtest.ECDSAKey(t), "inter-a")
interB := root.Issue(t, certtest.ECDSAKey(t), "inter-b")
leaf := root.Issue(t, certtest.ECDSAKey(t), "leaf")
// Management checks each chain against each check's CAs, so the same leaf proven
// through another chain is a different proof, not a duplicate.
device := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interA.Raw}}}
user := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interB.Raw}}, {Chain: [][]byte{leaf.Raw, interA.Raw}}}
merged := mergeProofs(device, user)
require.Len(t, merged, 2, "the chain only the user store holds is kept, the one both hold is not repeated")
assert.Equal(t, interB.Raw, merged[1].Chain[1], "the user's chain through the other intermediate is appended")
}
+54
View File
@@ -0,0 +1,54 @@
package certproof
import (
"crypto/sha256"
"encoding/binary"
"errors"
"sync"
)
// errPINRejectedBefore is returned instead of logging in with a PIN the token already
// refused: every failed login counts towards the token's lockout, which for a TPM is
// shared with everything else on the machine, and proofs are collected on every sync.
var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again")
// errPINNeedsToken refuses a PIN that names no token to log in to.
var errPINNeedsToken = errors.New("a PKCS#11 PIN needs the token named in NB_CERT_PKCS11_URI, as token=<label>")
// rejectedPINs outlives a single store, since a store is built for each collection.
var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}}
// pinLoginMu serializes logging in to a PKCS#11 token with a PIN, from checking the latch
// to recording a rejection.
var pinLoginMu sync.Mutex
// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not
// kept in memory any longer than the store that read it.
type pinLatch struct {
mu sync.Mutex
keys map[[sha256.Size]byte]struct{}
}
func (l *pinLatch) has(key [sha256.Size]byte) bool {
l.mu.Lock()
defer l.mu.Unlock()
_, ok := l.keys[key]
return ok
}
func (l *pinLatch) add(key [sha256.Size]byte) {
l.mu.Lock()
defer l.mu.Unlock()
l.keys[key] = struct{}{}
}
// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token
// rejected is still tried on the token it belongs to.
func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte {
var buf []byte
for _, part := range [][]byte{[]byte(module), []byte(token), pin} {
buf = binary.BigEndian.AppendUint64(buf, uint64(len(part)))
buf = append(buf, part...)
}
return sha256.Sum256(buf)
}
@@ -0,0 +1,29 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) {
base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another")
assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another")
assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")),
"field boundaries are part of the key, so shifted fields do not collide")
}
func TestPINLatch_RemembersRejectedKeys(t *testing.T) {
latch := &pinLatch{keys: map[[32]byte]struct{}{}}
rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000"))
other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.False(t, latch.has(rejected), "nothing is rejected before a login fails")
latch.add(rejected)
assert.True(t, latch.has(rejected), "a rejected PIN is not tried again")
assert.False(t, latch.has(other), "other PINs are unaffected")
}
+342
View File
@@ -0,0 +1,342 @@
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, fileIntermediates, 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...)
}
pool = append(pool, fileIntermediates...)
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
}
// A token that cannot list its public keys still answers for the certificates it
// holds, so the identities found above are kept.
keys, err := tokenPublicKeys(session)
if err != nil {
log.Warnf("%s: cannot read its public keys, certificate files are not paired with them: %v", s, err)
return candidates, nil
}
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. The certificates of a file that does
// carry a key are returned too, since they can complete the chain of a token certificate.
func (s *pkcs11Store) fileChains() (chains [][]*x509.Certificate, intermediates []*x509.Certificate, err error) {
if s.certDir == "" {
return nil, nil, nil
}
paths, err := certFiles(s.certDir)
if err != nil {
return nil, nil, err
}
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
continue
}
if signer != nil {
intermediates = append(intermediates, chain...)
continue
}
chains = append(chains, chain)
}
return chains, intermediates, 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 {
log.Debugf("skipping public key on PKCS#11 token: %v", err)
continue
}
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,407 @@
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")
// An intermediate that only a keyed file carries still has to reach the pool a token
// certificate's chain is built from.
root := certtest.NewCA(t, "corp-root")
intermediateCA := certtest.NewIntermediate(t, root, "corp-intermediate")
chained := certtest.ECDSAKey(t)
writeFile(t, dir, "chained.pem", certtest.CertPEM(intermediateCA.Issue(t, chained, "chained"))+certtest.CertPEM(intermediateCA.Cert)+certtest.KeyPEM(t, chained))
chains, extra, err := (&pkcs11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains()
require.NoError(t, err)
require.NotEmpty(t, extra, "the keyed file's certificates are offered for chain building")
tokenLeaf := intermediateCA.Issue(t, certtest.ECDSAKey(t), "token-leaf")
chain := buildChain(tokenLeaf, extra)
require.Len(t, chain, 2, "a token certificate is completed through the intermediate only the keyed file holds")
assert.True(t, intermediateCA.Cert.Equal(chain[1]), "the second certificate is that intermediate")
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, extra, err = (&pkcs11Store{uri: &pkcs11.URI{}}).fileChains()
require.NoError(t, err)
assert.Empty(t, chains, "no directory configured means no file certificates")
assert.Empty(t, extra, "and no extra intermediates")
}
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
}
+24
View File
@@ -0,0 +1,24 @@
package certproof
import "errors"
// signAttempts tracks the identities tried to sign with, for a certificate that can match
// more than one of them.
type signAttempts struct {
errs []error
}
// try runs sign and reports its signature and whether it succeeded. A failure is kept.
func (a *signAttempts) try(sign func() ([]byte, error)) ([]byte, bool) {
signature, err := sign()
if err != nil {
a.errs = append(a.errs, err)
return nil, false
}
return signature, true
}
// err joins the failures of the attempts, nil when there were none.
func (a *signAttempts) err() error {
return errors.Join(a.errs...)
}
@@ -0,0 +1,41 @@
package certproof
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errNeedsApprovalForTest = errors.New("key needs approval")
// TestSignAttempts_FallsThroughToTheNextIdentity: a certificate can sit in a keychain with
// more than one key, of which only some can sign. A failure moves on, and an identity that
// signs ends the search without reporting the earlier failure.
func TestSignAttempts_FallsThroughToTheNextIdentity(t *testing.T) {
var attempts signAttempts
signature, ok := attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest })
assert.False(t, ok, "a failing identity does not end the search")
assert.Nil(t, signature)
signature, ok = attempts.try(func() ([]byte, error) { return []byte("sig"), nil })
require.True(t, ok, "the next identity signs")
assert.Equal(t, []byte("sig"), signature)
}
// TestSignAttempts_KeepsEveryFailure: when no identity signs, the approval a user has to
// give must not be hidden behind the failure of a later identity.
func TestSignAttempts_KeepsEveryFailure(t *testing.T) {
var attempts signAttempts
assert.NoError(t, attempts.err(), "no attempts, no error")
attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest })
attempts.try(func() ([]byte, error) { return nil, errors.New("key is gone") })
err := attempts.err()
require.Error(t, err)
assert.ErrorIs(t, err, errNeedsApprovalForTest, "the earlier approval error survives the later failure")
assert.ErrorContains(t, err, "key is gone")
}
+266
View File
@@ -0,0 +1,266 @@
package certproof
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
log "github.com/sirupsen/logrus"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
// maxStoreFileSize bounds a certificate or key file of the PEM directory. A chain with
// its key is a few kilobytes.
maxStoreFileSize = 1 << 20
)
// errKeyMismatch rejects a key that does not belong to the certificate it sits with: it
// would sign a proof management can only reject, in place of a usable later candidate.
var (
errKeyMismatch = errors.New("private key does not match the certificate")
errNoSiblingKey = errors.New("no key file next to the certificate")
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
// path to a challenge's CAs can be found even where Chain followed a different issuer,
// such as an expired copy of a renewed intermediate.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
Intermediates []*x509.Certificate
}
// issuers is every certificate other than the leaf that a path may run through.
func (c Candidate) issuers() []*x509.Certificate {
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
}
// Store yields the certificates a peer may prove possession of. fileStore is the PEM
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
type Store interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// Config selects where the daemon looks for certificates. PKCS11 names a token whose keys
// sign for certificates on the token or in the PEM directory, which NB_CERT_STORE_DIR
// names on Linux, /etc/netbird/certs by default.
//
// ProfileOwner is the OS account the active profile belongs to. On macOS and Windows only
// that account's certificate store is consulted for user certificates, so on a machine
// with several people signed in the result does not depend on who else is logged in.
// Empty means the profile has no owner, and only the user at the physical console counts.
// OwnerUnknown means the owner could not be determined, and no user store is consulted:
// guessing would let whoever sits at the console answer for the profile.
type Config struct {
PKCS11 PKCS11Config
ProfileOwner string
OwnerUnknown bool
}
// fileStore reads PEM files from a directory. A file holds the chain (leaf first) and
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
type fileStore struct {
dir string
}
func newFileStore(dir string) *fileStore {
return &fileStore{dir: dir}
}
func StoreDir() string {
if dir := os.Getenv(StoreDirEnv); dir != "" {
return dir
}
return defaultStoreDir
}
func (s *fileStore) Candidates(_ context.Context) ([]Candidate, error) {
paths, err := certFiles(s.dir)
if err != nil {
return nil, err
}
var candidates []Candidate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
if signer == nil {
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
continue
}
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
}
return candidates, nil
}
// certFiles lists the certificate files in dir, none when the directory does not exist.
// The directory is checked before it is listed, so one that others can write to is
// refused before its entries are read.
func certFiles(dir string) ([]string, error) {
err := checkStoreDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("refusing certificate store: %w", err)
}
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
}
var paths []string
for _, entry := range entries {
if !entry.IsDir() && isCertFile(entry.Name()) {
paths = append(paths, filepath.Join(dir, entry.Name()))
}
}
return paths, nil
}
// loadPEM reads a certificate file and its private key, held in the file itself or in
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
data, err := readStoreFile(path)
if err != nil {
return nil, nil, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return nil, nil, err
}
if len(chain) == 0 {
return nil, nil, errors.New("no certificate")
}
if signer == nil {
signer, err = siblingKey(path)
switch {
case errors.Is(err, errNoSiblingKey):
// A certificate with no key of its own: the token store pairs it later.
return chain, nil, nil
case err != nil:
return nil, nil, err
}
}
if !samePublicKey(signer.Public(), chain[0].PublicKey) {
return nil, nil, errKeyMismatch
}
return chain, signer, nil
}
// siblingKey reads the private key from the "<name>.key" file next to a certificate
// file, reporting errNoSiblingKey when there is none.
func siblingKey(path string) (crypto.Signer, error) {
keyData, err := readStoreFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if errors.Is(err, os.ErrNotExist) {
return nil, errNoSiblingKey
}
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
_, signer, err := parsePEM(keyData)
if err != nil {
return nil, err
}
if signer == nil {
return nil, errors.New("no private key in key file")
}
return signer, nil
}
func samePublicKey(a, b crypto.PublicKey) bool {
equaler, ok := a.(interface{ Equal(crypto.PublicKey) bool })
return ok && equaler.Equal(b)
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
return chain, signer, nil
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tss2KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
}
signer = key
}
}
}
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case tss2KeyPEMType:
return parseTSS2Key(block.Bytes)
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("private key cannot sign")
}
return signer, nil
}
func isCertFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".pem", ".crt", ".cer":
return true
}
return false
}
// Stores queries several stores and carries on when one fails, so a broken token cannot
// hide the certificates a directory holds. A failure is logged instead of returned
// because Collect treats a store error as "no proofs at all".
type Stores []Store
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
var all []Candidate
for _, store := range s {
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("certificate store %T unavailable: %v", store, err)
continue
}
all = append(all, candidates...)
}
return all, nil
}
+39
View File
@@ -0,0 +1,39 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
var unsupportedTokenOnce sync.Once
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
func DefaultStore() Store {
return newFileStore(StoreDir())
}
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
// names one. The token pairs the directory's key-less certificates with its own keys.
func storeWithToken(cfg Config) Store {
files := newFileStore(StoreDir())
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
return files
}
if !pkcs11.Supported() {
unsupportedTokenOnce.Do(func() {
log.Warnf("ignoring the configured PKCS#11 token: %v", pkcs11.ErrUnsupported)
})
return files
}
token, err := newPKCS11Store(cfg.PKCS11, StoreDir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
}
return Stores{files, token}
}
@@ -0,0 +1,44 @@
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
func TestStoreWithToken(t *testing.T) {
dir := storeDir(t)
t.Setenv(StoreDirEnv, dir)
files, ok := storeWithToken(Config{}).(*fileStore)
require.True(t, ok, "no token configured reads the PEM directory alone")
assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR")
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory")
configured := map[string]PKCS11Config{
"env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"},
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
}
if !pkcs11.Supported() {
for name, cfg := range configured {
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: cfg}),
"%s: a build without PKCS#11 support reads the PEM directory alone", name)
}
return
}
for name, cfg := range configured {
store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores)
require.True(t, ok, "%s joins the token to the PEM directory", name)
require.Len(t, store, 2, name)
token, ok := store[1].(*pkcs11Store)
require.True(t, ok, name)
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
}
}
@@ -0,0 +1,96 @@
package certproof
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestFileStore_TPMKeyFile(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
// A key that needs a password can never be used silently, so its certificate is skipped.
locked := certtest.ECDSAKey(t)
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth()))
candidates, err := newFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
}
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
// swtpm started with:
//
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
//
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
// hierarchy, and proves the certificate for it through the regular file store.
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
if os.Getenv(tpm.DeviceEnv) == "" {
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
}
public, private := createTPMKey(t)
keyPEM := tpmtest.EncodePEM(t, public, private)
block, _ := pem.Decode([]byte(keyPEM))
require.NotNil(t, block)
signer, err := tpm.ParseKey(block.Bytes)
require.NoError(t, err)
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey)
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
}
func createTPMKey(t *testing.T) (public, private []byte) {
t.Helper()
rwc, err := tpm.Open()
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
require.NoError(t, err)
return public, private
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !js
package certproof
import (
"crypto"
"github.com/netbirdio/netbird/client/internal/tpm"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file.
const tss2KeyPEMType = tpm.KeyPEMType
// parseTSS2Key returns a signer for a TPM-held key file that signs inside the TPM.
func parseTSS2Key(der []byte) (crypto.Signer, error) {
return tpm.ParseKey(der)
}
@@ -0,0 +1,16 @@
//go:build js
package certproof
import (
"crypto"
"errors"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file. A browser has no TPM, so
// the TPM library stays out of the WebAssembly build and such a key is refused.
const tss2KeyPEMType = "TSS2 PRIVATE KEY"
func parseTSS2Key([]byte) (crypto.Signer, error) {
return nil, errors.New("TPM keys are not supported in this build")
}
@@ -0,0 +1,34 @@
//go:build !unix
package certproof
import (
"fmt"
"io"
"os"
)
// readStoreFile reads a certificate or key file of the PEM directory, up to the size
// limit. Ownership is checked on Unix only; elsewhere the PEM directory is not the store
// the daemon reads by default.
func readStoreFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s is over the %d byte limit", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir accepts any directory where the PEM directory is not a default store.
func checkStoreDir(string) error {
return nil
}
+101
View File
@@ -0,0 +1,101 @@
//go:build unix
package certproof
import (
"errors"
"fmt"
"io"
"os"
"syscall"
)
// readStoreFile reads a certificate or key file of the PEM directory as the daemon may
// trust it: the path must not be a symlink and must be a regular file that only root or
// this process's own user can write. Otherwise a user able to place or redirect a file
// could make the daemon sign with a key of their choosing, or with a root-only key kept
// elsewhere on the machine. The file is checked through the descriptor it is read from,
// so it cannot be swapped between the check and the read.
func readStoreFile(path string) ([]byte, error) {
// O_NONBLOCK keeps a FIFO planted in the directory from blocking the open; the
// regular-file check below then refuses it.
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", path)
}
if err := checkTrustedOwner(info); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if info.Size() > maxStoreFileSize {
return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", path, info.Size(), maxStoreFileSize)
}
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s grew past the %d byte limit while being read", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir refuses a PEM directory that someone other than root or this process's
// user could add files to or rename files in. The configured path may be a symlink, as
// distributions place certificate directories behind them, but only one root or this
// process's user owns: otherwise whoever owns the link could point it at any directory.
func checkStoreDir(dir string) error {
link, err := os.Lstat(dir)
if err != nil {
return err
}
if link.Mode()&os.ModeSymlink != 0 {
if err := checkTrustedUID(link); err != nil {
return fmt.Errorf("symlink %s: %w", dir, err)
}
}
info, err := os.Stat(dir)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", dir)
}
if err := checkTrustedOwner(info); err != nil {
return fmt.Errorf("%s: %w", dir, err)
}
return nil
}
// checkTrustedOwner requires info to belong to root or to this process's user and to be
// writable by nobody else. Group write is refused even for root's group, which ordinary
// users may be members of. An owner that cannot be read is refused.
func checkTrustedOwner(info os.FileInfo) error {
if err := checkTrustedUID(info); err != nil {
return err
}
if perm := info.Mode().Perm(); perm&0o022 != 0 {
return fmt.Errorf("writable by group or other users (mode %#o)", perm)
}
return nil
}
// checkTrustedUID requires info to belong to root or to this process's user.
func checkTrustedUID(info os.FileInfo) error {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("owner cannot be determined")
}
if st.Uid != 0 && int(st.Uid) != os.Geteuid() {
return fmt.Errorf("owned by uid %d, not by root", st.Uid)
}
return nil
}
@@ -0,0 +1,136 @@
//go:build unix
package certproof
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
// devicePair writes a certificate and its key as separate files into dir.
func devicePair(t *testing.T, dir string) {
t.Helper()
ca := certtest.NewCA(t, "corp-root")
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.crt", certtest.CertPEM(ca.Issue(t, key, "device")))
writeFile(t, dir, "device.key", certtest.KeyPEM(t, key))
}
func candidates(t *testing.T, dir string) ([]Candidate, error) {
t.Helper()
return newFileStore(dir).Candidates(context.Background())
}
func TestFileStore_AcceptsAPrivatePair(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate and key only their owner can write are used")
}
func TestFileStore_RefusesASymlinkedKey(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
// The real key moves elsewhere and the directory only links to it, as a user able
// to write the directory would do to point the daemon at a key it should not use.
elsewhere := filepath.Join(storeDir(t), "other.key")
require.NoError(t, os.Rename(filepath.Join(dir, "device.key"), elsewhere))
require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key reached through a symlink is never used")
}
func TestFileStore_RefusesASymlinkedCertificateFile(t *testing.T) {
dir := storeDir(t)
other := storeDir(t)
devicePair(t, other)
require.NoError(t, os.Symlink(filepath.Join(other, "device.crt"), filepath.Join(dir, "device.crt")))
require.NoError(t, os.Symlink(filepath.Join(other, "device.key"), filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a certificate file that is a symlink is skipped")
}
func TestFileStore_RefusesFilesOthersCanWrite(t *testing.T) {
for name, mode := range map[string]os.FileMode{"world-writable": 0o602, "group-writable": 0o620} {
t.Run(name, func(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(filepath.Join(dir, "device.key"), mode))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key %s may have been replaced by someone else, even when the group is root's", name)
})
}
}
func TestFileStore_RefusesADirectoryOthersCanWrite(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
_, err := candidates(t, dir)
assert.ErrorContains(t, err, "refusing certificate store", "files in a directory anyone can write are not trusted")
}
func TestFileStore_SkipsAFIFOWithoutBlocking(t *testing.T) {
dir := storeDir(t)
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "trap.pem"), 0o600))
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a FIFO is skipped and the real pair still loads")
}
func TestReadStoreFile_RefusesOversizedFiles(t *testing.T) {
dir := storeDir(t)
path := filepath.Join(dir, "huge.pem")
require.NoError(t, os.WriteFile(path, make([]byte, maxStoreFileSize+1), 0o600))
_, err := readStoreFile(path)
assert.ErrorContains(t, err, "limit")
}
func TestFileStore_AcceptsASymlinkedDirectoryItsOwnerControls(t *testing.T) {
target := storeDir(t)
devicePair(t, target)
link := filepath.Join(storeDir(t), "certs")
require.NoError(t, os.Symlink(target, link))
// The link belongs to this process's user, as one root creates belongs to root.
got, err := candidates(t, link)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate directory behind a symlink the owner controls is read")
}
func TestFileStore_ChecksTheDirectoryBeforeListingIt(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
paths, err := certFiles(dir)
assert.ErrorContains(t, err, "refusing certificate store")
assert.Nil(t, paths, "nothing in a directory anyone can write to is listed")
}
func TestFileStore_MissingDirectoryIsEmpty(t *testing.T) {
paths, err := certFiles(filepath.Join(storeDir(t), "missing"))
assert.NoError(t, err, "a store directory that does not exist yet is not an error")
assert.Empty(t, paths)
}
@@ -0,0 +1,270 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"slices"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
personalStore = "MY"
intermediateStore = "CA"
cryptAcquireSilentFlag = 0x00000040
cryptAcquirePreferNCryptKeyFlag = 0x00020000
certNCryptKeySpec = 0xFFFFFFFF
atKeyExchange = 1
atSignature = 2
bcryptPadPSS = 0x00000008
)
// errLegacyKey reports a private key CNG cannot open: it lives in a legacy CryptoAPI
// provider, which only offers PKCS#1 v1.5 and no RSA-PSS or ECDSA.
var errLegacyKey = errors.New("private key is held by a legacy CryptoAPI provider, which certificate posture cannot sign with")
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
)
type bcryptPSSPaddingInfo struct {
algID *uint16
salt uint32
}
// DefaultStore is the local machine's personal certificate store, where device
// certificates enrolled through AD or Intune are kept.
func DefaultStore() Store {
return newSystemStore()
}
// systemStore yields the identities of a personal certificate store, completing their
// chains from the matching intermediate CA store. Keys are used through CNG and never
// exported.
//
// The location decides whose certificates these are. The local machine store is the one
// a service reads; the current user store lives in the signed-in user's registry hive
// with keys protected against their profile, so it is only readable while running as
// that user.
type systemStore struct {
location uint32
}
// newSystemStore reads the local machine store, which is what the daemon uses.
func newSystemStore() *systemStore {
return &systemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
}
// newUserStore reads the calling user's personal store. It is only useful in a process
// already running as that user, which is what the posture helper is.
func newUserStore() *systemStore {
return &systemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
}
func (s *systemStore) Candidates(_ context.Context) ([]Candidate, error) {
leaves, err := storeCertificates(s.location, personalStore)
if err != nil {
return nil, err
}
intermediates, err := storeCertificates(s.location, intermediateStore)
if err != nil {
return nil, err
}
log.Debugf("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
if len(leaves) == 0 {
return nil, nil
}
pool := slices.Concat(intermediates, leaves)
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Debugf("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}, Intermediates: pool})
}
return candidates, nil
}
// String names the store location the way the Windows documentation does.
func (s *systemStore) String() string {
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
return "CurrentUser"
}
return "LocalMachine"
}
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
// at signing time so no handles outlive a call.
type systemStoreSigner struct {
leaf *x509.Certificate
location uint32
}
func (s *systemStoreSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
store, err := openStore(s.location, personalStore)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var signature []byte
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
return false, nil
}
signature, err = signWithContext(ctx, scheme, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the personal store")
}
return signature, nil
}
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
var key uintptr
var keySpec uint32
var callerFree int32
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
if ok == 0 {
return nil, fmt.Errorf("acquire private key: %w", err)
}
if keySpec != certNCryptKeySpec {
if callerFree != 0 {
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
}
return nil, legacyKeyError(keySpec)
}
if callerFree != 0 {
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
}
var padding unsafe.Pointer
var flags uintptr
if scheme == schemeRSAPSSSHA256 {
algID, _ := windows.UTF16PtrFromString("SHA256")
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
}
size, err := ncryptSignHash(key, padding, digest, nil, flags)
if err != nil {
return nil, err
}
signature := make([]byte, size)
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
return nil, err
}
signature = signature[:size]
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// legacyKeyError describes a key acquired through CryptoAPI rather than CNG, naming its
// key spec and the remedy, since the certificate otherwise looks usable in the store.
func legacyKeyError(keySpec uint32) error {
spec := fmt.Sprintf("key spec %d", keySpec)
switch keySpec {
case atKeyExchange:
spec = "AT_KEYEXCHANGE"
case atSignature:
spec = "AT_SIGNATURE"
}
return fmt.Errorf("%w (%s); re-enrol it with a certificate template whose provider is a CNG key storage provider", errLegacyKey, spec)
}
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
var result uint32
var signaturePtr uintptr
if len(signature) > 0 {
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
}
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
if uint32(status) != 0 {
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
}
return result, nil
}
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
store, err := openStore(location, name)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var certs []*x509.Certificate
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
if err != nil {
log.Warnf("skipping certificate in %s store: %v", name, err)
return false, nil
}
certs = append(certs, cert)
return false, nil
})
return certs, err
}
func openStore(location uint32, name string) (windows.Handle, error) {
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return 0, err
}
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
if err != nil {
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
}
return store, nil
}
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
var ctx *windows.CertContext
for {
next, err := windows.CertEnumCertificatesInStore(store, ctx)
if next == nil {
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil
}
return fmt.Errorf("enumerate certificates: %w", err)
}
ctx = next
if stop, err := fn(ctx); stop || err != nil {
_ = windows.CertFreeCertificateContext(ctx)
return err
}
}
}
func encodedCert(ctx *windows.CertContext) []byte {
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
}
@@ -0,0 +1,16 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLegacyKeyError_NamesTheKeySpecAndTheRemedy(t *testing.T) {
for spec, want := range map[uint32]string{atKeyExchange: "AT_KEYEXCHANGE", atSignature: "AT_SIGNATURE", 7: "key spec 7"} {
err := legacyKeyError(spec)
assert.ErrorIs(t, err, errLegacyKey, "callers can tell a legacy key from other failures")
assert.ErrorContains(t, err, want, "the key spec found is named")
assert.ErrorContains(t, err, "key storage provider", "the error says how to fix the enrolment")
}
}
+60 -3
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -71,20 +72,44 @@ type ConnectClient struct {
updateManager *updater.Manager
persistSyncResponse bool
// inMemoryState keeps the state manager off disk, for clients that change
// nothing on the host that a later run would have to restore.
inMemoryState bool
// 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.
type ConnectClientOption func(*ConnectClient)
// WithInMemoryState keeps client state in memory instead of the default state
// file, so the client neither reads nor writes another installation's state.
func WithInMemoryState() ConnectClientOption {
return func(c *ConnectClient) { c.inMemoryState = true }
}
// WithNetEvents injects the OS network event handling.
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,
@@ -256,8 +281,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return err
}
// path stays alongside the manager because the engine derives StateDir
// from it; an in-memory client leaves it empty.
var path string
if runtime.GOOS == "ios" || runtime.GOOS == "android" {
var stateManager *statemanager.Manager
switch {
case c.inMemoryState:
log.Debugf("client state is kept in memory")
stateManager = statemanager.NewInMemory()
case runtime.GOOS == "ios" || runtime.GOOS == "android":
// On mobile, use the provided state file path directly
if !fileExists(mobileDependency.StateFilePath) {
if err := createFile(mobileDependency.StateFilePath); err != nil {
@@ -266,11 +298,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
}
path = mobileDependency.StateFilePath
} else {
stateManager = statemanager.New(path)
default:
sm := profilemanager.NewServiceManager("")
path = sm.GetStatePath()
stateManager = statemanager.New(path)
}
stateManager := statemanager.New(path)
stateManager.RegisterState(&sshconfig.ShutdownState{})
if c.updateManager != nil {
@@ -292,6 +325,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return nil
}
if c.updateManager != nil {
c.updateManager.ResetMode()
}
// suspend connection attempts while the OS reports no usable network
if waited, err := c.netMgr.Wait(c.ctx); err != nil {
return nil
@@ -416,6 +453,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 != "" {
@@ -673,6 +712,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,
@@ -699,6 +740,22 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
return engineConf, nil
}
// certStoreConfig reads where certificate posture finds certificates from the daemon's
// environment, NB_CERT_STORE_DIR and NB_CERT_PKCS11_URI with NB_TPM_PIN. The profile
// config fields that once held them are ignored, and a value left there is reported
// once, so a setup relying on it does not silently stop proving.
func certStoreConfig(config *profilemanager.Config) certproof.Config {
if config.CertStoreDir != "" || config.CertPKCS11URI != "" {
legacyCertConfigOnce.Do(func() {
log.Warnf("certificate posture: CertStoreDir and CertPKCS11URI in the profile config are ignored, set %s and %s in the daemon's environment instead",
certproof.StoreDirEnv, certproof.PKCS11URIEnv)
})
}
return certproof.Config{PKCS11: certproof.PKCS11FromEnv()}
}
var legacyCertConfigOnce sync.Once
func selectMTU(localMTU uint16, peerMTU int32) uint16 {
var finalMTU uint16 = iface.DefaultMTU
if localMTU > 0 {
+26
View File
@@ -0,0 +1,26 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// TestCertStoreConfig_ReadsEnvironmentNotProfile: the token URI may carry the PIN, so it
// and the store directory come from the daemon's environment, and values left in the
// profile config from an older version are ignored.
func TestCertStoreConfig_ReadsEnvironmentNotProfile(t *testing.T) {
t.Setenv(certproof.PKCS11URIEnv, "pkcs11:token=env")
t.Setenv(certproof.PINEnv, "1234")
cfg := certStoreConfig(&profilemanager.Config{
CertStoreDir: "/from/profile",
CertPKCS11URI: "pkcs11:token=profile?pin-value=9999",
})
assert.Equal(t, "pkcs11:token=env", cfg.PKCS11.URI, "the URI comes from the environment")
assert.Equal(t, "1234", cfg.PKCS11.PIN, "the PIN comes from the environment")
}
+42
View File
@@ -0,0 +1,42 @@
package daemonaddr
import (
"os"
"strconv"
log "github.com/sirupsen/logrus"
)
const (
// EnvMaxRecvMsgSize overrides the default gRPC max receive message size for
// connections to the daemon. Value is in bytes.
EnvMaxRecvMsgSize = "NB_DAEMON_GRPC_MAX_MSG_SIZE"
// defaultMaxRecvMsgSize is the max gRPC receive message size used for daemon
// connections when EnvMaxRecvMsgSize is unset or invalid. It overrides the
// gRPC library default of 4 MB, which a detailed status already exceeds on a
// network of a few thousand peers.
defaultMaxRecvMsgSize = 1024 * 1024 * 16
)
// MaxRecvMsgSize returns the max gRPC receive message size for daemon connections
// from the environment, or defaultMaxRecvMsgSize (16 MB) if unset or invalid.
func MaxRecvMsgSize() int {
val := os.Getenv(EnvMaxRecvMsgSize)
if val == "" {
return defaultMaxRecvMsgSize
}
size, err := strconv.Atoi(val)
if err != nil {
log.Warnf("invalid %s value %q, using default: %v", EnvMaxRecvMsgSize, val, err)
return defaultMaxRecvMsgSize
}
if size <= 0 {
log.Warnf("invalid %s value %d, must be positive, using default", EnvMaxRecvMsgSize, size)
return defaultMaxRecvMsgSize
}
return size
}
+112
View File
@@ -0,0 +1,112 @@
package daemonaddr
import (
"context"
"net"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
)
func TestMaxRecvMsgSize(t *testing.T) {
tests := []struct {
name string
envValue string
expected int
}{
{name: "unset returns default", envValue: "", expected: defaultMaxRecvMsgSize},
{name: "non-numeric returns default", envValue: "abc", expected: defaultMaxRecvMsgSize},
{name: "negative returns default", envValue: "-1", expected: defaultMaxRecvMsgSize},
{name: "zero returns default", envValue: "0", expected: defaultMaxRecvMsgSize},
{name: "valid value is used", envValue: "33554432", expected: 33554432},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Set first so the previous value is restored on cleanup, then unset to
// exercise the absent case.
t.Setenv(EnvMaxRecvMsgSize, tc.envValue)
if tc.envValue == "" {
require.NoError(t, os.Unsetenv(EnvMaxRecvMsgSize), "unset the override")
}
assert.Equal(t, tc.expected, MaxRecvMsgSize(), "max receive message size")
})
}
}
// bigStatusServer answers Status with a response larger than gRPC's 4 MB default
// receive limit, which is what a detailed status on a large network looks like.
type bigStatusServer struct {
proto.UnimplementedDaemonServiceServer
payload string
}
func (s *bigStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) {
return &proto.StatusResponse{Status: s.payload}, nil
}
func startBigStatusServer(t *testing.T, payload string) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "listen on loopback")
srv := grpc.NewServer()
proto.RegisterDaemonServiceServer(srv, &bigStatusServer{payload: payload})
go func() {
_ = srv.Serve(listener)
}()
t.Cleanup(srv.Stop)
return "tcp://" + listener.Addr().String()
}
func TestDialTargetAcceptsAStatusOverTheGrpcDefault(t *testing.T) {
payload := strings.Repeat("x", 5*1024*1024)
addr := startBigStatusServer(t, payload)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
target, opts := DialTarget(addr)
conn, err := grpc.NewClient(target, opts...)
require.NoError(t, err, "dial the daemon")
t.Cleanup(func() { _ = conn.Close() })
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{})
require.NoError(t, err, "a detailed status must not be rejected for its size")
assert.Len(t, resp.GetStatus(), len(payload), "the whole response must arrive")
}
// TestDialTargetRaisesTheDefaultLimit is the negative control: the same response
// over a connection carrying gRPC's own defaults is refused, which is the failure
// reported by `netbird status -d` on a large deployment.
func TestDialTargetRaisesTheDefaultLimit(t *testing.T) {
payload := strings.Repeat("x", 5*1024*1024)
addr := startBigStatusServer(t, payload)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := grpc.NewClient(
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err, "dial with the library defaults")
t.Cleanup(func() { _ = conn.Close() })
_, err = proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{})
require.Error(t, err, "the library default must reject this response")
assert.Equal(t, codes.ResourceExhausted, status.Code(err), "gRPC rejects an oversized message")
}
+4 -1
View File
@@ -36,7 +36,10 @@ const (
// address. The npipe scheme needs a context dialer because gRPC has no
// named-pipe resolver; unix and tcp are handled by gRPC itself.
func DialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(MaxRecvMsgSize())),
}
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
paths := PipePaths(name)
+54 -2
View File
@@ -379,9 +379,38 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
}
}
// bundleFilePattern names the bundle zips Generate creates in tempDir; the
// asterisk is filled in by os.CreateTemp.
const bundleFilePattern = "netbird.debug.*.zip"
const exportedBundlePrefix = "netbird.debug-file."
const exportedBundleMaxAge = 24 * time.Hour
// RemoveStaleBundles deletes bundle zips that an interrupted generation or
// upload left behind in dir. Only files older than maxAge go, so a bundle that
// another caller is still writing or uploading in the same directory survives.
// Exported bundles are kept for exportedBundleMaxAge instead.
func RemoveStaleBundles(dir string, maxAge time.Duration) {
removeStaleFiles(dir, bundleFilePattern, maxAge)
removeStaleFiles(dir, exportedBundlePrefix+"*.zip", exportedBundleMaxAge)
}
// ExportBundle renames a generated bundle out of the RemoveStaleBundles pattern
// and returns the new path. The caller owns the file from then on; an export
// abandoned for longer than exportedBundleMaxAge is removed by RemoveStaleBundles.
func ExportBundle(path string) (string, error) {
base := strings.TrimPrefix(filepath.Base(path), strings.SplitN(bundleFilePattern, "*", 2)[0])
exported := filepath.Join(filepath.Dir(path), exportedBundlePrefix+base)
if err := os.Rename(path, exported); err != nil {
return "", fmt.Errorf("export debug bundle: %w", err)
}
return exported, nil
}
// Generate creates a debug bundle and returns the location.
func (g *BundleGenerator) Generate() (resp string, err error) {
bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip")
bundlePath, err := os.CreateTemp(g.tempDir, bundleFilePattern)
if err != nil {
return "", fmt.Errorf("create zip file: %w", err)
}
@@ -618,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.
@@ -1731,3 +1760,26 @@ func anonymizeSlice(v []any, anonymizer *anonymize.Anonymizer) []any {
}
return v
}
func removeStaleFiles(dir, pattern string, maxAge time.Duration) {
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil {
log.Debugf("glob stale debug bundles in %s: %v", dir, err)
return
}
cutoff := time.Now().Add(-maxAge)
for _, path := range matches {
info, err := os.Stat(path)
if err != nil || info.ModTime().After(cutoff) {
continue
}
if err := os.Remove(path); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
log.Warnf("remove stale debug bundle %s: %v", path, err)
}
continue
}
log.Infof("removed stale debug bundle %s", path)
}
}
+61
View File
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"net"
"net/netip"
"net/url"
@@ -457,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},
@@ -845,7 +849,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
"Name": "non-config: profile name is not needed for debug purposes",
"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 +898,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 +923,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
@@ -971,3 +983,52 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
func newAnonymizerForTest() *anonymize.Anonymizer {
return anonymize.NewAnonymizer(anonymize.DefaultAddresses())
}
func TestRemoveStaleBundles(t *testing.T) {
dir := t.TempDir()
stale := filepath.Join(dir, "netbird.debug.111.zip")
fresh := filepath.Join(dir, "netbird.debug.222.zip")
other := filepath.Join(dir, "netbird.debug.333.txt")
owned := filepath.Join(dir, "netbird.debug.444.zip")
abandoned := filepath.Join(dir, "netbird.debug.555.zip")
for _, p := range []string{stale, fresh, other, owned, abandoned} {
require.NoError(t, os.WriteFile(p, []byte("x"), 0o600))
}
exported, err := ExportBundle(owned)
require.NoError(t, err)
exportedAbandoned, err := ExportBundle(abandoned)
require.NoError(t, err)
old := time.Now().Add(-2 * time.Hour)
for _, p := range []string{stale, other, exported} {
require.NoError(t, os.Chtimes(p, old, old))
}
ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour)
require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient))
RemoveStaleBundles(dir, time.Hour)
assert.NoFileExists(t, stale, "bundle older than maxAge should be removed")
assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading")
assert.FileExists(t, other, "files outside the bundle pattern must not be touched")
assert.NoFileExists(t, owned)
assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge")
assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned")
}
func TestBundleIncludesNetworkMap(t *testing.T) {
for _, anonymize := range []bool{false, true} {
t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) {
g := NewBundleGenerator(GeneratorDependencies{
SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}},
}, BundleConfig{Anonymize: anonymize})
require.Contains(t, bundleEntries(t, g), "network_map.json")
})
}
}
func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) {
g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{})
require.NotContains(t, bundleEntries(t, g), "network_map.json")
}
+7 -10
View File
@@ -9,9 +9,9 @@ import (
"os"
"testing"
"go.uber.org/mock/gomock"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface"
@@ -24,6 +24,10 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
)
// testIFaceBlackList mirrors the overlay prefixes profilemanager.DefaultInterfaceBlacklist
// carries. Declared here rather than imported because profilemanager imports this package.
var testIFaceBlackList = []string{"wt", "utun", "tun0"}
func TestUpdateDNSServer(t *testing.T) {
nameServers := []nbdns.NameServer{
@@ -243,10 +247,7 @@ func TestUpdateDNSServer(t *testing.T) {
for n, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
privKey, _ := wgtypes.GenerateKey()
newNet, err := stdnet.NewNet(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
newNet := stdnet.NewNet(context.Background(), testIFaceBlackList, nil)
opts := iface.WGIFaceOpts{
IFaceName: fmt.Sprintf("utun230%d", n),
@@ -348,11 +349,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
if err != nil {
t.Errorf("create stdnet: %v", err)
return
}
newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}, nil)
privKey, _ := wgtypes.GeneratePrivateKey()
opts := iface.WGIFaceOpts{
+1 -5
View File
@@ -394,11 +394,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) {
defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
if err != nil {
t.Fatalf("create stdnet: %v", err)
return nil, err
}
newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}, nil)
privKey, _ := wgtypes.GeneratePrivateKey()
-148
View File
@@ -1,148 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build mips || mips64 || ppc64 || s390x
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
bpfVariableSpecs
}
// bpfProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
bpfVariables
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbWgProxySettingsMap,
)
}
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfeb.o
var _BpfBytes []byte
Binary file not shown.
-148
View File
@@ -1,148 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
bpfVariableSpecs
}
// bpfProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
bpfVariables
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbWgProxySettingsMap,
)
}
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfel.o
var _BpfBytes []byte
Binary file not shown.
-115
View File
@@ -1,115 +0,0 @@
package ebpf
import (
_ "embed"
"net"
"sync"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
mapKeyFeatures uint32 = 0
featureFlagWGProxy = 0b00000001
)
var (
singleton manager.Manager
singletonLock = &sync.Mutex{}
)
// required packages libbpf-dev, libc6-dev-i386-amd64-cross
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
//
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
type GeneralManager struct {
lock sync.Mutex
link link.Link
featureFlags uint16
bpfObjs bpfObjects
}
// GetEbpfManagerInstance return a static eBpf Manager instance
func GetEbpfManagerInstance() manager.Manager {
singletonLock.Lock()
defer singletonLock.Unlock()
if singleton != nil {
return singleton
}
singleton = &GeneralManager{}
return singleton
}
func (tf *GeneralManager) setFeatureFlag(feature uint16) {
tf.featureFlags |= feature
}
func (tf *GeneralManager) loadXdp() error {
if tf.link != nil {
return nil
}
// it required for Docker
err := rlimit.RemoveMemlock()
if err != nil {
return err
}
iFace, err := net.InterfaceByName("lo")
if err != nil {
return err
}
// load pre-compiled programs into the kernel.
err = loadBpfObjects(&tf.bpfObjs, nil)
if err != nil {
return err
}
tf.link, err = link.AttachXDP(link.XDPOptions{
Program: tf.bpfObjs.NbXdpProg,
Interface: iFace.Index,
})
if err != nil {
_ = tf.bpfObjs.Close()
tf.link = nil
return err
}
return nil
}
func (tf *GeneralManager) unsetFeatureFlag(feature uint16) error {
tf.lock.Lock()
defer tf.lock.Unlock()
tf.featureFlags &^= feature
if tf.link == nil {
return nil
}
if tf.featureFlags == 0 {
return tf.close()
}
return tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
}
func (tf *GeneralManager) close() error {
log.Debugf("detach ebpf program ")
err := tf.bpfObjs.Close()
if err != nil {
log.Warnf("failed to close eBpf objects: %s", err)
}
err = tf.link.Close()
tf.link = nil
return err
}
@@ -1,31 +0,0 @@
package ebpf
import (
"testing"
)
func TestManager_setFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("invalid feature state")
}
mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
}
}
func TestManager_unsetFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy)
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 0 {
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
}
}
@@ -1,16 +0,0 @@
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
// map definitions: they load on kernels built without BTF, which BTF-style
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
// programs compile against current libbpf headers.
#ifndef NB_BPF_MAP_DEF_H
#define NB_BPF_MAP_DEF_H
struct bpf_map_def {
unsigned int type;
unsigned int key_size;
unsigned int value_size;
unsigned int max_entries;
unsigned int map_flags;
};
#endif
-54
View File
@@ -1,54 +0,0 @@
#include <stdbool.h>
#include <linux/if_ether.h> // ETH_P_IP
#include <linux/udp.h>
#include <linux/ip.h>
#include <netinet/in.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include "wg_proxy.c"
const __u16 flag_feature_wg_proxy = 0b01;
const __u32 map_key_features = 0;
struct bpf_map_def SEC("maps") nb_features = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
SEC("xdp")
int nb_xdp_prog(struct xdp_md *ctx) {
__u16 *features;
features = bpf_map_lookup_elem(&nb_features, &map_key_features);
if (!features) {
return XDP_PASS;
}
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip = (data + sizeof(struct ethhdr));
struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr));
// return early if not enough data
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){
return XDP_PASS;
}
// skip non IPv4 packages
if (eth->h_proto != htons(ETH_P_IP)) {
return XDP_PASS;
}
// skip non UPD packages
if (ip->protocol != IPPROTO_UDP) {
return XDP_PASS;
}
if (*features & flag_feature_wg_proxy) {
xdp_wg_proxy(ip, udp);
}
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
-27
View File
@@ -1,27 +0,0 @@
# XDP programs
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
port instead, and swaps the peer endpoint port into the source so the proxy can tell
peers apart.
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
definitions do not.
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
# Debug
The CONFIG_BPF_EVENTS kernel module is required for bpf_printk.
Apply this code to use bpf_printk
```
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
})
```
-60
View File
@@ -1,60 +0,0 @@
const __u32 map_key_proxy_port = 0;
const __u32 map_key_wg_port = 1;
struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__u16 proxy_port = 0;
__u16 wg_port = 0;
bool read_port_settings() {
__u16 *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port);
if (!value) {
return false;
}
proxy_port = *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port);
if (!value) {
return false;
}
wg_port = htons(*value);
return true;
}
int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
if (proxy_port == 0 || wg_port == 0) {
if (!read_port_settings()){
return XDP_PASS;
}
// bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port);
}
// 2130706433 = 127.0.0.1
if (ip->daddr != htonl(2130706433)) {
return XDP_PASS;
}
if (udp->source != wg_port){
return XDP_PASS;
}
__be16 new_src_port = udp->dest;
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}
@@ -1,41 +0,0 @@
package ebpf
import log "github.com/sirupsen/logrus"
const (
mapKeyProxyPort uint32 = 0
mapKeyWgPort uint32 = 1
)
func (tf *GeneralManager) LoadWgProxy(proxyPort, wgPort int) error {
log.Debugf("load ebpf WG proxy")
tf.lock.Lock()
defer tf.lock.Unlock()
err := tf.loadXdp()
if err != nil {
return err
}
err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyProxyPort, uint16(proxyPort))
if err != nil {
return err
}
err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyWgPort, uint16(wgPort))
if err != nil {
return err
}
tf.setFeatureFlag(featureFlagWGProxy)
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
if err != nil {
return err
}
return nil
}
func (tf *GeneralManager) FreeWGProxy() error {
log.Debugf("free ebpf WG proxy")
return tf.unsetFeatureFlag(featureFlagWGProxy)
}
@@ -1,15 +0,0 @@
//go:build !android
package ebpf
import (
"github.com/netbirdio/netbird/client/internal/ebpf/ebpf"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal
// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant
// panic on older Android version.
func GetEbpfManagerInstance() manager.Manager {
return ebpf.GetEbpfManagerInstance()
}
@@ -1,10 +0,0 @@
//go:build !linux || android
package ebpf
import "github.com/netbirdio/netbird/client/internal/ebpf/manager"
// GetEbpfManagerInstance return error because ebpf is not supported on all os
func GetEbpfManagerInstance() manager.Manager {
panic("unsupported os")
}
-7
View File
@@ -1,7 +0,0 @@
package manager
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
type Manager interface {
LoadWgProxy(proxyPort, wgPort int) error
FreeWGProxy() error
}
+11
View File
@@ -6,6 +6,17 @@ import (
"path/filepath"
)
// CheckOnlyOwnerWritable reports an error unless path, and every directory
// leading to it, is owned by an account that can already act with the privileges
// the caller holds, and is writable by nobody else.
//
// Exported for callers outside elevation that read a file while privileged and
// then act on what it says: the same question this package asks of an
// executable, asked of a configuration file.
func CheckOnlyOwnerWritable(path string) error {
return checkOnlyOwnerWritable(path)
}
// trustedSelf returns the path of this executable, provided it is one we are
// willing to have run as root.
//
+94 -129
View File
@@ -38,12 +38,12 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/approval"
"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"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ingressgw"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/netflow"
@@ -59,6 +59,7 @@ import (
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
@@ -173,6 +174,8 @@ type EngineConfig struct {
MTU uint16
CertStore certproof.Config
// for debug bundle generation
ProfileConfig *profilemanager.Config
@@ -249,6 +252,9 @@ type Engine struct {
udpMux *udpmux.UniversalUDPMuxDefault
// wgDetector is shared by every ICE agent through the ICE config.
wgDetector *stdnet.WGDetector
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
@@ -267,16 +273,33 @@ type Engine struct {
statusRecorder *peer.Status
firewall firewallManager.Manager
routeManager routemanager.Manager
acl acl.Manager
dnsForwardMgr *dnsfwd.Manager
ingressGatewayMgr *ingressgw.Manager
firewall firewallManager.Manager
routeManager routemanager.Manager
acl acl.Manager
dnsForwardMgr *dnsfwd.Manager
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
@@ -369,6 +392,7 @@ func NewEngine(
mgmClient: services.MgmClient,
relayManager: services.RelayManager,
peerStore: peerstore.NewConnStore(),
wgDetector: stdnet.NewWGDetector(config.WgIfaceName),
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
@@ -381,6 +405,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,
@@ -458,13 +483,6 @@ func (e *Engine) stopLocked() {
e.cleanupSSHConfig()
if e.ingressGatewayMgr != nil {
if err := e.ingressGatewayMgr.Close(); err != nil {
log.Warnf("failed to cleanup forward rules: %v", err)
}
e.ingressGatewayMgr = nil
}
if e.srWatcher != nil {
e.srWatcher.Close()
}
@@ -474,7 +492,7 @@ func (e *Engine) stopLocked() {
}
if e.updateManager != nil {
e.updateManager.SetDownloadOnly()
e.updateManager.ResetMode()
}
log.Info("cleaning up status recorder states")
@@ -671,10 +689,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
e.wgDevice.Store(e.wgInterface.GetWGDevice())
// Set up notrack rules immediately after proxy is listening to prevent
// conntrack entries from being created before the rules are in place
e.setupWGProxyNoTrack()
// Start after interface is up since port may have been resolved from 0 or changed if occupied
e.shutdownWg.Add(1)
go func() {
@@ -682,6 +696,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)
@@ -812,23 +832,6 @@ func (e *Engine) initFirewall() error {
return nil
}
// setupWGProxyNoTrack configures connection tracking exclusion for WireGuard proxy traffic.
// This prevents conntrack/MASQUERADE from affecting loopback traffic between WireGuard and the eBPF proxy.
func (e *Engine) setupWGProxyNoTrack() {
if e.firewall == nil {
return
}
proxyPort := e.wgInterface.GetProxyPort()
if proxyPort == 0 {
return
}
if err := e.firewall.SetupEBPFProxyNoTrack(proxyPort, uint16(e.config.WgPort)); err != nil {
log.Warnf("failed to setup ebpf proxy notrack: %v", err)
}
}
func (e *Engine) blockLanAccess() {
if e.config.BlockInbound {
// no need to set up extra deny rules if inbound is already blocked in general
@@ -991,11 +994,13 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
}
if autoUpdateSettings == nil {
log.Infof("no auto-update settings received, defaulting to download-only")
e.updateManager.SetDownloadOnly()
return
}
if autoUpdateSettings.Version == disableAutoUpdate {
log.Infof("auto-update is disabled")
log.Infof("auto-update is disabled, switching to download-only")
e.updateManager.SetDownloadOnly()
return
}
@@ -1264,23 +1269,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(
@@ -1308,6 +1344,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
}
@@ -1323,6 +1360,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
}
}
@@ -1524,7 +1562,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")
}
@@ -1663,13 +1701,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
done()
// Ingress forward rules
done = e.phase("forward_rules")
if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil {
log.Errorf("failed to update forward rules, err: %v", err)
}
done()
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
done = e.phase("offline_peers")
@@ -2219,10 +2250,7 @@ func (e *Engine) close() {
}
func (e *Engine) newWgIface() (*iface.WGIface, error) {
transportNet, err := e.newStdNet()
if err != nil {
log.Errorf("failed to create pion's stdnet: %s", err)
}
transportNet := e.newStdNet()
opts := iface.WGIFaceOpts{
IFaceName: e.config.WgIfaceName,
@@ -2778,74 +2806,6 @@ func (e *Engine) setForwarderCapture(pc device.PacketCapture) {
}
}
func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) {
if e.firewall == nil {
log.Warn("firewall is disabled, not updating forwarding rules")
return nil, nil
}
if len(rules) == 0 {
if e.ingressGatewayMgr == nil {
return nil, nil
}
err := e.ingressGatewayMgr.Close()
e.ingressGatewayMgr = nil
e.statusRecorder.SetIngressGwMgr(nil)
return nil, err
}
if e.ingressGatewayMgr == nil {
mgr := ingressgw.NewManager(e.firewall)
e.ingressGatewayMgr = mgr
e.statusRecorder.SetIngressGwMgr(mgr)
}
var merr *multierror.Error
forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules))
for _, rule := range rules {
proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol())
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err))
continue
}
dstPortInfo, err := convertPortInfo(rule.GetDestinationPort())
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("invalid destination port '%v': %w", rule.GetDestinationPort(), err))
continue
}
translateIP, err := convertToIP(rule.GetTranslatedAddress())
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to convert translated address '%s': %w", rule.GetTranslatedAddress(), err))
continue
}
translatePort, err := convertPortInfo(rule.GetTranslatedPort())
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("invalid translate port '%v': %w", rule.GetTranslatedPort(), err))
continue
}
forwardRule := firewallManager.ForwardRule{
Protocol: proto,
DestinationPort: *dstPortInfo,
TranslatedAddress: translateIP,
TranslatedPort: *translatePort,
}
forwardingRules = append(forwardingRules, forwardRule)
}
log.Infof("updating forwarding rules: %d", len(forwardingRules))
if err := e.ingressGatewayMgr.Update(forwardingRules); err != nil {
log.Errorf("failed to update forwarding rules: %v", err)
}
return forwardingRules, nberrors.FormatErrorOrNil(merr)
}
// toExcludedLazyPeers returns the peers that must have an always-active
// connection: those that are not lazy by policy (the per-peer lazy state or the
// account flag, subject to the local override).
@@ -2868,6 +2828,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
sortedFiles := slices.Clone(check.Files)
sort.Strings(sortedFiles)
normalized[i] = strings.Join(sortedFiles, "|")
if challenge := check.GetCertificateChallenge(); challenge != nil {
sortedCAs := slices.Clone(challenge.GetCaCertificates())
sort.Strings(sortedCAs)
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
}
}
sort.Strings(normalized)
+1 -1
View File
@@ -88,7 +88,7 @@ func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.T
return time.Time{}, errors.New("management client is not initialised")
}
info, err := system.GetInfoWithChecks(ctx, e.checks)
info, err := system.GetInfoWithChecks(ctx, e.appliedChecks())
if err != nil {
log.Warnf("failed to collect system info for session extend: %v", err)
info = system.GetInfo(ctx)
+386
View File
@@ -0,0 +1,386 @@
package internal
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/certproof"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
const (
// certContextPollInterval is how often the engine checks whether the user who can
// answer certificate challenges has changed, such as a login after an autostart.
certContextPollInterval = time.Minute
// certRetryInterval is how long after a collection that proved nothing it is tried
// again with the same user, for stores that come up late: a keychain unlocked after
// login, or a TPM resource manager started after the daemon.
certRetryInterval = 5 * time.Minute
)
// undeliveredContext never matches a real user context, so a collection whose meta sync
// failed is seen as stale on the next poll.
const undeliveredContext = "\x00undelivered"
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
var errSystemInfoTimeout = errors.New("system info gathering timed out")
// certPostureState remembers what the last certificate proof collection saw, so the
// engine can collect again when it went stale and tell the user when proofs go missing.
//
// Collection runs only on the posture watcher, never on the sync path, because a token,
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
// last collection cached. delivered identifies the chains management last received, so
// a collection that proves the same chains is not sent again: every meta sync makes
// management recompute and push network maps to the peer and its neighbours.
type certPostureState struct {
mu sync.Mutex
attempted bool
attemptedAt time.Time
userContext string
proven bool
delivered string
hasDelivery bool
cached []certposture.Proof
cachedFor string
// stuck is whether the collector was last seen with every slot held by a lost
// collection.
stuck bool
}
// record stores the outcome of a collection for the challenges identified by
// challengesKey and reports whether it changed from proven to unproven or back. The first
// collection only counts as a change when it proved nothing.
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
s.mu.Lock()
defer s.mu.Unlock()
proven := len(proofs) > 0
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
s.attempted = true
s.attemptedAt = now
s.userContext = userContext
s.proven = proven
s.cached = proofs
s.cachedFor = challengesKey
return changed
}
// undelivered marks the last collection as not having reached management, so the next
// poll collects and sends again instead of treating it as current.
func (s *certPostureState) undelivered() {
s.mu.Lock()
defer s.mu.Unlock()
if s.attempted {
s.userContext = undeliveredContext
}
s.hasDelivery = false
}
// markDelivered records the chains of proofs as the ones management holds.
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
s.mu.Lock()
defer s.mu.Unlock()
s.delivered = provenChainsKey(proofs)
s.hasDelivery = true
}
// sameAsDelivered reports whether proofs prove exactly the chains management already
// holds. Nonces and signatures are left out: they differ on every signing while the
// result management stores, the verified chains, stays the same.
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
}
// needsCollection reports whether the cached proofs no longer reflect what the device
// can prove for the challenges identified by challengesKey: they answer other
// challenges, the user who can answer has changed, or nothing was proven and the retry
// interval has passed.
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case !s.attempted || s.cachedFor != challengesKey:
return true
case userContext != s.userContext:
return true
default:
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
}
}
// setStuck records whether the collector is stuck and reports whether it just became so.
func (s *certPostureState) setStuck(stuck bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
became := stuck && !s.stuck
s.stuck = stuck
return became
}
// cachedProofs returns the cached proofs management still accepts for the challenges in
// checks: those whose nonce is current, or from an adjacent window, for one of them. Proofs
// signed for the previous nonce bridge the time until the watcher has signed the new one.
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
s.mu.Lock()
defer s.mu.Unlock()
var nonces [][]byte
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil {
nonces = append(nonces, challenge.GetNonce())
}
}
var accepted []certposture.Proof
for _, proof := range s.cached {
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
}) {
accepted = append(accepted, proof)
}
}
return accepted
}
// provenChainsKey identifies the set of chains in proofs, independent of their order.
func provenChainsKey(proofs []certposture.Proof) string {
keys := make([]string, 0, len(proofs))
for _, proof := range proofs {
h := sha256.New()
for _, cert := range proof.Chain {
writeLengthPrefixed(h, cert)
}
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
}
slices.Sort(keys)
return strings.Join(keys, ",")
}
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
func challengesKey(checks []*mgmProto.Checks) string {
h := sha256.New()
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
writeLengthPrefixed(h, challenge.GetNonce())
for _, ca := range challenge.GetCaCertificates() {
writeLengthPrefixed(h, []byte(ca))
}
writeLengthPrefixed(h, nil)
}
return hex.EncodeToString(h.Sum(nil))
}
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
_, _ = h.Write(size[:])
_, _ = h.Write(data)
}
// attachCertificateProofs attaches the cached proofs management accepts for the
// challenges in checks, without collecting: the caller is on the sync path. The info is
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
// count as delivered. The watcher is woken to collect when the cache is out of date.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
if !certproof.HasChallenges(checks) {
info.CertificateProofs = nil
return
}
info.CertificateProofs = e.certState.cachedProofs(checks)
e.certState.markDelivered(info.CertificateProofs)
e.wakeCertificatePosture()
}
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
// its next tick. It never blocks.
func (e *Engine) wakeCertificatePosture() {
if e.certWake == nil {
return
}
select {
case e.certWake <- struct{}{}:
default:
}
}
// publishCertificatePostureEvent tells the user when the device stops proving any
// certificate, which management treats as failing every certificate posture check, and
// when it proves one again. Without it the loss of access would have no visible cause.
func (e *Engine) publishCertificatePostureEvent(proven bool) {
if e.statusRecorder == nil {
return
}
if proven {
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
"certificate posture: a certificate is proven again",
"A certificate required by your organization's device policy is available again.", nil)
return
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
"certificate posture: no certificate could be proven",
"NetBird could not use a certificate required by your organization's device policy. "+
"Access to some resources may be blocked until one is available.", nil)
}
// publishCertificateStoreStuckEvent tells the user that reading the certificate store
// stopped answering. Collection resumes only once a stuck call returns, which a store
// that hangs for good never does short of a restart.
func (e *Engine) publishCertificateStoreStuckEvent() {
if e.statusRecorder == nil {
return
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
"certificate posture: the certificate store stopped responding",
"NetBird cannot read the certificates required by your organization's device policy because "+
"the certificate store stopped responding. If this persists, restart the NetBird service.", nil)
}
// watchCertificatePosture owns certificate proof collection until ctx is done. It
// collects when woken by new checks or a sync, and on every tick when the cached proofs
// went stale, such as after a login following an autostart.
func (e *Engine) watchCertificatePosture(ctx context.Context) {
ticker := time.NewTicker(certContextPollInterval)
defer ticker.Stop()
for {
// A pending update that times out again stays pending; the applied checks'
// proofs are still refreshed so a lost certificate is reported meanwhile.
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to sync posture checks that timed out before: %v", err)
}
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to refresh certificate posture proofs: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-e.certWake:
}
}
}
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
// gathering the system info, and applies them once it succeeds. Without it they would
// wait for the next network map that changes the checks.
func (e *Engine) retryPendingChecks() error {
if !e.hasPendingChecks.Load() {
return nil
}
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.stopping() || !e.hasPendingChecks.Load() {
return nil
}
checks := e.pendingChecks
log.Debugf("posture checks: retrying the meta sync that timed out")
if err := e.syncChecksMeta(checks); err != nil {
return err
}
e.setAppliedChecks(checks)
e.clearPendingChecks()
return nil
}
// refreshCertificateProofs collects the proofs for the applied checks when the cached
// ones are out of date, and sends them to management when they prove other chains than
// it holds. It holds no engine lock while collecting.
func (e *Engine) refreshCertificateProofs() error {
checks := e.appliedChecks()
if e.stopping() || !certproof.HasChallenges(checks) {
return nil
}
key := challengesKey(checks)
userContext := certproof.UserContext(e.config.CertStore)
if !e.certState.needsCollection(key, userContext, time.Now()) {
return nil
}
log.Debugf("certificate posture: collecting proofs")
peerKey := e.config.WgPrivateKey.PublicKey()
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
// A collection cut short by the engine stopping proved nothing about the device, and
// must not be reported to the user as a lost certificate.
if e.stopping() {
return nil
}
if e.certState.record(key, userContext, proofs, time.Now()) {
e.publishCertificatePostureEvent(len(proofs) > 0)
}
if e.certState.setStuck(e.certProofs.Stuck()) {
e.publishCertificateStoreStuckEvent()
}
if e.certState.sameAsDelivered(proofs) {
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
return nil
}
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// stopping reports whether the engine is shutting down.
func (e *Engine) stopping() bool {
select {
case <-e.ctx.Done():
return true
default:
return false
}
}
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
// with the cached certificate proofs. The caller holds syncMsgMux.
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
// are serialized and read the cache under that lock, so a send from the sync path cannot
// overwrite newer proofs the watcher sent in the meantime.
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
e.certSendMu.Lock()
defer e.certSendMu.Unlock()
info.CertificateProofs = e.certState.cachedProofs(checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
e.certState.undelivered()
return fmt.Errorf("sync meta: %w", err)
}
e.certState.markDelivered(info.CertificateProofs)
return nil
}
+224
View File
@@ -0,0 +1,224 @@
//go:build !windows && !darwin
package internal
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
mgmt "github.com/netbirdio/netbird/shared/management/client"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertPostureState_Record(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var proven certPostureState
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
var unproven certPostureState
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
}
// TestCertPostureState_ReportsStuckOnce: a store that stopped answering is reported to
// the user once when it wedges the collector, not on every collection that follows, and
// again if it wedges anew after recovering.
func TestCertPostureState_ReportsStuckOnce(t *testing.T) {
var s certPostureState
assert.True(t, s.setStuck(true), "becoming stuck is reported")
assert.False(t, s.setStuck(true), "staying stuck is not reported again")
assert.False(t, s.setStuck(false), "recovering is not a stuck report")
assert.True(t, s.setStuck(true), "getting stuck again is reported again")
}
func TestCertPostureState_NeedsCollection(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var s certPostureState
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
s.record("k", "501:alice", one, now)
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
s.record("k", "", nil, now)
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
s.record("k", "501:alice", one, now)
s.undelivered()
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
}
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
// window and accepts the previous one, so a cached proof keeps being attached across one
// rotation, while the watcher signs the new nonce, and not across two.
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
challenger := certposture.NewChallenger([]byte("secret"))
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
now := time.Now()
checksAt := func(at time.Time) []*mgmProto.Checks {
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
}
var s certPostureState
current := checksAt(now)
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
}
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
// whose management client records the proofs of every meta sync.
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
t.Helper()
// The store refuses a group-writable directory, which t.TempDir yields under a
// user-private-group umask.
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
t.Setenv(certproof.StoreDirEnv, dir)
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
e := &Engine{
ctx: context.Background(),
syncMsgMux: &sync.Mutex{},
config: &EngineConfig{WgPrivateKey: key},
statusRecorder: recorder,
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
}
ca := certtest.NewCA(t, "corp-root")
peerKey := key.PublicKey()
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
return e, dir, ca
}
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
t.Helper()
deviceKey := certtest.ECDSAKey(t)
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
return pem
}
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
// watcher collected, so a certificate that appears in the store is not proven on the sync
// path until the watcher ran.
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
writeDeviceCert(t, dir, ca)
info := &system.Info{}
e.attachCertificateProofs(info, e.checks)
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
require.NoError(t, e.refreshCertificateProofs())
e.attachCertificateProofs(info, e.checks)
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
}
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
// certificate gets one warning, and one notice when it can again.
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
recorder := peer.NewRecorder("")
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
warnings := func() int {
n := 0
for _, ev := range recorder.GetEventHistory() {
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
n++
}
}
return n
}
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
events := recorder.GetEventHistory()
require.NotEmpty(t, events)
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
}
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
// and push network maps, so a collection that proves the same chains as before must not
// send, while a new certificate or a failed delivery must.
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
var sent [][]certposture.Proof
failSync := false
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
if failSync {
return errors.New("management unavailable")
}
sent = append(sent, info.CertificateProofs)
return nil
})
// expire makes the last collection due for the unproven retry.
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
// switchUser makes the last collection stale as if another user signed in.
switchUser := func() { e.certState.userContext = "someone else" }
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 1, "the first collection is sent")
assert.Empty(t, sent[0], "nothing to prove yet")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 1, "still proving nothing is not sent again")
pem := writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 2, "a newly proven certificate is sent")
assert.Len(t, sent[1], 1, "the new proof is attached")
switchUser()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
failSync = true
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
switchUser()
require.Error(t, e.refreshCertificateProofs())
failSync = false
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
// Management missed the empty proof set, so it still holds the chain the device proves
// again now; after a failed delivery the state is unknown, and it is sent regardless.
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
}
+1
View File
@@ -15,5 +15,6 @@ func (e *Engine) createICEConfig() icemaker.Config {
UDPMux: e.udpMux.SingleSocketUDPMux,
UDPMuxSrflx: e.udpMux,
NATExternalIPs: e.parseNATExternalIPMappings(),
WGDetector: e.wgDetector,
}
}
+1
View File
@@ -13,6 +13,7 @@ func (e *Engine) createICEConfig() icemaker.Config {
InterfaceBlackList: e.config.IFaceBlackList,
DisableIPv6Discovery: e.config.DisableIPv6Discovery,
NATExternalIPs: e.parseNATExternalIPMappings(),
WGDetector: e.wgDetector,
}
return cfg
}
+17 -14
View File
@@ -12,12 +12,12 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.uber.org/mock/gomock"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
@@ -41,7 +42,6 @@ import (
nbcache "github.com/netbirdio/netbird/management/server/cache"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
@@ -81,6 +81,7 @@ func TestEngine_SSH(t *testing.T) {
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
ServerSSHAllowed: true,
MTU: iface.DefaultMTU,
SSHKey: sshKey,
@@ -204,11 +205,12 @@ func TestEngine_Sync(t *testing.T) {
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun103",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
MTU: iface.DefaultMTU,
WgIfaceName: "utun103",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
@@ -412,11 +414,12 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin
wgPort := 33100 + i
conf := &EngineConfig{
WgIfaceName: ifaceName,
WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
WgPrivateKey: key,
WgPort: wgPort,
MTU: iface.DefaultMTU,
WgIfaceName: ifaceName,
WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
WgPrivateKey: key,
WgPort: wgPort,
IFaceBlackList: profilemanager.DefaultInterfaceBlacklist,
MTU: iface.DefaultMTU,
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
@@ -519,8 +522,8 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManager, false, cacheStore)
if err != nil {
return nil, "", err
}
+7 -5
View File
@@ -1,4 +1,4 @@
//go:build !js
//go:build !js && !android
package internal
@@ -7,10 +7,12 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
)
// newSessionWatcher returns the real SSO session expiry watcher for every
// non-wasm build. The js/wasm build gets a no-op stub from
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
// machinery) never links into the wasm binary.
// newSessionWatcher returns the real SSO session expiry watcher. The js/wasm
// build gets a no-op stub from engine_sessionwatch_js.go so the sessionwatch
// package (and its timer machinery) never links into the wasm binary; the
// android build gets a deadline-only watcher from
// engine_sessionwatch_android.go because the app schedules the warnings
// itself.
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
return sessionwatch.New(recorder)
}
@@ -0,0 +1,12 @@
//go:build android
package internal
import (
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
"github.com/netbirdio/netbird/client/internal/peer"
)
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
return sessionwatch.NewDeadlineOnly(recorder)
}
+2 -2
View File
@@ -6,6 +6,6 @@ import (
"github.com/netbirdio/netbird/client/internal/stdnet"
)
func (e *Engine) newStdNet() (*stdnet.Net, error) {
return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList)
func (e *Engine) newStdNet() *stdnet.Net {
return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList, e.wgDetector)
}
+2 -2
View File
@@ -2,6 +2,6 @@ package internal
import "github.com/netbirdio/netbird/client/internal/stdnet"
func (e *Engine) newStdNet() (*stdnet.Net, error) {
return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList)
func (e *Engine) newStdNet() *stdnet.Net {
return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList, e.wgDetector)
}
+151 -16
View File
@@ -65,7 +65,6 @@ type MockWGIface struct {
GetStatsFunc func() (map[string]configurer.WGStats, error)
GetInterfaceGUIDStringFunc func() (string, error)
GetProxyFunc func() wgproxy.Proxy
GetProxyPortFunc func() uint16
GetNetFunc func() *netstack.Net
LastActivitiesFunc func() map[string]monotime.Time
}
@@ -162,13 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy {
return m.GetProxyFunc()
}
func (m *MockWGIface) GetProxyPort() uint16 {
if m.GetProxyPortFunc != nil {
return m.GetProxyPortFunc()
}
return 0
}
func (m *MockWGIface) GetNet() *netstack.Net {
return m.GetNetFunc()
}
@@ -367,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()
@@ -696,10 +793,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
engine.ctx = ctx
newNet, err := stdnet.NewNet(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
opts := iface.WGIFaceOpts{
IFaceName: wgIfaceName,
@@ -904,10 +998,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
}, MobileDependency{})
engine.ctx = ctx
newNet, err := stdnet.NewNet(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist, nil)
opts := iface.WGIFaceOpts{
IFaceName: wgIfaceName,
Address: wgaddr.MustParseWGAddress(wgAddr),
@@ -1193,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) {
@@ -1507,3 +1624,21 @@ func TestOverlayAddrsFromAllowedIPs(t *testing.T) {
})
}
}
func TestEngine_SyncResponsePersistence(t *testing.T) {
e := &Engine{}
_, err := e.GetLatestSyncResponse()
require.Error(t, err, "persistence is disabled by default")
e.SetSyncResponsePersistence(true)
e.persistSyncResponse(&mgmtProto.SyncResponse{NetworkMap: &mgmtProto.NetworkMap{Serial: 7}})
got, err := e.GetLatestSyncResponse()
require.NoError(t, err)
assert.Equal(t, uint64(7), got.GetNetworkMap().GetSerial())
e.SetSyncResponsePersistence(false)
_, err = e.GetLatestSyncResponse()
require.Error(t, err)
}
+2 -2
View File
@@ -331,8 +331,8 @@ type vncApprover struct {
func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) {
// Resolve the source overlay IP to a peer FQDN for the prompt label.
if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil {
if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok {
info.PeerName = fqdn
if state, ok := a.statusRecorder.PeerStateByIP(info.SourceIP); ok {
info.PeerName = state.FQDN
}
}
subject := fmt.Sprintf("VNC connection from %s", displayPeer(info))
-1
View File
@@ -28,7 +28,6 @@ type wgIfaceBase interface {
Up() (*udpmux.UniversalUDPMuxDefault, error)
UpdateAddr(newAddr wgaddr.Address) error
GetProxy() wgproxy.Proxy
GetProxyPort() uint16
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
RemoveEndpointAddress(key string) error
RemovePeer(peerKey string) error
-111
View File
@@ -1,111 +0,0 @@
package ingressgw
import (
"fmt"
"sync"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
)
type DNATFirewall interface {
AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error)
DeleteDNATRule(rule firewall.Rule) error
}
type RulePair struct {
firewall.ForwardRule
firewall.Rule
}
type Manager struct {
dnatFirewall DNATFirewall
rules map[firewall.RuleID]RulePair
rulesMu sync.Mutex
}
func NewManager(dnatFirewall DNATFirewall) *Manager {
return &Manager{
dnatFirewall: dnatFirewall,
rules: make(map[firewall.RuleID]RulePair),
}
}
func (h *Manager) Update(forwardRules []firewall.ForwardRule) error {
h.rulesMu.Lock()
defer h.rulesMu.Unlock()
var mErr *multierror.Error
toDelete := make(map[firewall.RuleID]RulePair, len(h.rules))
for id, r := range h.rules {
toDelete[id] = r
}
// Process new/updated rules
for _, fwdRule := range forwardRules {
id := fwdRule.ID()
if _, ok := h.rules[id]; ok {
delete(toDelete, id)
continue
}
rule, err := h.dnatFirewall.AddDNATRule(fwdRule)
if err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err))
continue
}
if rule == nil {
mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String()))
continue
}
log.Infof("forward rule has been added '%s'", fwdRule)
h.rules[id] = RulePair{
ForwardRule: fwdRule,
Rule: rule,
}
}
// Remove deleted rules
for id, rulePair := range toDelete {
if err := h.dnatFirewall.DeleteDNATRule(rulePair.Rule); err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rulePair.ForwardRule.String(), err))
}
log.Infof("forward rule has been deleted '%s'", rulePair.ForwardRule)
delete(h.rules, id)
}
return nberrors.FormatErrorOrNil(mErr)
}
func (h *Manager) Close() error {
h.rulesMu.Lock()
defer h.rulesMu.Unlock()
log.Infof("clean up all (%d) forward rules", len(h.rules))
var mErr *multierror.Error
for _, rule := range h.rules {
if err := h.dnatFirewall.DeleteDNATRule(rule.Rule); err != nil {
mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rule, err))
}
}
h.rules = make(map[firewall.RuleID]RulePair)
return nberrors.FormatErrorOrNil(mErr)
}
func (h *Manager) Rules() []firewall.ForwardRule {
h.rulesMu.Lock()
defer h.rulesMu.Unlock()
rules := make([]firewall.ForwardRule, 0, len(h.rules))
for _, rulePair := range h.rules {
rules = append(rules, rulePair.ForwardRule)
}
return rules
}
-281
View File
@@ -1,281 +0,0 @@
package ingressgw
import (
"fmt"
"net/netip"
"testing"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
)
var (
_ firewall.Rule = (*MocFwRule)(nil)
_ DNATFirewall = &MockDNATFirewall{}
)
type MocFwRule struct {
id firewall.RuleID
}
func (m *MocFwRule) ID() firewall.RuleID {
return m.id
}
type MockDNATFirewall struct {
throwError bool
}
func (m *MockDNATFirewall) AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) {
if m.throwError {
return nil, fmt.Errorf("moc error")
}
fwRule := &MocFwRule{
id: fwdRule.ID(),
}
return fwRule, nil
}
func (m *MockDNATFirewall) DeleteDNATRule(rule firewall.Rule) error {
if m.throwError {
return fmt.Errorf("moc error")
}
return nil
}
func (m *MockDNATFirewall) forceToThrowErrors() {
m.throwError = true
}
func TestManager_AddRule(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
updates := []firewall.ForwardRule{
{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
},
{
Protocol: firewall.ProtocolUDP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}}
if err := mgr.Update(updates); err != nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != len(updates) {
t.Errorf("unexpected rules count: %d", len(rules))
}
}
func TestManager_UpdateRule(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
ruleUDP := firewall.ForwardRule{
Protocol: firewall.ProtocolUDP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleUDP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != 1 {
t.Errorf("unexpected rules count: %d", len(rules))
}
if rules[0].TranslatedAddress.String() != ruleUDP.TranslatedAddress.String() {
t.Errorf("unexpected rule: %v", rules[0])
}
if rules[0].TranslatedPort.String() != ruleUDP.TranslatedPort.String() {
t.Errorf("unexpected rule: %v", rules[0])
}
if rules[0].DestinationPort.String() != ruleUDP.DestinationPort.String() {
t.Errorf("unexpected rule: %v", rules[0])
}
if rules[0].Protocol != ruleUDP.Protocol {
t.Errorf("unexpected rule: %v", rules[0])
}
}
func TestManager_ExtendRules(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
ruleUDP := firewall.ForwardRule{
Protocol: firewall.ProtocolUDP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != 2 {
t.Errorf("unexpected rules count: %d", len(rules))
}
}
func TestManager_UnderlingError(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
ruleUDP := firewall.ForwardRule{
Protocol: firewall.ProtocolUDP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.2"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
fw.forceToThrowErrors()
if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err == nil {
t.Errorf("expected error")
}
rules := mgr.Rules()
if len(rules) != 1 {
t.Errorf("unexpected rules count: %d", len(rules))
}
}
func TestManager_Cleanup(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := mgr.Update([]firewall.ForwardRule{}); err != nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != 0 {
t.Errorf("unexpected rules count: %d", len(rules))
}
}
func TestManager_DeleteBrokenRule(t *testing.T) {
fw := &MockDNATFirewall{}
// force to throw errors when Add DNAT Rule
fw.forceToThrowErrors()
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err == nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != 0 {
t.Errorf("unexpected rules count: %d", len(rules))
}
// simulate that to remove a broken rule
if err := mgr.Update([]firewall.ForwardRule{}); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := mgr.Close(); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestManager_Close(t *testing.T) {
fw := &MockDNATFirewall{}
mgr := NewManager(fw)
port, _ := firewall.NewPort(8080)
ruleTCP := firewall.ForwardRule{
Protocol: firewall.ProtocolTCP,
DestinationPort: *port,
TranslatedAddress: netip.MustParseAddr("172.16.254.1"),
TranslatedPort: *port,
}
if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil {
t.Errorf("unexpected error: %v", err)
}
if err := mgr.Close(); err != nil {
t.Errorf("unexpected error: %v", err)
}
rules := mgr.Rules()
if len(rules) != 0 {
t.Errorf("unexpected rules count: %d", len(rules))
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func asDaemon(t *testing.T, id Identity) {
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = id, true
selfMayDelegate = !id.IsPrivileged()
selfMayDelegate = mayDelegate(id)
}
func TestCallerIdentity_DirectConnections(t *testing.T) {
+6 -6
View File
@@ -18,7 +18,8 @@ import (
"google.golang.org/grpc/peer"
)
// Well-known Windows SIDs that identify a fully privileged principal.
// Well-known Windows SIDs. Only LocalSystem and BUILTIN\Administrators identify a
// privileged principal; the service accounts are shared by unrelated services.
const (
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
@@ -67,9 +68,9 @@ func (i Identity) IsWindows() bool {
// user-to-root boundary.
//
// On Windows the decision comes from the caller's token rather than from
// account names or group RIDs: an elevated token, one of the service accounts
// the daemon itself may run as, or a token with BUILTIN\Administrators
// enabled. A UAC-filtered administrator has that group marked deny-only, and
// account names or group RIDs: an elevated token, the LocalSystem SID, or a
// token with BUILTIN\Administrators enabled. LocalService and NetworkService
// are not privileged by SID. A UAC-filtered administrator has that group marked deny-only, and
// deny-only groups are dropped when the identity is captured, so such a
// caller is correctly reported as unprivileged. Domain group memberships
// (Domain Admins and friends) are deliberately not consulted: they say
@@ -83,8 +84,7 @@ func (i Identity) IsPrivileged() bool {
return true
}
switch i.SID {
case sidLocalSystem, sidLocalService, sidNetworkService:
if i.SID == sidLocalSystem {
return true
}
@@ -64,3 +64,58 @@ func TestIdentitySameUser(t *testing.T) {
})
}
}
func TestIdentityIsPrivileged(t *testing.T) {
tests := []struct {
name string
id Identity
want bool
}{
{
name: "Root",
id: Identity{UID: 0, GID: 0},
want: true,
},
{
name: "Non-root",
id: Identity{UID: 1000, GID: 1000},
want: false,
},
{
name: "Local system windows",
id: Identity{SID: sidLocalSystem},
want: true,
},
{
name: "Windows elevated",
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Elevated: true},
want: true,
},
{
name: "Admin group windows",
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Groups: []string{sidAdministrators}},
want: true,
},
{
name: "Regular user windows",
id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001"},
want: false,
},
{
name: "Network service windows",
id: Identity{SID: sidNetworkService},
want: false,
},
{
name: "Local service windows",
id: Identity{SID: sidLocalService},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.id.IsPrivileged())
})
}
}
+9 -1
View File
@@ -45,7 +45,15 @@ func init() {
// matching there would let a non-elevated shell of an administrator account
// act as an administrator, which is the boundary the token check exists to
// keep.
selfMayDelegate = !id.IsPrivileged()
selfMayDelegate = mayDelegate(id)
}
// mayDelegate reports whether a daemon running as id may extend its authority to
// callers sharing its identity. The shared service accounts are excluded: their
// SID is held by unrelated services, so matching on it would grant them the
// daemon's authority.
func mayDelegate(id Identity) bool {
return !id.IsPrivileged() && id.SID != sidLocalService && id.SID != sidNetworkService
}
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
+26 -1
View File
@@ -98,7 +98,7 @@ func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = tt.self, tt.selfKnown
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
selfMayDelegate = tt.selfKnown && mayDelegate(tt.self)
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
@@ -132,3 +132,28 @@ func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
t.Errorf("an unrelated identity %v was treated as privileged", other)
}
}
// The shared service accounts are held by unrelated services, so a daemon running
// as one of them must not extend its authority to every process with that SID.
func TestMayDelegate(t *testing.T) {
tests := []struct {
name string
self Identity
want bool
}{
{name: "unprivileged unix user", self: Identity{UID: 1000}, want: true},
{name: "root", self: Identity{UID: 0}, want: false},
{name: "unprivileged windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001"}, want: true},
{name: "elevated windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, want: false},
{name: "local system", self: Identity{SID: sidLocalSystem}, want: false},
{name: "local service", self: Identity{SID: sidLocalService}, want: false},
{name: "network service", self: Identity{SID: sidNetworkService}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mayDelegate(tt.self); got != tt.want {
t.Errorf("mayDelegate(%+v) = %v, want %v", tt.self, got, tt.want)
}
})
}
}

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