mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 18:29:07 +02:00
read the console user's keychain through a user session helper
A root daemon cannot reach a login keychain: securityd is per session and a key ACL needs a session to prompt in, so dropping uid is not enough. The daemon now answers certificate challenges from the System keychain itself, where MDM installs device identities, and launches "netbird posture cert-proof" into the console user's desktop session with launchctl asuser for the login keychain. Only the signature and the chain cross back, never the private key. The console user comes from SCDynamicStoreCopyConsoleUser, bound with purego like the keychain calls. The login window reports no user, root, or "loginwindow", and all three are treated as no keychain to read, so a Mac at the lock screen sends device proofs alone. Adds info logging across the path: the keychain search list, per class query status and item counts, the chain built per candidate, and the verification error for every rejected candidate. A run that sends nothing now says why. README.md documents the trust model, the console user limitation and how to read the logs.
This commit is contained in:
@@ -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())
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <uid> sudo -u <user> -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. |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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("<SecKeychainGetPath: %d>", 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user