mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-05 22:41:30 +02:00
* [client] Bind the cached SSH JWT to the local caller that obtained it Record the identity that obtained the token and return it only to that same identity, comparing the account alone: the group set and the elevation flag describe what a token may do rather than who it belongs to, and the same user may call once elevated and once not. A control channel that carries no caller identity gets a miss on read and stores nothing on write, matching how the other ipcauth consumers fail closed. Clear the entry when the session it speaks for ends: logout, down and profile switch. * [client] Cover the profile-switch path of the SSH JWT cache The cache being correct buys nothing if a handler around it forgets to clear it, and SwitchProfile had no test at all. Point the profile globals at a temp dir holding a single default profile, which is the one ActiveProfileState.FilePath resolves without consulting the current OS user, and call SwitchProfile with no request so neither the switch itself nor the profile-list event is involved. * [client] Report the SSH JWT cache in the no-identity startup warning daemonServerOptions already warns once, at startup, about what a control channel with no caller identity gives up. Name the SSH JWT cache there too, on both the TCP and the no-peer-identity-primitive paths. The per-request logs in cachedJWT and WaitJWTToken drop to Debug: the condition is expected and handled on such a channel, the caller simply re-authenticates, and repeating it on every SSH authentication buried the one message that is actionable. * [client] Stop the local-metrics manager leaking out of the profile test localmetrics.NewManager runs a goroutine until its context is done, and the test handed it context.Background(), so the manager outlived the test and stayed in the test binary for every case that followed. * [client] Keep the cached SSH JWT across a down/up cycle Clearing the cache in cleanupConnection also caught Down, which ends the connection and not the session: the peer stays enrolled, `up` reconnects without going back to the IdP, and the token still belongs to the same NetBird identity. With a long cache TTL that cost the owner a fresh device-code flow for nothing, since the owner binding is what keeps the token away from other local accounts. Clear it on the two paths where the session really ends and the next one may belong to a different NetBird user: profile logout when the profile is the active one, and active-profile logout. SwitchProfile already cleared it on its own. * [client] Resolve the merge conflict in the profile-logout cleanup main extracted the inline profile-logout cleanup into cleanupAfterProfileLogout, which this branch had edited in place to clear the SSH JWT cache. Take main's helper and move the clear inside it. The helper returns early when the profile that was deregistered is not the active one, so the cache is still only cleared when the session that owns the token actually ends. * [client] Do not cache an SSH JWT obtained under a session that ended WaitJWTToken polls the IdP with s.mutex released, and that wait can run for as long as the user takes in the browser. A logout or a profile switch in the meantime clears the cache, but the poll then completed and stored its token anyway, so the entry the next session read belonged to the previous one. Give the cache a generation that clear advances. WaitJWTToken takes the generation before the wait and hands it back to store, which keeps the token only while the generation still matches. The two mutexes are distinct, so this was never a data race and the race detector could not have found it: the window is between two separately locked sections. * [client] Make the profile-switch test switch a profile SwitchProfile with a nil request skips switchProfileIfNeeded, so the test only covered the no-op path and would have passed with profile-transition invalidation broken. Create a second profile and name it in the request, then assert the active profile actually moved before checking the cache. Also correct the comment on the Down test: the logout handlers do call cleanupConnection. What changed is that clearing the cache is no longer one of the things cleanupConnection does. * [client] Take the SSH JWT cache generation when the flow is created WaitJWTToken read the generation after validating the device code, but the flow it belongs to is created earlier, in RequestJWTAuth, and SwitchProfile does not reset s.oauthAuthFlow. A profile switch between the two therefore advanced the generation before it was ever read: the guard compared the new session against itself and let the token through, which is the case it exists to stop. Record the generation on the flow when RequestJWTAuth creates it, and read it from there. The whole span from the request to the IdP answering now counts as one session for the cache. * [client] Correct two test comments the clear-on-Down change invalidated Moving the clear out of cleanupConnection left two comments describing the old behaviour: newTestServer said cleanupConnection clears the cache, and the comment above TestJWTCache_ClearDropsTheEntry listed Down among the callers of clear. Neither is true any more. * [client] Read the SSH JWT cache generation before the IdP round trip RequestJWTAuth read the generation where it stored the flow, which is after RequestAuthInfo has talked to the IdP. A logout or a profile switch during that call advanced the generation first, so the flow recorded the new session's value and the later store was accepted: the window moved rather than closed. Read it with the config, under the same s.mutex section. SwitchProfile holds that mutex across its own clear(), so the config and the generation cannot be torn apart by a switch.
141 lines
5.1 KiB
Go
141 lines
5.1 KiB
Go
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
|
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
|
// context, so the daemon can authorize individual RPCs by caller identity.
|
|
//
|
|
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
|
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
|
|
// named-pipe client token. Platforms without a peer-identity primitive get no
|
|
// credentials, and every consumer must fail closed when no identity is
|
|
// available.
|
|
package ipcauth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/peer"
|
|
)
|
|
|
|
// Well-known Windows SIDs that identify a fully privileged principal.
|
|
const (
|
|
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
|
|
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
|
|
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
|
|
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
|
|
)
|
|
|
|
// Identity is the kernel-authenticated identity of a local IPC caller. The
|
|
// zero value is not a valid identity: consumers must only use one obtained
|
|
// with a true ok/nil error return.
|
|
type Identity struct {
|
|
// UID and GID are the caller's Unix user ID and primary group ID. Both are
|
|
// zero on Windows, where SID is authoritative instead.
|
|
UID uint32
|
|
GID uint32
|
|
|
|
// SID is the caller's Windows security identifier, empty on Unix.
|
|
SID string
|
|
|
|
// Groups holds the caller's Windows group SIDs, captured from the client
|
|
// token at handshake time. Only groups that are enabled and not
|
|
// deny-only are captured, so a group listed here is one the caller can
|
|
// actually exercise. Empty on Unix.
|
|
Groups []string
|
|
|
|
// Elevated reports whether the Windows client token is elevated (running
|
|
// as administrator, or an administrator with UAC turned off). Always false
|
|
// on Unix, where privilege is uid 0.
|
|
Elevated bool
|
|
|
|
// PID is the caller's process ID where the platform reports it (Linux's
|
|
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
|
|
// process dialling itself, which is what the JSON gateway does, and is never
|
|
// used to grant anything.
|
|
PID int32
|
|
}
|
|
|
|
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
|
// rather than a Unix uid/gid principal.
|
|
func (i Identity) IsWindows() bool {
|
|
return i.SID != ""
|
|
}
|
|
|
|
// IsPrivileged reports whether the caller is the platform's administrative
|
|
// principal, which is what the daemon requires for changes that cross the
|
|
// 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
|
|
// 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
|
|
// nothing about what this token may do on this machine.
|
|
func (i Identity) IsPrivileged() bool {
|
|
if !i.IsWindows() {
|
|
return i.UID == 0
|
|
}
|
|
|
|
if i.Elevated {
|
|
return true
|
|
}
|
|
|
|
switch i.SID {
|
|
case sidLocalSystem, sidLocalService, sidNetworkService:
|
|
return true
|
|
}
|
|
|
|
return slices.Contains(i.Groups, sidAdministrators)
|
|
}
|
|
|
|
// SameUser reports whether two identities are the same local principal. Only
|
|
// the account is compared: the group set and the elevation flag describe what a
|
|
// token may do, not who it belongs to. A SID on either side decides the
|
|
// comparison, so a Windows principal never matches a Unix one on the UID both
|
|
// happen to leave at zero. The zero Identity carries uid 0, so callers must
|
|
// establish that both identities are real before the answer means anything.
|
|
func (i Identity) SameUser(other Identity) bool {
|
|
if i.SID != "" || other.SID != "" {
|
|
return i.SID == other.SID
|
|
}
|
|
return i.UID == other.UID
|
|
}
|
|
|
|
// String renders the identity for audit logs and denial messages.
|
|
func (i Identity) String() string {
|
|
if i.IsWindows() {
|
|
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
|
}
|
|
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
|
}
|
|
|
|
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
|
|
// handlers can retrieve it from the request context via IdentityFromContext.
|
|
type AuthInfo struct {
|
|
credentials.CommonAuthInfo
|
|
Identity Identity
|
|
}
|
|
|
|
// AuthType identifies the authentication scheme.
|
|
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
|
|
|
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
|
// the gRPC peer context. The second return value is false when no IPC
|
|
// transport credentials were negotiated, which happens on a TCP daemon socket
|
|
// and on platforms without a peer-identity primitive. Callers MUST fail closed
|
|
// in that case.
|
|
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
|
p, ok := peer.FromContext(ctx)
|
|
if !ok {
|
|
return Identity{}, false
|
|
}
|
|
info, ok := p.AuthInfo.(AuthInfo)
|
|
if !ok {
|
|
return Identity{}, false
|
|
}
|
|
return info.Identity, true
|
|
}
|