mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
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:
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const helperTimeout = 30 * time.Second
|
||||
|
||||
// userHelperBackoff holds off asking a user's keychain again after it proved nothing.
|
||||
var userHelperBackoff = newHelperBackoff()
|
||||
|
||||
// CollectProofs answers the certificate challenges in checks from every store this Mac
|
||||
// can reach. The root daemon reads the System keychain itself, which is where MDM
|
||||
// installs device identities, and reaches the console user's login keychain only by
|
||||
// launching a helper into that user's session. A Mac sitting at the login window
|
||||
// therefore yields device proofs alone.
|
||||
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
challenges := certificateChallenges(checks)
|
||||
if len(challenges) == 0 {
|
||||
logNoChallenges(checks)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A helper already runs inside the user's session, so it reads its own keychain
|
||||
// directly and must never launch another one.
|
||||
if os.Geteuid() != 0 {
|
||||
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||
}
|
||||
|
||||
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||
if cfg.OwnerUnknown {
|
||||
return proofs
|
||||
}
|
||||
|
||||
userProofs, err := collectAsConsoleUser(ctx, cfg.ProfileOwner, challenges, peerKey)
|
||||
if err != nil {
|
||||
log.Debugf("certificate posture: console user keychain unavailable: %v", err)
|
||||
}
|
||||
return mergeProofs(proofs, userProofs)
|
||||
}
|
||||
|
||||
// UserContext identifies the user whose keychain a collection would include: the console
|
||||
// user when it owns the active profile, or empty when no user keychain would be asked. A
|
||||
// change means a collection made earlier no longer reflects what this Mac can prove.
|
||||
func UserContext(cfg Config) string {
|
||||
if os.Geteuid() != 0 || cfg.OwnerUnknown {
|
||||
return ""
|
||||
}
|
||||
user, ok := currentConsoleUser()
|
||||
if !ok || !user.isOwner(cfg.ProfileOwner) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(user.UID), 10) + ":" + user.Name
|
||||
}
|
||||
|
||||
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
|
||||
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
|
||||
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
|
||||
// which is what launchctl asuser does.
|
||||
func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
|
||||
user, ok := currentConsoleUser()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if !user.isOwner(owner) {
|
||||
log.Debugf("certificate posture: console user %s does not own the active profile, no user keychain is asked", user.Name)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
uid := strconv.FormatUint(uint64(user.UID), 10)
|
||||
backoffKey := helperBackoffKey(uid, challenges)
|
||||
if !userHelperBackoff.allow(backoffKey, time.Now()) {
|
||||
log.Debugf("certificate posture: the keychain of uid %s proved nothing recently, not asking again yet", uid)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve own binary: %w", err)
|
||||
}
|
||||
|
||||
parent := ctx
|
||||
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Absolute paths, because the daemon's PATH is configurable through the service
|
||||
// environment, and sudo selects the user by uid so the name never has to round-trip.
|
||||
cmd := exec.CommandContext(ctx, "/bin/launchctl", "asuser", uid, "/usr/bin/sudo", "-u", "#"+uid, "-H", "--", binary, "posture", "cert-proof")
|
||||
killHelperGroupOnCancel(cmd)
|
||||
|
||||
log.Debugf("certificate posture: asking the desktop session of uid %s to answer %d challenges", uid, len(challenges))
|
||||
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
|
||||
// A run that completed, or ran into the timeout waiting on a prompt nobody answered,
|
||||
// tells whether the keychain proves anything. A launch or output failure, or a run the
|
||||
// caller cut short, says nothing about it and must not hold off the next one.
|
||||
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) && parent.Err() == nil
|
||||
if err == nil || timedOut {
|
||||
userHelperBackoff.record(backoffKey, err == nil && len(proofs) > 0, time.Now())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run helper as uid %s: %w", uid, err)
|
||||
}
|
||||
log.Debugf("certificate posture: desktop session of uid %s returned %d proofs", uid, len(proofs))
|
||||
return proofs, nil
|
||||
}
|
||||
|
||||
// helperStore is the store the helper reads: the user's login keychain alone. The
|
||||
// session's search list also holds the System keychain, which the daemon reads itself,
|
||||
// and using a System keychain key from the user's session would ask for an
|
||||
// administrator's approval.
|
||||
func helperStore() Store {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err)
|
||||
return newKeychainStore()
|
||||
}
|
||||
login := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
||||
if _, err := os.Stat(login); err != nil {
|
||||
// Keychains created before macOS 10.12 keep the old file name.
|
||||
login = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
||||
}
|
||||
return newKeychainStore(login)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
|
||||
// store to sign with, so the stores stay out of the WebAssembly build.
|
||||
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserContext identifies the user whose certificates a collection would include. A
|
||||
// browser has no per-user store, so it never changes.
|
||||
func UserContext(Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultStore is empty in a browser.
|
||||
func DefaultStore() Store {
|
||||
return Stores{}
|
||||
}
|
||||
|
||||
func helperStore() Store {
|
||||
return DefaultStore()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
|
||||
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
|
||||
// per-user certificates out of reach of a privileged daemon, so every other platform
|
||||
// reads its store in the daemon itself.
|
||||
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
|
||||
}
|
||||
|
||||
// UserContext identifies the user whose certificates a collection would include. These
|
||||
// platforms have no per-user store, so it never changes.
|
||||
func UserContext(Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// helperStore is the store the helper reads. Nothing launches a helper on these
|
||||
// platforms, so it is the store the daemon reads, configured from the same environment,
|
||||
// which lets an administrator check a setup by running the helper by hand.
|
||||
func helperStore() Store {
|
||||
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
||||
|
||||
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
|
||||
corpCA := certtest.NewCA(t, "corp-root")
|
||||
otherCA := certtest.NewCA(t, "other-root")
|
||||
unrelatedCA := certtest.NewCA(t, "unrelated-root")
|
||||
|
||||
dir := storeDir(t)
|
||||
deviceKey := certtest.ECDSAKey(t)
|
||||
device := corpCA.Issue(t, deviceKey, "device")
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
|
||||
|
||||
otherKey := certtest.RSAKey(t)
|
||||
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
|
||||
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
|
||||
|
||||
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
|
||||
writeFile(t, dir, "notes.txt", "ignored")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
nonce := challenger.Nonce(peerKey, time.Now())
|
||||
challenge := func(cas ...string) *proto.Checks {
|
||||
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
|
||||
}
|
||||
checks := []*proto.Checks{
|
||||
{Files: []string{"/usr/bin/agent"}},
|
||||
challenge(corpCA.PEM),
|
||||
challenge(corpCA.PEM),
|
||||
challenge(otherCA.PEM),
|
||||
challenge(unrelatedCA.PEM),
|
||||
challenge("not a pem"),
|
||||
}
|
||||
|
||||
proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey)
|
||||
|
||||
require.Len(t, proofs, 2)
|
||||
var subjects []string
|
||||
for _, p := range proofs {
|
||||
chain, err := challenger.Verify(p, peerKey, time.Now())
|
||||
require.NoError(t, err)
|
||||
subjects = append(subjects, chain[0].Subject.CommonName)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
|
||||
}
|
||||
|
||||
func TestCollect_NothingToProve(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
key := certtest.ECDSAKey(t)
|
||||
ca := certtest.NewCA(t, "root")
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store Store
|
||||
checks []*proto.Checks
|
||||
}{
|
||||
{"no checks", newFileStore(dir), nil},
|
||||
{"files only", newFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
|
||||
{"challenge without nonce", newFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
|
||||
{"missing store dir", newFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_ChainWithIntermediate(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "intermediate")
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := intermediate.Issue(t, key, "device")
|
||||
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
|
||||
|
||||
candidates, err := newFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1)
|
||||
require.Len(t, candidates[0].Chain, 2)
|
||||
|
||||
roots, err := certposture.ParseCAs([]string{root.PEM})
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
|
||||
}
|
||||
|
||||
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
|
||||
// leaves the directory group-writable on systems with a user-private group umask.
|
||||
func storeDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o700))
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
|
||||
}
|
||||
|
||||
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
dir := storeDir(t)
|
||||
key := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
|
||||
store := newFileStore(dir)
|
||||
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nonce []byte
|
||||
peerKey []byte
|
||||
want int
|
||||
}{
|
||||
{"issued nonce and peer key are signed", nonce, peerKey, 1},
|
||||
{"short nonce is not signed", nonce[:8], peerKey, 0},
|
||||
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
|
||||
{"short peer key is not signed", nonce, peerKey[:16], 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
|
||||
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
|
||||
"the device key signs only what management could have issued")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
dir := storeDir(t)
|
||||
|
||||
// A stale key next to a renewed certificate, sorted before the good pair, must not
|
||||
// produce a proof that management rejects and stop the search there.
|
||||
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
|
||||
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
|
||||
goodKey := certtest.ECDSAKey(t)
|
||||
good := ca.Issue(t, goodKey, "good")
|
||||
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
|
||||
|
||||
candidates, err := newFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
|
||||
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
proofs := CollectChallenges(context.Background(), newFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
|
||||
require.Len(t, proofs, 1)
|
||||
_, err = challenger.Verify(proofs[0], peerKey, now)
|
||||
assert.NoError(t, err, "the proof sent is one management accepts")
|
||||
}
|
||||
|
||||
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
|
||||
type staticStore []Candidate
|
||||
|
||||
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
|
||||
|
||||
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
|
||||
root := certtest.NewCA(t, "root")
|
||||
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
|
||||
|
||||
// Renewing a CA with the same key pair leaves two certificates with the same
|
||||
// subject and key in the store. The expired one sorts first here.
|
||||
expiredTmpl := *intermediate.Cert
|
||||
expiredTmpl.SerialNumber = big.NewInt(1)
|
||||
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
|
||||
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
|
||||
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
|
||||
require.NoError(t, err)
|
||||
expired, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := intermediate.Issue(t, key, "device")
|
||||
pool := []*x509.Certificate{expired, intermediate.Cert}
|
||||
|
||||
chain := buildChain(leaf, pool)
|
||||
require.Len(t, chain, 2)
|
||||
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
|
||||
|
||||
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
|
||||
|
||||
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
|
||||
verified, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
|
||||
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
|
||||
"management's own check accepts the chain the proof carries")
|
||||
}
|
||||
|
||||
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
|
||||
rootA := certtest.NewCA(t, "root-a")
|
||||
rootB := certtest.NewCA(t, "root-b")
|
||||
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
|
||||
|
||||
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
|
||||
crossTmpl := *issuer.Cert
|
||||
crossTmpl.SerialNumber = big.NewInt(2)
|
||||
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
|
||||
require.NoError(t, err)
|
||||
cross, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := certtest.ECDSAKey(t)
|
||||
leaf := issuer.Issue(t, key, "device")
|
||||
pool := []*x509.Certificate{issuer.Cert, cross}
|
||||
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
challenges := []*proto.CertificateChallenge{
|
||||
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
|
||||
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
|
||||
}
|
||||
|
||||
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
|
||||
|
||||
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
|
||||
for _, root := range []*certtest.CA{rootA, rootB} {
|
||||
matched := false
|
||||
for _, p := range proofs {
|
||||
chain, err := challenger.Verify(p, peerKey, now)
|
||||
require.NoError(t, err)
|
||||
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
|
||||
}
|
||||
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const helperTimeout = 30 * time.Second
|
||||
|
||||
// CollectProofs answers the certificate challenges in checks from every store this
|
||||
// machine can reach. The service reads the local machine store itself, where AD and
|
||||
// Intune enrol device certificates, and reaches the signed-in user's store by launching
|
||||
// a helper with that session's token. A machine at the sign-in screen therefore proves
|
||||
// device certificates alone.
|
||||
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
challenges := certificateChallenges(checks)
|
||||
if len(challenges) == 0 {
|
||||
logNoChallenges(checks)
|
||||
return nil
|
||||
}
|
||||
|
||||
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
|
||||
|
||||
// The helper already runs as the signed-in user, and an ordinary process has no
|
||||
// right to a session token, so only the service goes looking for one.
|
||||
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||
return proofs
|
||||
}
|
||||
|
||||
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
|
||||
if err != nil {
|
||||
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
|
||||
}
|
||||
return mergeProofs(proofs, userProofs)
|
||||
}
|
||||
|
||||
// UserContext identifies the session whose store a collection would include: a session
|
||||
// of the profile owner, or empty when no user store would be asked. A change means a
|
||||
// collection made earlier no longer reflects what this machine can prove.
|
||||
func UserContext(cfg Config) string {
|
||||
if !runningAsLocalSystem() || cfg.OwnerUnknown {
|
||||
return ""
|
||||
}
|
||||
user, ok := currentDesktopUser(cfg.ProfileOwner)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
defer user.Close()
|
||||
return fmt.Sprintf("%d:%s", user.Session, user.Name)
|
||||
}
|
||||
|
||||
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
|
||||
// that user's store rather than the machine store the service already read.
|
||||
func helperStore() Store {
|
||||
return newUserStore()
|
||||
}
|
||||
|
||||
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
|
||||
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
|
||||
// directly, so the session token goes straight into the child process.
|
||||
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
|
||||
user, ok := currentDesktopUser(owner)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
defer user.Close()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve own binary: %w", err)
|
||||
}
|
||||
|
||||
// The user's own environment, not the service's: the service environment may carry
|
||||
// secrets such as a setup key that the signed-in user must not be able to read.
|
||||
env, err := user.Token.Environ(false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(user.Token),
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.CREATE_NO_WINDOW,
|
||||
}
|
||||
|
||||
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
|
||||
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
|
||||
}
|
||||
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
|
||||
return proofs, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
collectTimeout = 45 * time.Second
|
||||
|
||||
// lostAfter is how many deadlines an abandoned collection is waited for before a new
|
||||
// one may start beside it. A store that is merely slow finishes well within that and
|
||||
// releases its slot; only one wedged for good is worked around.
|
||||
lostAfter = 10
|
||||
|
||||
// maxInFlight bounds how many abandoned collections may pile up. A store that never
|
||||
// answers would otherwise leak a goroutine on every collection for the life of the
|
||||
// daemon.
|
||||
maxInFlight = 2
|
||||
)
|
||||
|
||||
// collectSlots tracks the collections running in the process. Collectors share one by
|
||||
// default: a collection abandoned in a token, TPM or keychain call keeps running after
|
||||
// the engine that started it has stopped, and the next engine must account for it.
|
||||
type collectSlots struct {
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
// running holds the start time of each collection still running, by slot id.
|
||||
running map[uint64]time.Time
|
||||
}
|
||||
|
||||
var processSlots collectSlots
|
||||
|
||||
// Collector runs CollectProofs with a deadline and, in the normal case, one collection
|
||||
// at a time in the process. Token, TPM and keychain calls cannot be interrupted, and on
|
||||
// Linux there is not even a process to kill, so a collection that overruns is abandoned
|
||||
// rather than awaited. A single wedged call would then refuse every later collection for
|
||||
// the life of the daemon, so once an abandoned collection has been running for lostAfter
|
||||
// deadlines another may start beside it, up to maxInFlight. The zero value is ready to
|
||||
// use.
|
||||
type Collector struct {
|
||||
// slots overrides the process-wide slots when set.
|
||||
slots *collectSlots
|
||||
// timeout overrides collectTimeout when set.
|
||||
timeout time.Duration
|
||||
// now overrides the clock when set.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Collect answers the certificate challenges in checks, returning no proofs when there
|
||||
// are no challenges, when previous collections still hold the slots, or when this one
|
||||
// does not finish in time. Missing proofs fail the certificate check on management.
|
||||
func (c *Collector) Collect(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
|
||||
return c.collect(ctx, checks, func(ctx context.Context) []certposture.Proof {
|
||||
return CollectProofs(ctx, checks, peerKey, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Collector) collect(ctx context.Context, checks []*proto.Checks, run func(context.Context) []certposture.Proof) []certposture.Proof {
|
||||
if len(certificateChallenges(checks)) == 0 {
|
||||
return nil
|
||||
}
|
||||
slots := c.slotsInUse()
|
||||
slot, ok := slots.start(c.clock(), time.Duration(lostAfter)*c.deadline())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.deadline())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan []certposture.Proof, 1)
|
||||
go func() {
|
||||
// The slot is freed before the result is delivered, so a caller that starts the
|
||||
// next collection right after this one returned is not turned away.
|
||||
proofs := run(ctx)
|
||||
slots.finish(slot)
|
||||
done <- proofs
|
||||
}()
|
||||
|
||||
select {
|
||||
case proofs := <-done:
|
||||
return proofs
|
||||
case <-ctx.Done():
|
||||
log.Warnf("certificate posture: proof collection did not finish within %s, sending no proofs", c.deadline())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stuck reports whether lost collections hold every slot. No collection starts again
|
||||
// until one of them returns, which for a store that never answers means a restart.
|
||||
func (c *Collector) Stuck() bool {
|
||||
return c.slotsInUse().allLost(c.clock(), time.Duration(lostAfter)*c.deadline())
|
||||
}
|
||||
|
||||
func (c *Collector) slotsInUse() *collectSlots {
|
||||
if c.slots != nil {
|
||||
return c.slots
|
||||
}
|
||||
return &processSlots
|
||||
}
|
||||
|
||||
func (c *Collector) clock() time.Time {
|
||||
if c.now != nil {
|
||||
return c.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *Collector) deadline() time.Duration {
|
||||
if c.timeout > 0 {
|
||||
return c.timeout
|
||||
}
|
||||
return collectTimeout
|
||||
}
|
||||
|
||||
// start claims a slot at now, reporting its id and whether the caller may collect. A
|
||||
// collection that has held a slot for lost is taken to be wedged.
|
||||
func (s *collectSlots) start(now time.Time, lost time.Duration) (uint64, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.running) > 0 {
|
||||
oldest := s.oldest()
|
||||
switch {
|
||||
case len(s.running) >= maxInFlight:
|
||||
log.Warnf("certificate posture: %d proof collections are wedged, sending no proofs", len(s.running))
|
||||
return 0, false
|
||||
case now.Sub(oldest) < lost:
|
||||
log.Warnf("certificate posture: previous proof collection is still running, sending no proofs")
|
||||
return 0, false
|
||||
default:
|
||||
log.Warnf("certificate posture: a proof collection has been stuck since %s, starting another", oldest.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
if s.running == nil {
|
||||
s.running = make(map[uint64]time.Time, maxInFlight)
|
||||
}
|
||||
s.next++
|
||||
s.running[s.next] = now
|
||||
return s.next, true
|
||||
}
|
||||
|
||||
// finish releases the slot with the given id.
|
||||
func (s *collectSlots) finish(slot uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.running, slot)
|
||||
}
|
||||
|
||||
// allLost reports whether every slot is held by a collection that has run for lost.
|
||||
func (s *collectSlots) allLost(now time.Time, lost time.Duration) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.running) < maxInFlight {
|
||||
return false
|
||||
}
|
||||
for _, started := range s.running {
|
||||
if now.Sub(started) < lost {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// idle reports whether no collection is running.
|
||||
func (s *collectSlots) idle() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.running) == 0
|
||||
}
|
||||
|
||||
// oldest returns the start time of the longest-running collection. The caller holds mu
|
||||
// and has checked that one is running.
|
||||
func (s *collectSlots) oldest() time.Time {
|
||||
var oldest time.Time
|
||||
for _, started := range s.running {
|
||||
if oldest.IsZero() || started.Before(oldest) {
|
||||
oldest = started
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
|
||||
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
|
||||
}}}
|
||||
|
||||
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
|
||||
var c Collector
|
||||
called := false
|
||||
|
||||
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.Nil(t, proofs)
|
||||
assert.False(t, called, "no store is touched when no check carries a challenge")
|
||||
}
|
||||
|
||||
func TestCollector_ReturnsProofs(t *testing.T) {
|
||||
var c Collector
|
||||
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||
|
||||
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
|
||||
|
||||
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
|
||||
}
|
||||
|
||||
func TestCollector_AbandonsStuckCollection(t *testing.T) {
|
||||
c := Collector{timeout: 50 * time.Millisecond, slots: &collectSlots{}}
|
||||
release := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
|
||||
// A token or keychain call that ignores its context and blocks well past the deadline.
|
||||
stuck := func(context.Context) []certposture.Proof {
|
||||
defer close(finished)
|
||||
<-release
|
||||
return []certposture.Proof{{Nonce: []byte("late")}}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
proofs := c.collect(context.Background(), challengeChecks, stuck)
|
||||
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
|
||||
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
|
||||
|
||||
called := false
|
||||
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
assert.Nil(t, proofs)
|
||||
assert.False(t, called, "no second collection starts while the first is still running")
|
||||
|
||||
close(release)
|
||||
<-finished
|
||||
require.Eventually(t, func() bool { return c.slots.idle() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
|
||||
|
||||
want := []certposture.Proof{{Nonce: []byte("nonce")}}
|
||||
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
|
||||
"collection works again after the stuck call returned")
|
||||
}
|
||||
|
||||
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
|
||||
c := Collector{timeout: 20 * time.Millisecond, slots: &collectSlots{}}
|
||||
cancelled := make(chan struct{})
|
||||
|
||||
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
|
||||
<-ctx.Done()
|
||||
close(cancelled)
|
||||
return nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-cancelled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
|
||||
// collection stuck in a token call outlives the engine that started it, so the next
|
||||
// engine's Collector must not start another one until it has finished.
|
||||
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
|
||||
shared := &collectSlots{}
|
||||
first := Collector{timeout: 20 * time.Millisecond, slots: shared}
|
||||
second := Collector{timeout: time.Second, slots: shared}
|
||||
|
||||
release := make(chan struct{})
|
||||
stuck := func(context.Context) []certposture.Proof {
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
|
||||
|
||||
called := false
|
||||
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
|
||||
called = true
|
||||
return []certposture.Proof{{}}
|
||||
})
|
||||
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
|
||||
assert.False(t, called, "a second collection does not start on top of the abandoned one")
|
||||
|
||||
close(release)
|
||||
require.Eventually(t, func() bool { return shared.idle() }, time.Second, 5*time.Millisecond)
|
||||
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
|
||||
"collections resume once the abandoned one finished")
|
||||
}
|
||||
|
||||
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
|
||||
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
|
||||
var a, b Collector
|
||||
assert.Same(t, a.slotsInUse(), b.slotsInUse(), "separate Collectors share the process slots")
|
||||
}
|
||||
|
||||
// TestCollector_LostCollectionDoesNotBlockForever: a store call that never answers, such
|
||||
// as a wedged token on Linux where there is no process to kill, must not refuse every
|
||||
// later collection. Once it has run for lostAfter deadlines another starts beside it,
|
||||
// and no more than maxInFlight ever run.
|
||||
func TestCollector_LostCollectionDoesNotBlockForever(t *testing.T) {
|
||||
now := time.Now()
|
||||
slots := &collectSlots{}
|
||||
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var started atomic.Int32
|
||||
wedged := func(context.Context) []certposture.Proof {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
waitStarted(t, &started, 1)
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
assert.Equal(t, int32(1), started.Load(), "a second collection is refused while the first may still finish")
|
||||
assert.False(t, c.Stuck(), "one slow collection is not stuck yet")
|
||||
|
||||
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
waitStarted(t, &started, 2)
|
||||
assert.False(t, c.Stuck(), "the second collection may still return, so the collector is not stuck yet")
|
||||
|
||||
now = now.Add(time.Duration(lostAfter) * c.deadline())
|
||||
c.collect(context.Background(), challengeChecks, wedged)
|
||||
assert.Equal(t, int32(2), started.Load(), "no more than maxInFlight collections run")
|
||||
assert.True(t, c.Stuck(), "with every slot held by a lost collection the collector reports itself stuck")
|
||||
}
|
||||
|
||||
// TestCollector_LostWindowFollowsTheOldestRunningCollection: A wedges, B starts beside it
|
||||
// once A counts as lost, then A returns. B is recent, so a third collection must wait for
|
||||
// B to be lost in its own right rather than inherit A's start time.
|
||||
func TestCollector_LostWindowFollowsTheOldestRunningCollection(t *testing.T) {
|
||||
now := time.Now()
|
||||
slots := &collectSlots{}
|
||||
c := Collector{timeout: 10 * time.Millisecond, slots: slots, now: func() time.Time { return now }}
|
||||
lost := time.Duration(lostAfter) * c.deadline()
|
||||
|
||||
releaseA := make(chan struct{})
|
||||
releaseB := make(chan struct{})
|
||||
defer close(releaseB)
|
||||
var started atomic.Int32
|
||||
blockOn := func(release chan struct{}) func(context.Context) []certposture.Proof {
|
||||
return func(context.Context) []certposture.Proof {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
c.collect(context.Background(), challengeChecks, blockOn(releaseA))
|
||||
waitStarted(t, &started, 1)
|
||||
now = now.Add(lost)
|
||||
c.collect(context.Background(), challengeChecks, blockOn(releaseB))
|
||||
waitStarted(t, &started, 2)
|
||||
|
||||
close(releaseA)
|
||||
require.Eventually(t, func() bool {
|
||||
slots.mu.Lock()
|
||||
defer slots.mu.Unlock()
|
||||
return len(slots.running) == 1
|
||||
}, time.Second, 5*time.Millisecond, "A frees its slot once it returns")
|
||||
|
||||
c.collect(context.Background(), challengeChecks, blockOn(make(chan struct{})))
|
||||
assert.Equal(t, int32(2), started.Load(), "a third collection waits while B is recent")
|
||||
}
|
||||
|
||||
// waitStarted waits for the collections the test launched to have started. collect may
|
||||
// return at its deadline before the goroutine running the store call was scheduled.
|
||||
func waitStarted(t *testing.T, started *atomic.Int32, n int32) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool { return started.Load() == n }, time.Second, time.Millisecond,
|
||||
"%d collections should have started", n)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
|
||||
|
||||
encodingUTF8 = 0x08000100
|
||||
consoleNameBufSize = 256
|
||||
)
|
||||
|
||||
var (
|
||||
consoleOnce sync.Once
|
||||
consoleErr error
|
||||
|
||||
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
|
||||
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
|
||||
)
|
||||
|
||||
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
|
||||
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
|
||||
type ConsoleUser struct {
|
||||
Name string
|
||||
UID uint32
|
||||
GID uint32
|
||||
}
|
||||
|
||||
// currentConsoleUser reports the user sitting at the desktop. The second return value is
|
||||
// false when nobody is: at the login window macOS either reports no console user at all
|
||||
// or attributes the session to root, and neither has a login keychain to offer.
|
||||
func currentConsoleUser() (ConsoleUser, bool) {
|
||||
if err := loadConsoleUser(); err != nil {
|
||||
log.Debugf("console user lookup unavailable: %v", err)
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
|
||||
var uid, gid uint32
|
||||
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
|
||||
if name == 0 {
|
||||
log.Debug("no console user is logged in, no login keychain is reachable")
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
defer release(name)
|
||||
|
||||
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
|
||||
if !user.hasDesktop() {
|
||||
log.Debugf("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
|
||||
return ConsoleUser{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// hasDesktop reports whether the console session is a real user desktop. The login
|
||||
// window runs as root and some macOS releases name it "loginwindow" instead.
|
||||
func (u ConsoleUser) hasDesktop() bool {
|
||||
switch u.Name {
|
||||
case "", "root", "loginwindow":
|
||||
return false
|
||||
}
|
||||
return u.UID != 0
|
||||
}
|
||||
|
||||
// isOwner reports whether the console user is owner, the account of the active profile,
|
||||
// which is recorded as a short user name or, for an account without one, a numeric uid.
|
||||
// With no owner the console user counts, as macOS has a single console user.
|
||||
func (u ConsoleUser) isOwner(owner string) bool {
|
||||
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
|
||||
}
|
||||
|
||||
func cfString(str uintptr) string {
|
||||
buf := make([]byte, consoleNameBufSize)
|
||||
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
|
||||
return ""
|
||||
}
|
||||
if end := bytes.IndexByte(buf, 0); end >= 0 {
|
||||
return string(buf[:end])
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
|
||||
// first because CFRelease is resolved there and released strings depend on it.
|
||||
func loadConsoleUser() error {
|
||||
if err := loadKeychain(); err != nil {
|
||||
return err
|
||||
}
|
||||
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
|
||||
return consoleErr
|
||||
}
|
||||
|
||||
func resolveConsoleUser() error {
|
||||
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
|
||||
}
|
||||
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
||||
}
|
||||
|
||||
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
|
||||
}
|
||||
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
|
||||
|
||||
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve CFStringGetCString: %w", err)
|
||||
}
|
||||
purego.RegisterFunc(&cfStringGetCString, symbol)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !ios
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user ConsoleUser
|
||||
desktop bool
|
||||
}{
|
||||
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
|
||||
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
|
||||
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
|
||||
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
|
||||
{"no console user", ConsoleUser{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// currentConsoleUser runs against the real SystemConfiguration framework. A machine with
|
||||
// a desktop open must report a non-root user; a headless runner must report none.
|
||||
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
|
||||
user, ok := currentConsoleUser()
|
||||
if !ok {
|
||||
t.Log("no console user, running headless")
|
||||
return
|
||||
}
|
||||
assert.NotEmpty(t, user.Name, "a console user must have a name")
|
||||
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
|
||||
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
|
||||
}
|
||||
|
||||
func TestConsoleUser_IsOwner(t *testing.T) {
|
||||
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
|
||||
|
||||
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
|
||||
assert.True(t, user.isOwner("maycon"), "the owner by short name")
|
||||
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
|
||||
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
|
||||
assert.False(t, user.isOwner("502"), "another uid is another account")
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package certproof answers certificate posture challenges: it finds the certificates a
|
||||
// peer holds a private key for and signs the challenge nonce, bound to the peer's
|
||||
// WireGuard key, with that key. Management verifies the signature and the chain against
|
||||
// the CAs of the check, so only a holder of the key passes and a proof made for one peer
|
||||
// cannot be replayed by another. Keys in the OS stores, the TPM and a PKCS#11 token are
|
||||
// used through the platform, which signs; a plain PEM key file is the exception, parsed
|
||||
// and used in the daemon's memory.
|
||||
//
|
||||
// Machine stores are read by the daemon itself: the Windows LocalMachine store, the macOS
|
||||
// System keychain, and on Linux a directory of PEM files, TSS2 key files the TPM signs
|
||||
// with, and a PKCS#11 token. On Linux the directory and the token URI come from the
|
||||
// daemon's environment (NB_CERT_STORE_DIR, NB_CERT_PKCS11_URI), as does the token PIN
|
||||
// (NB_TPM_PIN); none of them is read from the profile config.
|
||||
//
|
||||
// User stores cannot be read by a privileged daemon, so it starts this binary as
|
||||
// "netbird posture cert-proof" inside the user's session and receives only signatures
|
||||
// and chains on its stdout. On macOS uid is not what unlocks a login keychain, the
|
||||
// session's bootstrap namespace is, which is why the helper enters it through launchctl
|
||||
// asuser. On Windows a service opening CURRENT_USER silently reads its own empty hive, so
|
||||
// the helper runs with the session's token instead. Its output is untrusted: sizes are
|
||||
// capped and only proofs for requested nonces are kept.
|
||||
//
|
||||
// A user proof shows that some process in that user's session could use a key whose
|
||||
// certificate chains to the CA. It does not show which binary signed, or that the key is
|
||||
// hardware-bound; device stores are the stronger signal.
|
||||
package certproof
|
||||
@@ -0,0 +1,66 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// HelperRequest is the work the daemon hands to a helper running in a user session. The
|
||||
// peer key binds every signature to this machine, so a proof cannot be replayed onto
|
||||
// another peer.
|
||||
type HelperRequest struct {
|
||||
PeerKey []byte `json:"peerKey"`
|
||||
Challenges []HelperChallenge `json:"challenges"`
|
||||
}
|
||||
|
||||
// HelperChallenge is one certificate challenge in the form the helper needs.
|
||||
type HelperChallenge struct {
|
||||
Nonce []byte `json:"nonce"`
|
||||
CACertificates []string `json:"caCertificates"`
|
||||
}
|
||||
|
||||
// HelperResponse carries the proofs the helper produced from its own keychain.
|
||||
type HelperResponse struct {
|
||||
Proofs []certposture.Proof `json:"proofs"`
|
||||
}
|
||||
|
||||
// RunHelper answers the challenges on in from the store of the user running this
|
||||
// process and writes the proofs to out. It is the child half of the console user
|
||||
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
|
||||
// session instead. Only the signature crosses back, never the private key.
|
||||
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
return runHelper(ctx, helperStore(), in, out)
|
||||
}
|
||||
|
||||
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
|
||||
var req HelperRequest
|
||||
if err := json.NewDecoder(in).Decode(&req); err != nil {
|
||||
return fmt.Errorf("decode helper request: %w", err)
|
||||
}
|
||||
|
||||
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
|
||||
for _, challenge := range req.Challenges {
|
||||
challenges = append(challenges, &proto.CertificateChallenge{
|
||||
Nonce: challenge.Nonce,
|
||||
CaCertificates: challenge.CACertificates,
|
||||
})
|
||||
}
|
||||
|
||||
var proofs []certposture.Proof
|
||||
if len(challenges) > 0 {
|
||||
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
|
||||
}
|
||||
log.Debugf("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
|
||||
|
||||
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
|
||||
return fmt.Errorf("encode helper response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
|
||||
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
|
||||
// every collection would put up a new one each time the user ignored or denied the last.
|
||||
const helperQuietPeriod = time.Hour
|
||||
|
||||
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
|
||||
// is not launched again because its last run proved nothing.
|
||||
type helperBackoff struct {
|
||||
mu sync.Mutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
func newHelperBackoff() *helperBackoff {
|
||||
return &helperBackoff{until: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// allow reports whether the helper may be launched for key now.
|
||||
func (b *helperBackoff) allow(key string, now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return !now.Before(b.until[key])
|
||||
}
|
||||
|
||||
// record stores the outcome of a helper run for key: one that proved something clears
|
||||
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
|
||||
// map holds only users currently held off.
|
||||
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for other, until := range b.until {
|
||||
if !now.Before(until) {
|
||||
delete(b.until, other)
|
||||
}
|
||||
}
|
||||
if proven {
|
||||
delete(b.until, key)
|
||||
return
|
||||
}
|
||||
b.until[key] = now.Add(helperQuietPeriod)
|
||||
}
|
||||
|
||||
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
|
||||
// order, leaving out the nonces, which rotate without changing what the user is asked to
|
||||
// prove.
|
||||
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
|
||||
sets := make([]string, 0, len(challenges))
|
||||
for _, challenge := range challenges {
|
||||
cas := make([]string, 0, len(challenge.GetCaCertificates()))
|
||||
for _, ca := range challenge.GetCaCertificates() {
|
||||
sum := sha256.Sum256([]byte(ca))
|
||||
cas = append(cas, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
slices.Sort(cas)
|
||||
sets = append(sets, strings.Join(cas, ","))
|
||||
}
|
||||
slices.Sort(sets)
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(user))
|
||||
for _, set := range sets {
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(set))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
|
||||
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
|
||||
// success or other CAs are asked at once.
|
||||
func TestHelperBackoff(t *testing.T) {
|
||||
now := time.Now()
|
||||
b := newHelperBackoff()
|
||||
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
|
||||
|
||||
assert.True(t, b.allow(key, now), "a user never asked is asked")
|
||||
|
||||
b.record(key, false, now)
|
||||
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
|
||||
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
|
||||
|
||||
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
|
||||
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
|
||||
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
|
||||
|
||||
b.record(key, true, now)
|
||||
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
|
||||
}
|
||||
|
||||
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
|
||||
// the user is asked to prove, so they do not reset the quiet period.
|
||||
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
|
||||
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
|
||||
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
|
||||
assert.Equal(t, a, b, "the key does not depend on the nonce")
|
||||
}
|
||||
|
||||
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
|
||||
// challenges reordered, are the same question to the user.
|
||||
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
|
||||
a := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||
{CaCertificates: []string{"ca-1", "ca-2"}},
|
||||
{CaCertificates: []string{"ca-3"}},
|
||||
})
|
||||
b := helperBackoffKey("501", []*proto.CertificateChallenge{
|
||||
{CaCertificates: []string{"ca-3"}},
|
||||
{CaCertificates: []string{"ca-2", "ca-1"}},
|
||||
})
|
||||
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
|
||||
|
||||
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
|
||||
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
|
||||
}
|
||||
|
||||
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
|
||||
// next outcome is recorded, so the map does not grow with every user and CA set seen.
|
||||
func TestHelperBackoff_PrunesExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
b := newHelperBackoff()
|
||||
b.record("old", false, now)
|
||||
b.record("new", false, now.Add(helperQuietPeriod))
|
||||
|
||||
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
|
||||
assert.Contains(t, b.until, "new", "a current entry is kept")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package certproof
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// startHelper starts cmd.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
return func() {}, cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
|
||||
// group when cmd's context ends. The helper may run below launchers such as launchctl
|
||||
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
|
||||
// only the direct child would leave the helper running and Wait blocked until the
|
||||
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
|
||||
func killHelperGroupOnCancel(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.Cancel = func() error {
|
||||
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.WaitDelay = helperWaitDelay
|
||||
}
|
||||
|
||||
// startHelper starts cmd. Killing the helper with everything below it is arranged by
|
||||
// killHelperGroupOnCancel where the helper runs under a launcher.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
return func() {}, cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
|
||||
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
|
||||
// only the helper on Windows leaves its descendants running, and one that holds the
|
||||
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
|
||||
func startHelper(cmd *exec.Cmd) (func(), error) {
|
||||
job, err := newKillOnCloseJob()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeJob := func() {
|
||||
if err := windows.CloseHandle(job); err != nil {
|
||||
log.Debugf("failed to close certificate proof helper job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Cancel = func() error {
|
||||
return windows.TerminateJobObject(job, 1)
|
||||
}
|
||||
cmd.WaitDelay = helperWaitDelay
|
||||
if err := cmd.Start(); err != nil {
|
||||
closeJob()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A helper outside the job is still killed directly and bounded by WaitDelay; only
|
||||
// its descendants would outlive it.
|
||||
if err := assignToJob(job, cmd.Process.Pid); err != nil {
|
||||
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
|
||||
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||
}
|
||||
return closeJob, nil
|
||||
}
|
||||
|
||||
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
|
||||
// is closed.
|
||||
func newKillOnCloseJob() (windows.Handle, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, fmt.Errorf("set job object limits: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// assignToJob puts the process pid into job.
|
||||
func assignToJob(job windows.Handle, pid int) error {
|
||||
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open process: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = windows.CloseHandle(process)
|
||||
}()
|
||||
return windows.AssignProcessToJobObject(job, process)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
|
||||
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
|
||||
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
|
||||
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
|
||||
|
||||
start := time.Now()
|
||||
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
const (
|
||||
maxHelperStdout = 1 << 20
|
||||
maxHelperStderr = 4 << 10
|
||||
|
||||
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
|
||||
helperWaitDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
|
||||
|
||||
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
|
||||
// The helper runs as an unprivileged user who can control its output, so both streams
|
||||
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
|
||||
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode helper request: %w", err)
|
||||
}
|
||||
|
||||
stdout := &cappedBuffer{limit: maxHelperStdout}
|
||||
stderr := &cappedBuffer{limit: maxHelperStderr}
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
release, err := startHelper(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start helper: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
logHelperStderr(stderr.String())
|
||||
if stdout.truncated {
|
||||
return nil, errHelperOutputTooLarge
|
||||
}
|
||||
|
||||
var resp HelperResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode helper response: %w", err)
|
||||
}
|
||||
return requestedProofs(req, resp.Proofs), nil
|
||||
}
|
||||
|
||||
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
|
||||
// certificate it could not sign with, which is otherwise only visible in the user's
|
||||
// session. Each line is quoted because the helper's user controls its content.
|
||||
func logHelperStderr(stderr string) {
|
||||
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
log.Debugf("certificate posture helper: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
|
||||
// most as many as req has challenges.
|
||||
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
|
||||
var kept []certposture.Proof
|
||||
for _, proof := range proofs {
|
||||
if len(kept) == len(req.Challenges) {
|
||||
break
|
||||
}
|
||||
if !req.asked(proof.Nonce) {
|
||||
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
|
||||
continue
|
||||
}
|
||||
kept = append(kept, proof)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func (r HelperRequest) asked(nonce []byte) bool {
|
||||
for _, challenge := range r.Challenges {
|
||||
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
|
||||
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
|
||||
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
|
||||
// which io.Copy prefers over Write, bypassing the cap.
|
||||
type cappedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
limit int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||
if room := b.limit - b.buf.Len(); room < len(p) {
|
||||
b.truncated = true
|
||||
if room > 0 {
|
||||
b.buf.Write(p[:room])
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) Bytes() []byte {
|
||||
return b.buf.Bytes()
|
||||
}
|
||||
|
||||
func (b *cappedBuffer) String() string {
|
||||
return b.buf.String()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build !windows && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
// fakeHelper is a child process that drains its stdin and then prints script's output,
|
||||
// standing in for a helper running in a user session the daemon cannot trust.
|
||||
func fakeHelper(t *testing.T, script string) *exec.Cmd {
|
||||
t.Helper()
|
||||
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
|
||||
}
|
||||
|
||||
func printJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
out, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return fmt.Sprintf("printf '%%s' '%s'", out)
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
|
||||
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
resp := HelperResponse{Proofs: []certposture.Proof{
|
||||
{Nonce: []byte("injected"), Signature: []byte("x")},
|
||||
{Nonce: []byte("asked"), Signature: []byte("first")},
|
||||
{Nonce: []byte("asked"), Signature: []byte("second")},
|
||||
}}
|
||||
|
||||
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
|
||||
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
assert.ErrorIs(t, err, errHelperOutputTooLarge)
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
|
||||
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
|
||||
}
|
||||
|
||||
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
|
||||
// for one about a certificate it could not sign with: the warning reaches the daemon's
|
||||
// log, quoted so a helper cannot forge log lines with embedded newlines.
|
||||
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
|
||||
var logged bytes.Buffer
|
||||
log.SetOutput(&logged)
|
||||
level := log.GetLevel()
|
||||
log.SetLevel(log.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetLevel(level)
|
||||
})
|
||||
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, script), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
|
||||
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
|
||||
}
|
||||
|
||||
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
|
||||
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
|
||||
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
|
||||
// grandchild.
|
||||
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
|
||||
killHelperGroupOnCancel(cmd)
|
||||
|
||||
start := time.Now()
|
||||
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
|
||||
}
|
||||
|
||||
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
|
||||
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
||||
|
||||
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
|
||||
|
||||
assert.ErrorContains(t, err, "decode helper response")
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// errPINRejectedBefore is returned instead of logging in with a PIN the token already
|
||||
// refused: every failed login counts towards the token's lockout, which for a TPM is
|
||||
// shared with everything else on the machine, and proofs are collected on every sync.
|
||||
var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again")
|
||||
|
||||
// errPINNeedsToken refuses a PIN that names no token to log in to.
|
||||
var errPINNeedsToken = errors.New("a PKCS#11 PIN needs the token named in NB_CERT_PKCS11_URI, as token=<label>")
|
||||
|
||||
// rejectedPINs outlives a single store, since a store is built for each collection.
|
||||
var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}}
|
||||
|
||||
// pinLoginMu serializes logging in to a PKCS#11 token with a PIN, from checking the latch
|
||||
// to recording a rejection.
|
||||
var pinLoginMu sync.Mutex
|
||||
|
||||
// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not
|
||||
// kept in memory any longer than the store that read it.
|
||||
type pinLatch struct {
|
||||
mu sync.Mutex
|
||||
keys map[[sha256.Size]byte]struct{}
|
||||
}
|
||||
|
||||
func (l *pinLatch) has(key [sha256.Size]byte) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
_, ok := l.keys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (l *pinLatch) add(key [sha256.Size]byte) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.keys[key] = struct{}{}
|
||||
}
|
||||
|
||||
// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token
|
||||
// rejected is still tried on the token it belongs to.
|
||||
func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte {
|
||||
var buf []byte
|
||||
for _, part := range [][]byte{[]byte(module), []byte(token), pin} {
|
||||
buf = binary.BigEndian.AppendUint64(buf, uint64(len(part)))
|
||||
buf = append(buf, part...)
|
||||
}
|
||||
return sha256.Sum256(buf)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) {
|
||||
base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
|
||||
|
||||
assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another")
|
||||
assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another")
|
||||
assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")),
|
||||
"field boundaries are part of the key, so shifted fields do not collide")
|
||||
}
|
||||
|
||||
func TestPINLatch_RemembersRejectedKeys(t *testing.T) {
|
||||
latch := &pinLatch{keys: map[[32]byte]struct{}{}}
|
||||
rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000"))
|
||||
other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
|
||||
|
||||
assert.False(t, latch.has(rejected), "nothing is rejected before a login fails")
|
||||
latch.add(rejected)
|
||||
assert.True(t, latch.has(rejected), "a rejected PIN is not tried again")
|
||||
assert.False(t, latch.has(other), "other PINs are unaffected")
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
StoreDirEnv = "NB_CERT_STORE_DIR"
|
||||
defaultStoreDir = "/etc/netbird/certs"
|
||||
|
||||
// maxStoreFileSize bounds a certificate or key file of the PEM directory. A chain with
|
||||
// its key is a few kilobytes.
|
||||
maxStoreFileSize = 1 << 20
|
||||
)
|
||||
|
||||
// errKeyMismatch rejects a key that does not belong to the certificate it sits with: it
|
||||
// would sign a proof management can only reject, in place of a usable later candidate.
|
||||
var (
|
||||
errKeyMismatch = errors.New("private key does not match the certificate")
|
||||
errNoSiblingKey = errors.New("no key file next to the certificate")
|
||||
)
|
||||
|
||||
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
|
||||
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
|
||||
// path to a challenge's CAs can be found even where Chain followed a different issuer,
|
||||
// such as an expired copy of a renewed intermediate.
|
||||
type Candidate struct {
|
||||
Chain []*x509.Certificate
|
||||
Signer crypto.Signer
|
||||
Intermediates []*x509.Certificate
|
||||
}
|
||||
|
||||
// issuers is every certificate other than the leaf that a path may run through.
|
||||
func (c Candidate) issuers() []*x509.Certificate {
|
||||
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
|
||||
}
|
||||
|
||||
// Store yields the certificates a peer may prove possession of. fileStore is the PEM
|
||||
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
|
||||
type Store interface {
|
||||
Candidates(ctx context.Context) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// Config selects where the daemon looks for certificates. PKCS11 names a token whose keys
|
||||
// sign for certificates on the token or in the PEM directory, which NB_CERT_STORE_DIR
|
||||
// names on Linux, /etc/netbird/certs by default.
|
||||
//
|
||||
// ProfileOwner is the OS account the active profile belongs to. On macOS and Windows only
|
||||
// that account's certificate store is consulted for user certificates, so on a machine
|
||||
// with several people signed in the result does not depend on who else is logged in.
|
||||
// Empty means the profile has no owner, and only the user at the physical console counts.
|
||||
// OwnerUnknown means the owner could not be determined, and no user store is consulted:
|
||||
// guessing would let whoever sits at the console answer for the profile.
|
||||
type Config struct {
|
||||
PKCS11 PKCS11Config
|
||||
ProfileOwner string
|
||||
OwnerUnknown bool
|
||||
}
|
||||
|
||||
// fileStore reads PEM files from a directory. A file holds the chain (leaf first) and
|
||||
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
|
||||
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
|
||||
type fileStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func newFileStore(dir string) *fileStore {
|
||||
return &fileStore{dir: dir}
|
||||
}
|
||||
|
||||
func StoreDir() string {
|
||||
if dir := os.Getenv(StoreDirEnv); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return defaultStoreDir
|
||||
}
|
||||
|
||||
func (s *fileStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
paths, err := certFiles(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []Candidate
|
||||
for _, path := range paths {
|
||||
chain, signer, err := loadPEM(path)
|
||||
if err != nil {
|
||||
log.Warnf("skipping certificate %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if signer == nil {
|
||||
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// certFiles lists the certificate files in dir, none when the directory does not exist.
|
||||
// The directory is checked before it is listed, so one that others can write to is
|
||||
// refused before its entries are read.
|
||||
func certFiles(dir string) ([]string, error) {
|
||||
err := checkStoreDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refusing certificate store: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
|
||||
}
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isCertFile(entry.Name()) {
|
||||
paths = append(paths, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// loadPEM reads a certificate file and its private key, held in the file itself or in
|
||||
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
|
||||
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
|
||||
data, err := readStoreFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
chain, signer, err := parsePEM(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return nil, nil, errors.New("no certificate")
|
||||
}
|
||||
if signer == nil {
|
||||
signer, err = siblingKey(path)
|
||||
switch {
|
||||
case errors.Is(err, errNoSiblingKey):
|
||||
// A certificate with no key of its own: the token store pairs it later.
|
||||
return chain, nil, nil
|
||||
case err != nil:
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if !samePublicKey(signer.Public(), chain[0].PublicKey) {
|
||||
return nil, nil, errKeyMismatch
|
||||
}
|
||||
return chain, signer, nil
|
||||
}
|
||||
|
||||
// siblingKey reads the private key from the "<name>.key" file next to a certificate
|
||||
// file, reporting errNoSiblingKey when there is none.
|
||||
func siblingKey(path string) (crypto.Signer, error) {
|
||||
keyData, err := readStoreFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errNoSiblingKey
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key file: %w", err)
|
||||
}
|
||||
_, signer, err := parsePEM(keyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if signer == nil {
|
||||
return nil, errors.New("no private key in key file")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func samePublicKey(a, b crypto.PublicKey) bool {
|
||||
equaler, ok := a.(interface{ Equal(crypto.PublicKey) bool })
|
||||
return ok && equaler.Equal(b)
|
||||
}
|
||||
|
||||
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
|
||||
var chain []*x509.Certificate
|
||||
var signer crypto.Signer
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
return chain, signer, nil
|
||||
}
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tss2KeyPEMType:
|
||||
key, err := parsePrivateKey(block)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
signer = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
var key any
|
||||
var err error
|
||||
switch block.Type {
|
||||
case tss2KeyPEMType:
|
||||
return parseTSS2Key(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
key, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
default:
|
||||
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
signer, ok := key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, errors.New("private key cannot sign")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func isCertFile(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pem", ".crt", ".cer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stores queries several stores and carries on when one fails, so a broken token cannot
|
||||
// hide the certificates a directory holds. A failure is logged instead of returned
|
||||
// because Collect treats a store error as "no proofs at all".
|
||||
type Stores []Store
|
||||
|
||||
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
|
||||
var all []Candidate
|
||||
for _, store := range s {
|
||||
candidates, err := store.Candidates(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("certificate store %T unavailable: %v", store, err)
|
||||
continue
|
||||
}
|
||||
all = append(all, candidates...)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
)
|
||||
|
||||
var unsupportedTokenOnce sync.Once
|
||||
|
||||
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
|
||||
func DefaultStore() Store {
|
||||
return newFileStore(StoreDir())
|
||||
}
|
||||
|
||||
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
|
||||
// names one. The token pairs the directory's key-less certificates with its own keys.
|
||||
func storeWithToken(cfg Config) Store {
|
||||
files := newFileStore(StoreDir())
|
||||
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
|
||||
return files
|
||||
}
|
||||
if !pkcs11.Supported() {
|
||||
unsupportedTokenOnce.Do(func() {
|
||||
log.Warnf("ignoring the configured PKCS#11 token: %v", pkcs11.ErrUnsupported)
|
||||
})
|
||||
return files
|
||||
}
|
||||
token, err := newPKCS11Store(cfg.PKCS11, StoreDir())
|
||||
if err != nil {
|
||||
log.Warnf("ignoring PKCS#11 URI: %v", err)
|
||||
return files
|
||||
}
|
||||
return Stores{files, token}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build ((!darwin && !windows) || ios) && !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pkcs11"
|
||||
)
|
||||
|
||||
func TestStoreWithToken(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
t.Setenv(StoreDirEnv, dir)
|
||||
|
||||
files, ok := storeWithToken(Config{}).(*fileStore)
|
||||
require.True(t, ok, "no token configured reads the PEM directory alone")
|
||||
assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR")
|
||||
|
||||
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
|
||||
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory")
|
||||
|
||||
configured := map[string]PKCS11Config{
|
||||
"env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"},
|
||||
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
|
||||
}
|
||||
if !pkcs11.Supported() {
|
||||
for name, cfg := range configured {
|
||||
assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: cfg}),
|
||||
"%s: a build without PKCS#11 support reads the PEM directory alone", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
for name, cfg := range configured {
|
||||
store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores)
|
||||
require.True(t, ok, "%s joins the token to the PEM directory", name)
|
||||
require.Len(t, store, 2, name)
|
||||
token, ok := store[1].(*pkcs11Store)
|
||||
require.True(t, ok, name)
|
||||
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-tpm/legacy/tpm2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm"
|
||||
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestFileStore_TPMKeyFile(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
leaf := ca.Issue(t, key, "device")
|
||||
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
|
||||
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
|
||||
|
||||
// A key that needs a password can never be used silently, so its certificate is skipped.
|
||||
locked := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
|
||||
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth()))
|
||||
|
||||
candidates, err := newFileStore(dir).Candidates(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
|
||||
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
|
||||
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
|
||||
}
|
||||
|
||||
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
|
||||
// swtpm started with:
|
||||
//
|
||||
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
|
||||
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
|
||||
//
|
||||
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
|
||||
// hierarchy, and proves the certificate for it through the regular file store.
|
||||
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
|
||||
if os.Getenv(tpm.DeviceEnv) == "" {
|
||||
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
|
||||
}
|
||||
public, private := createTPMKey(t)
|
||||
keyPEM := tpmtest.EncodePEM(t, public, private)
|
||||
block, _ := pem.Decode([]byte(keyPEM))
|
||||
require.NotNil(t, block)
|
||||
signer, err := tpm.ParseKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
ca := certtest.NewCA(t, "corp")
|
||||
leaf := ca.Issue(t, signer, "device")
|
||||
dir := storeDir(t)
|
||||
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
|
||||
writeFile(t, dir, "device.key", keyPEM)
|
||||
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
now := time.Now()
|
||||
nonce := challenger.Nonce(peerKey, now)
|
||||
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
|
||||
proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey)
|
||||
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
|
||||
chain, err := challenger.Verify(proofs[0], peerKey, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
|
||||
}
|
||||
|
||||
func createTPMKey(t *testing.T) (public, private []byte) {
|
||||
t.Helper()
|
||||
rwc, err := tpm.Open()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = rwc.Close() }()
|
||||
|
||||
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
|
||||
|
||||
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
|
||||
require.NoError(t, err)
|
||||
return public, private
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/tpm"
|
||||
)
|
||||
|
||||
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file.
|
||||
const tss2KeyPEMType = tpm.KeyPEMType
|
||||
|
||||
// parseTSS2Key returns a signer for a TPM-held key file that signs inside the TPM.
|
||||
func parseTSS2Key(der []byte) (crypto.Signer, error) {
|
||||
return tpm.ParseKey(der)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build js
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file. A browser has no TPM, so
|
||||
// the TPM library stays out of the WebAssembly build and such a key is refused.
|
||||
const tss2KeyPEMType = "TSS2 PRIVATE KEY"
|
||||
|
||||
func parseTSS2Key([]byte) (crypto.Signer, error) {
|
||||
return nil, errors.New("TPM keys are not supported in this build")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// readStoreFile reads a certificate or key file of the PEM directory, up to the size
|
||||
// limit. Ownership is checked on Unix only; elsewhere the PEM directory is not the store
|
||||
// the daemon reads by default.
|
||||
func readStoreFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(data) > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s is over the %d byte limit", path, maxStoreFileSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// checkStoreDir accepts any directory where the PEM directory is not a default store.
|
||||
func checkStoreDir(string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// readStoreFile reads a certificate or key file of the PEM directory as the daemon may
|
||||
// trust it: the path must not be a symlink and must be a regular file that only root or
|
||||
// this process's own user can write. Otherwise a user able to place or redirect a file
|
||||
// could make the daemon sign with a key of their choosing, or with a root-only key kept
|
||||
// elsewhere on the machine. The file is checked through the descriptor it is read from,
|
||||
// so it cannot be swapped between the check and the read.
|
||||
func readStoreFile(path string) ([]byte, error) {
|
||||
// O_NONBLOCK keeps a FIFO planted in the directory from blocking the open; the
|
||||
// regular-file check below then refuses it.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
if err := checkTrustedOwner(info); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if info.Size() > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", path, info.Size(), maxStoreFileSize)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(data) > maxStoreFileSize {
|
||||
return nil, fmt.Errorf("%s grew past the %d byte limit while being read", path, maxStoreFileSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// checkStoreDir refuses a PEM directory that someone other than root or this process's
|
||||
// user could add files to or rename files in. The configured path may be a symlink, as
|
||||
// distributions place certificate directories behind them, but only one root or this
|
||||
// process's user owns: otherwise whoever owns the link could point it at any directory.
|
||||
func checkStoreDir(dir string) error {
|
||||
link, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if link.Mode()&os.ModeSymlink != 0 {
|
||||
if err := checkTrustedUID(link); err != nil {
|
||||
return fmt.Errorf("symlink %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
if err := checkTrustedOwner(info); err != nil {
|
||||
return fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTrustedOwner requires info to belong to root or to this process's user and to be
|
||||
// writable by nobody else. Group write is refused even for root's group, which ordinary
|
||||
// users may be members of. An owner that cannot be read is refused.
|
||||
func checkTrustedOwner(info os.FileInfo) error {
|
||||
if err := checkTrustedUID(info); err != nil {
|
||||
return err
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o022 != 0 {
|
||||
return fmt.Errorf("writable by group or other users (mode %#o)", perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTrustedUID requires info to belong to root or to this process's user.
|
||||
func checkTrustedUID(info os.FileInfo) error {
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("owner cannot be determined")
|
||||
}
|
||||
if st.Uid != 0 && int(st.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("owned by uid %d, not by root", st.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build unix
|
||||
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
)
|
||||
|
||||
// devicePair writes a certificate and its key as separate files into dir.
|
||||
func devicePair(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
key := certtest.ECDSAKey(t)
|
||||
writeFile(t, dir, "device.crt", certtest.CertPEM(ca.Issue(t, key, "device")))
|
||||
writeFile(t, dir, "device.key", certtest.KeyPEM(t, key))
|
||||
}
|
||||
|
||||
func candidates(t *testing.T, dir string) ([]Candidate, error) {
|
||||
t.Helper()
|
||||
return newFileStore(dir).Candidates(context.Background())
|
||||
}
|
||||
|
||||
func TestFileStore_AcceptsAPrivatePair(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a certificate and key only their owner can write are used")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesASymlinkedKey(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
|
||||
// The real key moves elsewhere and the directory only links to it, as a user able
|
||||
// to write the directory would do to point the daemon at a key it should not use.
|
||||
elsewhere := filepath.Join(storeDir(t), "other.key")
|
||||
require.NoError(t, os.Rename(filepath.Join(dir, "device.key"), elsewhere))
|
||||
require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "device.key")))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a key reached through a symlink is never used")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesASymlinkedCertificateFile(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
other := storeDir(t)
|
||||
devicePair(t, other)
|
||||
require.NoError(t, os.Symlink(filepath.Join(other, "device.crt"), filepath.Join(dir, "device.crt")))
|
||||
require.NoError(t, os.Symlink(filepath.Join(other, "device.key"), filepath.Join(dir, "device.key")))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a certificate file that is a symlink is skipped")
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesFilesOthersCanWrite(t *testing.T) {
|
||||
for name, mode := range map[string]os.FileMode{"world-writable": 0o602, "group-writable": 0o620} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(filepath.Join(dir, "device.key"), mode))
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "a key %s may have been replaced by someone else, even when the group is root's", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_RefusesADirectoryOthersCanWrite(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777))
|
||||
|
||||
_, err := candidates(t, dir)
|
||||
assert.ErrorContains(t, err, "refusing certificate store", "files in a directory anyone can write are not trusted")
|
||||
}
|
||||
|
||||
func TestFileStore_SkipsAFIFOWithoutBlocking(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "trap.pem"), 0o600))
|
||||
devicePair(t, dir)
|
||||
|
||||
got, err := candidates(t, dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a FIFO is skipped and the real pair still loads")
|
||||
}
|
||||
|
||||
func TestReadStoreFile_RefusesOversizedFiles(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
path := filepath.Join(dir, "huge.pem")
|
||||
require.NoError(t, os.WriteFile(path, make([]byte, maxStoreFileSize+1), 0o600))
|
||||
|
||||
_, err := readStoreFile(path)
|
||||
assert.ErrorContains(t, err, "limit")
|
||||
}
|
||||
|
||||
func TestFileStore_AcceptsASymlinkedDirectoryItsOwnerControls(t *testing.T) {
|
||||
target := storeDir(t)
|
||||
devicePair(t, target)
|
||||
link := filepath.Join(storeDir(t), "certs")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
// The link belongs to this process's user, as one root creates belongs to root.
|
||||
got, err := candidates(t, link)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1, "a certificate directory behind a symlink the owner controls is read")
|
||||
}
|
||||
|
||||
func TestFileStore_ChecksTheDirectoryBeforeListingIt(t *testing.T) {
|
||||
dir := storeDir(t)
|
||||
devicePair(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777))
|
||||
|
||||
paths, err := certFiles(dir)
|
||||
assert.ErrorContains(t, err, "refusing certificate store")
|
||||
assert.Nil(t, paths, "nothing in a directory anyone can write to is listed")
|
||||
}
|
||||
|
||||
func TestFileStore_MissingDirectoryIsEmpty(t *testing.T) {
|
||||
paths, err := certFiles(filepath.Join(storeDir(t), "missing"))
|
||||
assert.NoError(t, err, "a store directory that does not exist yet is not an error")
|
||||
assert.Empty(t, paths)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
personalStore = "MY"
|
||||
intermediateStore = "CA"
|
||||
|
||||
cryptAcquireSilentFlag = 0x00000040
|
||||
cryptAcquirePreferNCryptKeyFlag = 0x00020000
|
||||
certNCryptKeySpec = 0xFFFFFFFF
|
||||
atKeyExchange = 1
|
||||
atSignature = 2
|
||||
bcryptPadPSS = 0x00000008
|
||||
)
|
||||
|
||||
// errLegacyKey reports a private key CNG cannot open: it lives in a legacy CryptoAPI
|
||||
// provider, which only offers PKCS#1 v1.5 and no RSA-PSS or ECDSA.
|
||||
var errLegacyKey = errors.New("private key is held by a legacy CryptoAPI provider, which certificate posture cannot sign with")
|
||||
|
||||
var (
|
||||
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
|
||||
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
|
||||
|
||||
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
|
||||
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
|
||||
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
|
||||
)
|
||||
|
||||
type bcryptPSSPaddingInfo struct {
|
||||
algID *uint16
|
||||
salt uint32
|
||||
}
|
||||
|
||||
// DefaultStore is the local machine's personal certificate store, where device
|
||||
// certificates enrolled through AD or Intune are kept.
|
||||
func DefaultStore() Store {
|
||||
return newSystemStore()
|
||||
}
|
||||
|
||||
// systemStore yields the identities of a personal certificate store, completing their
|
||||
// chains from the matching intermediate CA store. Keys are used through CNG and never
|
||||
// exported.
|
||||
//
|
||||
// The location decides whose certificates these are. The local machine store is the one
|
||||
// a service reads; the current user store lives in the signed-in user's registry hive
|
||||
// with keys protected against their profile, so it is only readable while running as
|
||||
// that user.
|
||||
type systemStore struct {
|
||||
location uint32
|
||||
}
|
||||
|
||||
// newSystemStore reads the local machine store, which is what the daemon uses.
|
||||
func newSystemStore() *systemStore {
|
||||
return &systemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
|
||||
}
|
||||
|
||||
// newUserStore reads the calling user's personal store. It is only useful in a process
|
||||
// already running as that user, which is what the posture helper is.
|
||||
func newUserStore() *systemStore {
|
||||
return &systemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
|
||||
}
|
||||
|
||||
func (s *systemStore) Candidates(_ context.Context) ([]Candidate, error) {
|
||||
leaves, err := storeCertificates(s.location, personalStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intermediates, err := storeCertificates(s.location, intermediateStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debugf("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
|
||||
if len(leaves) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pool := slices.Concat(intermediates, leaves)
|
||||
candidates := make([]Candidate, 0, len(leaves))
|
||||
for _, leaf := range leaves {
|
||||
chain := buildChain(leaf, pool)
|
||||
log.Debugf("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
|
||||
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}, Intermediates: pool})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// String names the store location the way the Windows documentation does.
|
||||
func (s *systemStore) String() string {
|
||||
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
|
||||
return "CurrentUser"
|
||||
}
|
||||
return "LocalMachine"
|
||||
}
|
||||
|
||||
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
|
||||
// at signing time so no handles outlive a call.
|
||||
type systemStoreSigner struct {
|
||||
leaf *x509.Certificate
|
||||
location uint32
|
||||
}
|
||||
|
||||
func (s *systemStoreSigner) Public() crypto.PublicKey {
|
||||
return s.leaf.PublicKey
|
||||
}
|
||||
|
||||
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
||||
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := openStore(s.location, personalStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = windows.CertCloseStore(store, 0) }()
|
||||
|
||||
var signature []byte
|
||||
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
|
||||
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
|
||||
return false, nil
|
||||
}
|
||||
signature, err = signWithContext(ctx, scheme, digest)
|
||||
return true, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if signature == nil {
|
||||
return nil, errors.New("certificate is no longer in the personal store")
|
||||
}
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
|
||||
var key uintptr
|
||||
var keySpec uint32
|
||||
var callerFree int32
|
||||
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
|
||||
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
|
||||
if ok == 0 {
|
||||
return nil, fmt.Errorf("acquire private key: %w", err)
|
||||
}
|
||||
if keySpec != certNCryptKeySpec {
|
||||
if callerFree != 0 {
|
||||
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
|
||||
}
|
||||
return nil, legacyKeyError(keySpec)
|
||||
}
|
||||
if callerFree != 0 {
|
||||
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
|
||||
}
|
||||
|
||||
var padding unsafe.Pointer
|
||||
var flags uintptr
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
algID, _ := windows.UTF16PtrFromString("SHA256")
|
||||
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
|
||||
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
|
||||
}
|
||||
size, err := ncryptSignHash(key, padding, digest, nil, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature := make([]byte, size)
|
||||
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature = signature[:size]
|
||||
if scheme == schemeRSAPSSSHA256 {
|
||||
return signature, nil
|
||||
}
|
||||
return ecdsaSignatureASN1(signature)
|
||||
}
|
||||
|
||||
// legacyKeyError describes a key acquired through CryptoAPI rather than CNG, naming its
|
||||
// key spec and the remedy, since the certificate otherwise looks usable in the store.
|
||||
func legacyKeyError(keySpec uint32) error {
|
||||
spec := fmt.Sprintf("key spec %d", keySpec)
|
||||
switch keySpec {
|
||||
case atKeyExchange:
|
||||
spec = "AT_KEYEXCHANGE"
|
||||
case atSignature:
|
||||
spec = "AT_SIGNATURE"
|
||||
}
|
||||
return fmt.Errorf("%w (%s); re-enrol it with a certificate template whose provider is a CNG key storage provider", errLegacyKey, spec)
|
||||
}
|
||||
|
||||
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
|
||||
var result uint32
|
||||
var signaturePtr uintptr
|
||||
if len(signature) > 0 {
|
||||
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
|
||||
}
|
||||
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
|
||||
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
|
||||
if uint32(status) != 0 {
|
||||
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
|
||||
store, err := openStore(location, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = windows.CertCloseStore(store, 0) }()
|
||||
|
||||
var certs []*x509.Certificate
|
||||
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
|
||||
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
|
||||
if err != nil {
|
||||
log.Warnf("skipping certificate in %s store: %v", name, err)
|
||||
return false, nil
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
return false, nil
|
||||
})
|
||||
return certs, err
|
||||
}
|
||||
|
||||
func openStore(location uint32, name string) (windows.Handle, error) {
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
|
||||
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
|
||||
var ctx *windows.CertContext
|
||||
for {
|
||||
next, err := windows.CertEnumCertificatesInStore(store, ctx)
|
||||
if next == nil {
|
||||
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("enumerate certificates: %w", err)
|
||||
}
|
||||
ctx = next
|
||||
if stop, err := fn(ctx); stop || err != nil {
|
||||
_ = windows.CertFreeCertificateContext(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodedCert(ctx *windows.CertContext) []byte {
|
||||
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package certproof
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLegacyKeyError_NamesTheKeySpecAndTheRemedy(t *testing.T) {
|
||||
for spec, want := range map[uint32]string{atKeyExchange: "AT_KEYEXCHANGE", atSignature: "AT_SIGNATURE", 7: "key spec 7"} {
|
||||
err := legacyKeyError(spec)
|
||||
assert.ErrorIs(t, err, errLegacyKey, "callers can tell a legacy key from other failures")
|
||||
assert.ErrorContains(t, err, want, "the key spec found is named")
|
||||
assert.ErrorContains(t, err, "key storage provider", "the error says how to fix the enrolment")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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
|
||||
@@ -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";
|
||||
@@ -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__); \
|
||||
})
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -88,7 +88,7 @@ func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.T
|
||||
return time.Time{}, errors.New("management client is not initialised")
|
||||
}
|
||||
|
||||
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||
info, err := system.GetInfoWithChecks(ctx, e.appliedChecks())
|
||||
if err != nil {
|
||||
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||
info = system.GetInfo(ctx)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// certContextPollInterval is how often the engine checks whether the user who can
|
||||
// answer certificate challenges has changed, such as a login after an autostart.
|
||||
certContextPollInterval = time.Minute
|
||||
// certRetryInterval is how long after a collection that proved nothing it is tried
|
||||
// again with the same user, for stores that come up late: a keychain unlocked after
|
||||
// login, or a TPM resource manager started after the daemon.
|
||||
certRetryInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
// undeliveredContext never matches a real user context, so a collection whose meta sync
|
||||
// failed is seen as stale on the next poll.
|
||||
const undeliveredContext = "\x00undelivered"
|
||||
|
||||
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
|
||||
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
|
||||
var errSystemInfoTimeout = errors.New("system info gathering timed out")
|
||||
|
||||
// certPostureState remembers what the last certificate proof collection saw, so the
|
||||
// engine can collect again when it went stale and tell the user when proofs go missing.
|
||||
//
|
||||
// Collection runs only on the posture watcher, never on the sync path, because a token,
|
||||
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
|
||||
// last collection cached. delivered identifies the chains management last received, so
|
||||
// a collection that proves the same chains is not sent again: every meta sync makes
|
||||
// management recompute and push network maps to the peer and its neighbours.
|
||||
type certPostureState struct {
|
||||
mu sync.Mutex
|
||||
attempted bool
|
||||
attemptedAt time.Time
|
||||
userContext string
|
||||
proven bool
|
||||
delivered string
|
||||
hasDelivery bool
|
||||
cached []certposture.Proof
|
||||
cachedFor string
|
||||
// stuck is whether the collector was last seen with every slot held by a lost
|
||||
// collection.
|
||||
stuck bool
|
||||
}
|
||||
|
||||
// record stores the outcome of a collection for the challenges identified by
|
||||
// challengesKey and reports whether it changed from proven to unproven or back. The first
|
||||
// collection only counts as a change when it proved nothing.
|
||||
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
proven := len(proofs) > 0
|
||||
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
|
||||
s.attempted = true
|
||||
s.attemptedAt = now
|
||||
s.userContext = userContext
|
||||
s.proven = proven
|
||||
s.cached = proofs
|
||||
s.cachedFor = challengesKey
|
||||
return changed
|
||||
}
|
||||
|
||||
// undelivered marks the last collection as not having reached management, so the next
|
||||
// poll collects and sends again instead of treating it as current.
|
||||
func (s *certPostureState) undelivered() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.attempted {
|
||||
s.userContext = undeliveredContext
|
||||
}
|
||||
s.hasDelivery = false
|
||||
}
|
||||
|
||||
// markDelivered records the chains of proofs as the ones management holds.
|
||||
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.delivered = provenChainsKey(proofs)
|
||||
s.hasDelivery = true
|
||||
}
|
||||
|
||||
// sameAsDelivered reports whether proofs prove exactly the chains management already
|
||||
// holds. Nonces and signatures are left out: they differ on every signing while the
|
||||
// result management stores, the verified chains, stays the same.
|
||||
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
|
||||
}
|
||||
|
||||
// needsCollection reports whether the cached proofs no longer reflect what the device
|
||||
// can prove for the challenges identified by challengesKey: they answer other
|
||||
// challenges, the user who can answer has changed, or nothing was proven and the retry
|
||||
// interval has passed.
|
||||
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case !s.attempted || s.cachedFor != challengesKey:
|
||||
return true
|
||||
case userContext != s.userContext:
|
||||
return true
|
||||
default:
|
||||
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
|
||||
}
|
||||
}
|
||||
|
||||
// setStuck records whether the collector is stuck and reports whether it just became so.
|
||||
func (s *certPostureState) setStuck(stuck bool) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
became := stuck && !s.stuck
|
||||
s.stuck = stuck
|
||||
return became
|
||||
}
|
||||
|
||||
// cachedProofs returns the cached proofs management still accepts for the challenges in
|
||||
// checks: those whose nonce is current, or from an adjacent window, for one of them. Proofs
|
||||
// signed for the previous nonce bridge the time until the watcher has signed the new one.
|
||||
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var nonces [][]byte
|
||||
for _, check := range checks {
|
||||
if challenge := check.GetCertificateChallenge(); challenge != nil {
|
||||
nonces = append(nonces, challenge.GetNonce())
|
||||
}
|
||||
}
|
||||
var accepted []certposture.Proof
|
||||
for _, proof := range s.cached {
|
||||
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
|
||||
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
|
||||
}) {
|
||||
accepted = append(accepted, proof)
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
// provenChainsKey identifies the set of chains in proofs, independent of their order.
|
||||
func provenChainsKey(proofs []certposture.Proof) string {
|
||||
keys := make([]string, 0, len(proofs))
|
||||
for _, proof := range proofs {
|
||||
h := sha256.New()
|
||||
for _, cert := range proof.Chain {
|
||||
writeLengthPrefixed(h, cert)
|
||||
}
|
||||
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
|
||||
func challengesKey(checks []*mgmProto.Checks) string {
|
||||
h := sha256.New()
|
||||
for _, check := range checks {
|
||||
challenge := check.GetCertificateChallenge()
|
||||
if challenge == nil {
|
||||
continue
|
||||
}
|
||||
writeLengthPrefixed(h, challenge.GetNonce())
|
||||
for _, ca := range challenge.GetCaCertificates() {
|
||||
writeLengthPrefixed(h, []byte(ca))
|
||||
}
|
||||
writeLengthPrefixed(h, nil)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write(data)
|
||||
}
|
||||
|
||||
// attachCertificateProofs attaches the cached proofs management accepts for the
|
||||
// challenges in checks, without collecting: the caller is on the sync path. The info is
|
||||
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
|
||||
// count as delivered. The watcher is woken to collect when the cache is out of date.
|
||||
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
|
||||
if !certproof.HasChallenges(checks) {
|
||||
info.CertificateProofs = nil
|
||||
return
|
||||
}
|
||||
info.CertificateProofs = e.certState.cachedProofs(checks)
|
||||
e.certState.markDelivered(info.CertificateProofs)
|
||||
e.wakeCertificatePosture()
|
||||
}
|
||||
|
||||
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
|
||||
// its next tick. It never blocks.
|
||||
func (e *Engine) wakeCertificatePosture() {
|
||||
if e.certWake == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case e.certWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// publishCertificatePostureEvent tells the user when the device stops proving any
|
||||
// certificate, which management treats as failing every certificate posture check, and
|
||||
// when it proves one again. Without it the loss of access would have no visible cause.
|
||||
func (e *Engine) publishCertificatePostureEvent(proven bool) {
|
||||
if e.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
if proven {
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: a certificate is proven again",
|
||||
"A certificate required by your organization's device policy is available again.", nil)
|
||||
return
|
||||
}
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: no certificate could be proven",
|
||||
"NetBird could not use a certificate required by your organization's device policy. "+
|
||||
"Access to some resources may be blocked until one is available.", nil)
|
||||
}
|
||||
|
||||
// publishCertificateStoreStuckEvent tells the user that reading the certificate store
|
||||
// stopped answering. Collection resumes only once a stuck call returns, which a store
|
||||
// that hangs for good never does short of a restart.
|
||||
func (e *Engine) publishCertificateStoreStuckEvent() {
|
||||
if e.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
||||
"certificate posture: the certificate store stopped responding",
|
||||
"NetBird cannot read the certificates required by your organization's device policy because "+
|
||||
"the certificate store stopped responding. If this persists, restart the NetBird service.", nil)
|
||||
}
|
||||
|
||||
// watchCertificatePosture owns certificate proof collection until ctx is done. It
|
||||
// collects when woken by new checks or a sync, and on every tick when the cached proofs
|
||||
// went stale, such as after a login following an autostart.
|
||||
func (e *Engine) watchCertificatePosture(ctx context.Context) {
|
||||
ticker := time.NewTicker(certContextPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
// A pending update that times out again stays pending; the applied checks'
|
||||
// proofs are still refreshed so a lost certificate is reported meanwhile.
|
||||
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
||||
log.Warnf("failed to sync posture checks that timed out before: %v", err)
|
||||
}
|
||||
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
||||
log.Warnf("failed to refresh certificate posture proofs: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-e.certWake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
|
||||
// gathering the system info, and applies them once it succeeds. Without it they would
|
||||
// wait for the next network map that changes the checks.
|
||||
func (e *Engine) retryPendingChecks() error {
|
||||
if !e.hasPendingChecks.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.stopping() || !e.hasPendingChecks.Load() {
|
||||
return nil
|
||||
}
|
||||
checks := e.pendingChecks
|
||||
log.Debugf("posture checks: retrying the meta sync that timed out")
|
||||
if err := e.syncChecksMeta(checks); err != nil {
|
||||
return err
|
||||
}
|
||||
e.setAppliedChecks(checks)
|
||||
e.clearPendingChecks()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCertificateProofs collects the proofs for the applied checks when the cached
|
||||
// ones are out of date, and sends them to management when they prove other chains than
|
||||
// it holds. It holds no engine lock while collecting.
|
||||
func (e *Engine) refreshCertificateProofs() error {
|
||||
checks := e.appliedChecks()
|
||||
if e.stopping() || !certproof.HasChallenges(checks) {
|
||||
return nil
|
||||
}
|
||||
key := challengesKey(checks)
|
||||
userContext := certproof.UserContext(e.config.CertStore)
|
||||
if !e.certState.needsCollection(key, userContext, time.Now()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("certificate posture: collecting proofs")
|
||||
peerKey := e.config.WgPrivateKey.PublicKey()
|
||||
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
|
||||
// A collection cut short by the engine stopping proved nothing about the device, and
|
||||
// must not be reported to the user as a lost certificate.
|
||||
if e.stopping() {
|
||||
return nil
|
||||
}
|
||||
if e.certState.record(key, userContext, proofs, time.Now()) {
|
||||
e.publishCertificatePostureEvent(len(proofs) > 0)
|
||||
}
|
||||
if e.certState.setStuck(e.certProofs.Stuck()) {
|
||||
e.publishCertificateStoreStuckEvent()
|
||||
}
|
||||
|
||||
if e.certState.sameAsDelivered(proofs) {
|
||||
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
|
||||
return nil
|
||||
}
|
||||
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
e.certState.undelivered()
|
||||
return errSystemInfoTimeout
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
return e.sendMetaWithProofs(info, checks)
|
||||
}
|
||||
|
||||
// stopping reports whether the engine is shutting down.
|
||||
func (e *Engine) stopping() bool {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
|
||||
// with the cached certificate proofs. The caller holds syncMsgMux.
|
||||
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
|
||||
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
|
||||
e.certState.undelivered()
|
||||
return errSystemInfoTimeout
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
return e.sendMetaWithProofs(info, checks)
|
||||
}
|
||||
|
||||
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
|
||||
// are serialized and read the cache under that lock, so a send from the sync path cannot
|
||||
// overwrite newer proofs the watcher sent in the meantime.
|
||||
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
|
||||
e.certSendMu.Lock()
|
||||
defer e.certSendMu.Unlock()
|
||||
|
||||
info.CertificateProofs = e.certState.cachedProofs(checks)
|
||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||
e.certState.undelivered()
|
||||
return fmt.Errorf("sync meta: %w", err)
|
||||
}
|
||||
e.certState.markDelivered(info.CertificateProofs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/certproof"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestCertPostureState_Record(t *testing.T) {
|
||||
now := time.Now()
|
||||
one := []certposture.Proof{{}}
|
||||
|
||||
var proven certPostureState
|
||||
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
|
||||
|
||||
var unproven certPostureState
|
||||
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
|
||||
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
|
||||
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
|
||||
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
|
||||
}
|
||||
|
||||
// TestCertPostureState_ReportsStuckOnce: a store that stopped answering is reported to
|
||||
// the user once when it wedges the collector, not on every collection that follows, and
|
||||
// again if it wedges anew after recovering.
|
||||
func TestCertPostureState_ReportsStuckOnce(t *testing.T) {
|
||||
var s certPostureState
|
||||
assert.True(t, s.setStuck(true), "becoming stuck is reported")
|
||||
assert.False(t, s.setStuck(true), "staying stuck is not reported again")
|
||||
assert.False(t, s.setStuck(false), "recovering is not a stuck report")
|
||||
assert.True(t, s.setStuck(true), "getting stuck again is reported again")
|
||||
}
|
||||
|
||||
func TestCertPostureState_NeedsCollection(t *testing.T) {
|
||||
now := time.Now()
|
||||
one := []certposture.Proof{{}}
|
||||
var s certPostureState
|
||||
|
||||
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
|
||||
|
||||
s.record("k", "501:alice", one, now)
|
||||
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
|
||||
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
|
||||
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
|
||||
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
|
||||
|
||||
s.record("k", "", nil, now)
|
||||
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
|
||||
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
|
||||
|
||||
s.record("k", "501:alice", one, now)
|
||||
s.undelivered()
|
||||
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
|
||||
}
|
||||
|
||||
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
|
||||
// window and accepts the previous one, so a cached proof keeps being attached across one
|
||||
// rotation, while the watcher signs the new nonce, and not across two.
|
||||
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
|
||||
challenger := certposture.NewChallenger([]byte("secret"))
|
||||
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
||||
now := time.Now()
|
||||
checksAt := func(at time.Time) []*mgmProto.Checks {
|
||||
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
|
||||
}
|
||||
|
||||
var s certPostureState
|
||||
current := checksAt(now)
|
||||
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
|
||||
|
||||
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
|
||||
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
|
||||
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
|
||||
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
|
||||
}
|
||||
|
||||
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
|
||||
// whose management client records the proofs of every meta sync.
|
||||
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
|
||||
t.Helper()
|
||||
// The store refuses a group-writable directory, which t.TempDir yields under a
|
||||
// user-private-group umask.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o700))
|
||||
t.Setenv(certproof.StoreDirEnv, dir)
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
e := &Engine{
|
||||
ctx: context.Background(),
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: &EngineConfig{WgPrivateKey: key},
|
||||
statusRecorder: recorder,
|
||||
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
|
||||
}
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
peerKey := key.PublicKey()
|
||||
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
|
||||
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
||||
return e, dir, ca
|
||||
}
|
||||
|
||||
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
|
||||
t.Helper()
|
||||
deviceKey := certtest.ECDSAKey(t)
|
||||
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
||||
return pem
|
||||
}
|
||||
|
||||
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
|
||||
// watcher collected, so a certificate that appears in the store is not proven on the sync
|
||||
// path until the watcher ran.
|
||||
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
|
||||
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
|
||||
writeDeviceCert(t, dir, ca)
|
||||
|
||||
info := &system.Info{}
|
||||
e.attachCertificateProofs(info, e.checks)
|
||||
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
e.attachCertificateProofs(info, e.checks)
|
||||
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
|
||||
}
|
||||
|
||||
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
|
||||
// certificate gets one warning, and one notice when it can again.
|
||||
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
|
||||
recorder := peer.NewRecorder("")
|
||||
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
|
||||
warnings := func() int {
|
||||
n := 0
|
||||
for _, ev := range recorder.GetEventHistory() {
|
||||
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
|
||||
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
|
||||
|
||||
writeDeviceCert(t, dir, ca)
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
events := recorder.GetEventHistory()
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
|
||||
}
|
||||
|
||||
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
|
||||
// and push network maps, so a collection that proves the same chains as before must not
|
||||
// send, while a new certificate or a failed delivery must.
|
||||
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
|
||||
var sent [][]certposture.Proof
|
||||
failSync := false
|
||||
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
|
||||
if failSync {
|
||||
return errors.New("management unavailable")
|
||||
}
|
||||
sent = append(sent, info.CertificateProofs)
|
||||
return nil
|
||||
})
|
||||
// expire makes the last collection due for the unproven retry.
|
||||
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
||||
// switchUser makes the last collection stale as if another user signed in.
|
||||
switchUser := func() { e.certState.userContext = "someone else" }
|
||||
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
require.Len(t, sent, 1, "the first collection is sent")
|
||||
assert.Empty(t, sent[0], "nothing to prove yet")
|
||||
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 1, "still proving nothing is not sent again")
|
||||
|
||||
pem := writeDeviceCert(t, dir, ca)
|
||||
expire()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
require.Len(t, sent, 2, "a newly proven certificate is sent")
|
||||
assert.Len(t, sent[1], 1, "the new proof is attached")
|
||||
|
||||
switchUser()
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
|
||||
|
||||
failSync = true
|
||||
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
|
||||
switchUser()
|
||||
require.Error(t, e.refreshCertificateProofs())
|
||||
failSync = false
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
||||
|
||||
// Management missed the empty proof set, so it still holds the chain the device proves
|
||||
// again now; after a failed delivery the state is unknown, and it is sent regardless.
|
||||
require.NoError(t, e.refreshCertificateProofs())
|
||||
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
|
||||
}
|
||||
@@ -15,5 +15,6 @@ func (e *Engine) createICEConfig() icemaker.Config {
|
||||
UDPMux: e.udpMux.SingleSocketUDPMux,
|
||||
UDPMuxSrflx: e.udpMux,
|
||||
NATExternalIPs: e.parseNATExternalIPMappings(),
|
||||
WGDetector: e.wgDetector,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+55
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user