diff --git a/client/cmd/posture.go b/client/cmd/posture.go new file mode 100644 index 000000000..b0870233d --- /dev/null +++ b/client/cmd/posture.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/certproof" +) + +var postureCmd = &cobra.Command{ + Use: "posture", + Short: "Posture helpers invoked by the NetBird daemon", + Hidden: true, +} + +var postureCertProofCmd = &cobra.Command{ + Use: "cert-proof", + Short: "Answer certificate posture challenges from the calling user's keychain", + Long: "Reads a certificate challenge set as JSON on stdin and writes the proofs as JSON on stdout.\n" + + "The daemon launches this in the console user's desktop session, because a login keychain\n" + + "cannot be reached from a root daemon. Not intended to be run by hand.", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Proofs travel on stdout, so every log line has to go elsewhere. + log.SetOutput(cmd.ErrOrStderr()) + return certproof.RunHelper(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout()) + }, +} diff --git a/client/cmd/root.go b/client/cmd/root.go index be6479440..a4a99a024 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -179,6 +179,9 @@ func init() { rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(profileCmd) rootCmd.AddCommand(exposeCmd) + rootCmd.AddCommand(postureCmd) + + postureCmd.AddCommand(postureCertProofCmd) networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) diff --git a/client/internal/certproof/README.md b/client/internal/certproof/README.md new file mode 100644 index 000000000..d1f600de8 --- /dev/null +++ b/client/internal/certproof/README.md @@ -0,0 +1,123 @@ +# Certificate posture proofs + +A peer answers a management certificate challenge by signing the challenge nonce with a +private key it holds, and sending back the certificate chain. Management verifies the +chain against the CAs configured on the check and verifies the signature, which proves +the peer holds the key rather than merely a copy of the certificate. + +The signature covers `netbird-posture-cert-v1 || nonce || peerKey`, so a proof is bound +to one WireGuard peer key and cannot be replayed by another peer. + +## Where certificates come from + +| Platform | Store | Read by | +| --- | --- | --- | +| macOS | System keychain | the daemon, directly | +| macOS | console user's login keychain | a helper in that user's desktop session | +| Windows | CNG / system certificate store | the daemon, directly | +| Linux and others | PEM directory, `NB_CERT_STORE_DIR` or `/etc/netbird/certs` | the daemon, directly | + +## macOS: why the daemon cannot read a login keychain + +The daemon runs as root from a LaunchDaemon. Its keychain search list is the System +keychain, which is where MDM installs device identities, and nothing else. A user's +login keychain is out of reach for reasons that are not about privilege: + +- `login.keychain-db` is unlocked by `securityd` **in the user's session**. The daemon + lives in a different Mach bootstrap namespace, so from where it stands the keychain is + locked no matter which uid it runs as. +- Every private key carries an ACL naming the applications allowed to use it. A process + that is not listed causes a consent prompt *in the user's session*. A daemon has no + session to show one in, so it receives `errSecInteractionNotAllowed (-25308)` instead. + +Dropping to the user's uid with `SysProcAttr.Credential` does **not** fix this: uid is +not what selects the securityd instance, the bootstrap namespace is. The process has to +enter the user's session, which is what `launchctl asuser` does. + +## macOS: the console user helper + +When a certificate challenge arrives and the daemon is root, it: + +1. Reads the System keychain itself, so MDM device identities are answered with no user + session involved. +2. Resolves the console user with `SCDynamicStoreCopyConsoleUser`. +3. Launches itself as that user with + `launchctl asuser sudo -u -H netbird posture cert-proof`, writing the + challenges to the child's stdin as JSON and reading proofs from its stdout. +4. Merges both sets of proofs, dropping a leaf that both keychains hold. + +The child runs `RunHelper`, which uses the ordinary `KeychainStore` — inside the user's +session it simply works. **The private key never crosses the boundary; only the +signature and the certificate chain come back.** + +`-H` matters: it sets `HOME`, which is how the login keychain path is resolved. + +`netbird posture cert-proof` is hidden and not meant to be run by hand. It writes proofs +to stdout and every log line to stderr, so stdout stays parseable. + +## Only the console user can be validated + +This is the central limitation of the design, and it is deliberate. + +A proof from a login keychain can only ever be produced for **the user whose desktop +session is currently open**. Consequences worth designing around: + +- **At the login window there is no user proof.** macOS reports no console user, or + attributes the console to root, and `CurrentConsoleUser` returns false for both. Only + System keychain device proofs are sent. A posture check that demands a user + certificate will fail on a Mac sitting at the lock screen before anyone logs in. +- **Logging out changes the answer.** Posture can flip between compliant and + non-compliant across logout, so management should treat "no proof" as its own state + rather than as a failed check, or users get disconnected at the login window. +- **Fast user switching picks one user.** Other logged-in users keep valid sessions and + unlocked keychains, but only the console user is asked. If you ever need all of them, + enumerate GUI sessions instead of the console user. +- **A locked keychain still blocks signing.** A user can be logged in with their + keychain locked (locked on sleep, or manually). The helper then needs an unlock prompt + and may block, which is why the spawn has a 30s timeout and a failure is reported as + "no proof" rather than an error. +- **The first signature prompts.** The user sees "netbird wants to use your confidential + information stored in ...". Choosing *Always Allow* records the helper's designated + requirement in the key's ACL, so it persists across restarts and updates while the + signing identity is stable. Unsigned or ad-hoc development builds re-prompt every run. + +## What a user proof does and does not attest + +It attests: *some process in that user's session had ACL permission to use a private key +whose certificate chains to CA X, and signed a nonce bound to this peer key*. + +It does not attest that the daemon controls the key, that the key is hardware-bound, or +that a particular binary produced the signature. Any code running in that user's session +with an existing ACL grant can produce the same signature by calling +`SecKeyCreateSignature` directly — the proof format is not a secret. The helper does not +create that capability, it only packages it. + +If you need a stronger guarantee, use a device identity that never involves a user +session (MDM into the System keychain, which the daemon reads directly), or a key that +requires user presence for each signature (Secure Enclave or a PIV token). + +## Reading the logs + +Everything in this path logs at info. A healthy macOS run shows, in order: + +``` +certificate posture: answering N certificate challenges from store *certproof.KeychainStore +macOS Security framework loaded for certificate posture, running as uid=0 euid=0 +keychain search list contains 2 keychains +keychain search list[0]: /Library/Keychains/System.keychain +keychain identity query returned N items +certificate posture: asking the desktop session of "user" (uid 501) to answer N challenges +certificate posture: desktop session of "user" returned N proofs +peer meta carries N certificate posture proofs +``` + +Common outcomes and what they mean: + +| Log line | Meaning | +| --- | --- | +| `keychain identity query returned errSecItemNotFound (-25300)` | The keychain is readable and holds no identity of that class. Any other OSStatus is a real access failure. | +| `holds no identities usable for certificate posture, but N readable certificates` | Reading works; the certificate is present without its private key, or is not there at all. | +| `no console user is logged in` | Login window. Device proofs only. | +| `has no issuer in the keychain` | The chain ships leaf-only and verifies only if the challenge supplies that exact root. | +| `challenge N rejected "..." : x509: unhandled critical extension` | The chain is fine but Go refuses an extension in it, which is common for Apple-issued certificates. | +| `challenge N matched none of the M candidates` | Every candidate was rejected; the preceding lines give the reason for each. | diff --git a/client/internal/certproof/collect.go b/client/internal/certproof/collect.go index 6d8c75aef..d2b5d87b0 100644 --- a/client/internal/certproof/collect.go +++ b/client/internal/certproof/collect.go @@ -17,42 +17,77 @@ import ( 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.Infof("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. +func CollectChallenges(ctx context.Context, store Store, challenges []*proto.CertificateChallenge, peerKey []byte) []certposture.Proof { + log.Infof("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.Info("certificate posture: certificate store holds no candidates, no proof will be sent") + return nil + } + log.Infof("certificate posture: store holds %d candidate certificates", len(candidates)) now := time.Now() proven := make(map[[sha256.Size]byte]struct{}) var proofs []certposture.Proof - for _, challenge := range challenges { + 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.Infof("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 certposture.VerifyChain(candidate.Chain, roots, now) != nil { + if len(candidate.Chain) == 0 { continue } - fingerprint := sha256.Sum256(candidate.Chain[0].Raw) + leaf := candidate.Chain[0] + if err := certposture.VerifyChain(candidate.Chain, roots, now); err != nil { + log.Infof("certificate posture: challenge %d rejected %q issued by %q, chain of %d: %v", i, leaf.Subject, leaf.Issuer, len(candidate.Chain), err) + continue + } + matched = true + + fingerprint := sha256.Sum256(leaf.Raw) if _, done := proven[fingerprint]; done { + log.Infof("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject) break } proof, err := prove(candidate, challenge.GetNonce(), peerKey) if err != nil { - log.Warnf("failed signing certificate proof for %s: %v", candidate.Chain[0].Subject, err) + log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err) continue } + log.Infof("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.Infof("certificate posture: challenge %d matched none of the %d candidates", i, len(candidates)) + } } + log.Infof("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs)) return proofs } diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go new file mode 100644 index 000000000..135d4a882 --- /dev/null +++ b/client/internal/certproof/collect_darwin.go @@ -0,0 +1,142 @@ +package certproof + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "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 + +// 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) []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) + + userProofs, err := collectAsConsoleUser(ctx, challenges, peerKey) + if err != nil { + log.Infof("certificate posture: console user keychain unavailable: %v", err) + } + return mergeProofs(proofs, userProofs) +} + +// 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, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { + user, ok := CurrentConsoleUser() + if !ok { + return nil, nil + } + + binary, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve own binary: %w", err) + } + + payload, err := json.Marshal(helperRequest(challenges, peerKey)) + if err != nil { + return nil, fmt.Errorf("encode helper request: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, helperTimeout) + defer cancel() + + uid := strconv.FormatUint(uint64(user.UID), 10) + cmd := exec.CommandContext(ctx, "launchctl", "asuser", uid, "sudo", "-u", user.Name, "-H", binary, "posture", "cert-proof") + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + log.Infof("certificate posture: asking the desktop session of %q (uid %s) to answer %d challenges", user.Name, uid, len(challenges)) + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("run helper as %s: %w: %s", user.Name, err, strings.TrimSpace(stderr.String())) + } + + var resp HelperResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("decode helper response: %w", err) + } + log.Infof("certificate posture: desktop session of %q returned %d proofs", user.Name, len(resp.Proofs)) + return resp.Proofs, nil +} + +func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest { + req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))} + for _, challenge := range challenges { + req.Challenges = append(req.Challenges, HelperChallenge{ + Nonce: challenge.GetNonce(), + CACertificates: challenge.GetCaCertificates(), + }) + } + return req +} + +// mergeProofs appends the user session proofs to the device proofs, dropping a leaf +// that both keychains hold so the same certificate is proven once. +func mergeProofs(device, user []certposture.Proof) []certposture.Proof { + if len(user) == 0 { + return device + } + + seen := make(map[[sha256.Size]byte]struct{}, len(device)) + for _, proof := range device { + if len(proof.Chain) > 0 { + seen[sha256.Sum256(proof.Chain[0])] = struct{}{} + } + } + + merged := device + for _, proof := range user { + if len(proof.Chain) == 0 { + continue + } + fingerprint := sha256.Sum256(proof.Chain[0]) + if _, done := seen[fingerprint]; done { + continue + } + seen[fingerprint] = struct{}{} + merged = append(merged, proof) + logUserProof(proof) + } + return merged +} + +func logUserProof(proof certposture.Proof) { + leaf, err := x509.ParseCertificate(proof.Chain[0]) + if err != nil { + log.Infof("certificate posture: console user proof carries an unparsable leaf: %v", err) + return + } + log.Infof("certificate posture: console user proved %q issued by %q", leaf.Subject, leaf.Issuer) +} diff --git a/client/internal/certproof/collect_other.go b/client/internal/certproof/collect_other.go new file mode 100644 index 000000000..e867dc4fc --- /dev/null +++ b/client/internal/certproof/collect_other.go @@ -0,0 +1,17 @@ +//go:build !darwin + +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 platform store. +// Only macOS splits the work across a user session, so every other platform reads its +// store in the daemon itself. +func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte) []certposture.Proof { + return Collect(ctx, DefaultStore(), checks, peerKey) +} diff --git a/client/internal/certproof/consoleuser_darwin.go b/client/internal/certproof/consoleuser_darwin.go new file mode 100644 index 000000000..f0489d605 --- /dev/null +++ b/client/internal/certproof/consoleuser_darwin.go @@ -0,0 +1,113 @@ +package certproof + +import ( + "bytes" + "fmt" + "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.Infof("console user lookup unavailable: %v", err) + return ConsoleUser{}, false + } + + var uid, gid uint32 + name := scDynamicStoreCopyConsoleUser(0, &uid, &gid) + if name == 0 { + log.Info("no console user is logged in, no login keychain is reachable") + return ConsoleUser{}, false + } + defer cfRelease(name) + + user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid} + if !user.hasDesktop() { + log.Infof("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 +} + +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 +} diff --git a/client/internal/certproof/consoleuser_darwin_test.go b/client/internal/certproof/consoleuser_darwin_test.go new file mode 100644 index 000000000..8e46f6a49 --- /dev/null +++ b/client/internal/certproof/consoleuser_darwin_test.go @@ -0,0 +1,70 @@ +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 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 TestMergeProofs_ProvesACertificateHeldByBothKeychainsOnce(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + key := certtest.ECDSAKey(t) + shared := ca.Issue(t, key, "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 Mac at the login window 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") +} diff --git a/client/internal/certproof/helper.go b/client/internal/certproof/helper.go new file mode 100644 index 000000000..d47b6de24 --- /dev/null +++ b/client/internal/certproof/helper.go @@ -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, DefaultStore(), 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.Infof("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 +} diff --git a/client/internal/certproof/helper_test.go b/client/internal/certproof/helper_test.go new file mode 100644 index 000000000..b70bd71f3 --- /dev/null +++ b/client/internal/certproof/helper_test.go @@ -0,0 +1,63 @@ +package certproof + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/certposture" + "github.com/netbirdio/netbird/shared/management/certposture/certtest" +) + +func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + dir := t.TempDir() + 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(t.TempDir()), 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(t.TempDir()), 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") +} diff --git a/client/internal/certproof/keychain_darwin.go b/client/internal/certproof/keychain_darwin.go index 0e757ce9d..d53c15b27 100644 --- a/client/internal/certproof/keychain_darwin.go +++ b/client/internal/certproof/keychain_darwin.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "os" "sync" "unsafe" @@ -32,6 +33,9 @@ var ( secCertificateCopyData func(cert uintptr) uintptr secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr + secKeychainCopySearchList func(searchList *uintptr) int32 + secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32 + 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 @@ -71,19 +75,33 @@ func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) { log.Warnf("skipping keychain identity: %v", err) return false, nil } + log.Infof("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 || len(leaves) == 0 { + 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() if err != nil { return nil, err } + if len(leaves) == 0 { + log.Infof("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.Infof("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 { - candidates = append(candidates, Candidate{Chain: buildChain(leaf, pool), Signer: &keychainSigner{leaf: leaf}}) + chain := buildChain(leaf, pool) + log.Infof("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.Infof("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}}) } return candidates, nil } @@ -103,6 +121,8 @@ func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts if err != nil { return nil, err } + log.Infof("signing certificate posture challenge with keychain key of %q", s.leaf.Subject) + algorithm := keychainAlgorithm(scheme) var signature []byte err = eachIdentity(func(identity uintptr, der []byte) (bool, error) { @@ -118,6 +138,7 @@ func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts if signature == nil { return nil, errors.New("certificate is no longer in the keychain") } + log.Infof("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature)) return signature, nil } @@ -153,7 +174,7 @@ func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error } func eachIdentity(fn func(identity uintptr, der []byte) (bool, error)) error { - return eachMatching(kSecClassIdentity, func(identity uintptr) (bool, error) { + return eachMatching(kSecClassIdentity, "identity", func(identity uintptr) (bool, error) { var cert uintptr if status := secIdentityCopyCertificate(identity, &cert); status != 0 { return true, fmt.Errorf("SecIdentityCopyCertificate: %d", status) @@ -166,16 +187,20 @@ func eachIdentity(fn func(identity uintptr, der []byte) (bool, error)) error { func keychainCertificates() ([]*x509.Certificate, error) { var certs []*x509.Certificate - err := eachMatching(kSecClassCertificate, func(item uintptr) (bool, error) { + var unparsable int + err := eachMatching(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.Infof("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable) return certs, err } -func eachMatching(class uintptr, fn func(item uintptr) (bool, error)) error { +func eachMatching(class uintptr, name string, fn func(item uintptr) (bool, error)) error { keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef} values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue} query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks) @@ -185,12 +210,17 @@ func eachMatching(class uintptr, fn func(item uintptr) (bool, error)) error { switch status := secItemCopyMatching(query, &items); status { case 0: case errSecItemNotFound: + log.Infof("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound) return nil default: + log.Infof("keychain %s query returned OSStatus %d", name, status) return fmt.Errorf("SecItemCopyMatching: %d", status) } defer cfRelease(items) - for i, n := 0, cfArrayGetCount(items); i < n; i++ { + + n := cfArrayGetCount(items) + log.Infof("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 } @@ -209,10 +239,48 @@ func dataBytes(data uintptr) []byte { } func loadKeychain() error { - keychainOnce.Do(func() { keychainErr = resolveKeychain() }) + keychainOnce.Do(func() { + if keychainErr = resolveKeychain(); keychainErr != nil { + log.Infof("macOS keychain unavailable for certificate posture: %v", keychainErr) + return + } + log.Infof("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.Info("keychain search list diagnostics unavailable on this macOS version") + return + } + + var list uintptr + if status := secKeychainCopySearchList(&list); status != 0 { + log.Infof("SecKeychainCopySearchList returned OSStatus %d", status) + return + } + defer cfRelease(list) + + n := cfArrayGetCount(list) + log.Infof("keychain search list contains %d keychains", n) + for i := 0; i < n; i++ { + log.Infof("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("", status) + } + return string(path[:length]) +} + func resolveKeychain() error { security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL) if err != nil { @@ -277,5 +345,19 @@ func resolveKeychain() error { } *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.Infof("keychain diagnostics: %s unavailable: %v", name, err) + return + } + purego.RegisterFunc(ptr, symbol) +} diff --git a/client/internal/engine.go b/client/internal/engine.go index d5e4f094c..a19f319ff 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1293,10 +1293,10 @@ func (e *Engine) applyInfoFlags(info *system.Info) { } // attachCertificateProofs answers the certificate challenges in checks with the -// certificates found in the local store, signing each challenge nonce for our peer key. +// certificates reachable on this device, signing each challenge nonce for our peer key. func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) { peerKey := e.config.WgPrivateKey.PublicKey() - info.CertificateProofs = certproof.Collect(e.ctx, certproof.DefaultStore(), checks, peerKey[:]) + info.CertificateProofs = certproof.CollectProofs(e.ctx, checks, peerKey[:]) } func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info { diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 57ee9a258..0c8f3871d 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -1023,6 +1023,9 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta { Signature: p.Signature, }) } + if len(proofs) > 0 { + log.Infof("peer meta carries %d certificate posture proofs", len(proofs)) + } return &proto.PeerSystemMeta{ Hostname: info.Hostname,