From e0ede59d689de183977167c09f824e9661458a4d Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sun, 13 Sep 2026 15:22:16 +0200 Subject: [PATCH] read the signed-in user's certificate store on Windows A service reads LocalMachine\MY, where AD and Intune enrol device certificates. CurrentUser\MY lives in the signed-in user's registry hive with keys protected against their profile, and a service that opens it does not fail: "current user" resolves to HKU\S-1-5-18, so it silently reads the service account's own empty store. The service therefore reads the machine store itself and launches "netbird posture cert-proof" with the session token for the rest, mirroring the macOS console user helper. Windows lets a privileged service assume a user identity, so the token goes straight into the child process and no external tooling is involved. CREATE_NO_WINDOW keeps a console window from flashing on the desktop every sync. In-process impersonation would also work but is per OS thread while goroutines migrate, so the child process avoids that class of bug. Session selection prefers the physical console and falls back to any active session, so remote desktop and VDI hosts are covered. WTSQueryUserToken needs SE_TCB_NAME, so a user-run client skips the helper and reads the machine store alone. SystemStore takes a store location, gaining NewUserStore alongside NewSystemStore and the per candidate logging macOS already had. The request building and proof merging move to helper_spawn.go, shared by both platforms, and helperStore picks what the helper reads per platform. --- client/internal/certproof/README.md | 75 +++++++++-- client/internal/certproof/collect_darwin.go | 54 +------- client/internal/certproof/collect_other.go | 12 +- client/internal/certproof/collect_windows.go | 101 ++++++++++++++ .../certproof/consoleuser_darwin_test.go | 31 ----- .../internal/certproof/desktopuser_windows.go | 126 ++++++++++++++++++ client/internal/certproof/helper.go | 2 +- client/internal/certproof/helper_spawn.go | 63 +++++++++ .../internal/certproof/helper_spawn_test.go | 56 ++++++++ .../internal/certproof/systemstore_windows.go | 63 ++++++--- 10 files changed, 468 insertions(+), 115 deletions(-) create mode 100644 client/internal/certproof/collect_windows.go create mode 100644 client/internal/certproof/desktopuser_windows.go create mode 100644 client/internal/certproof/helper_spawn.go create mode 100644 client/internal/certproof/helper_spawn_test.go diff --git a/client/internal/certproof/README.md b/client/internal/certproof/README.md index d1f600de8..10a20cab8 100644 --- a/client/internal/certproof/README.md +++ b/client/internal/certproof/README.md @@ -14,9 +14,16 @@ to one WireGuard peer key and cannot be replayed by another peer. | --- | --- | --- | | 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 | +| Windows | `LocalMachine\MY` | the service, directly | +| Windows | signed-in user's `CurrentUser\MY` | a helper launched with that session's token | | Linux and others | PEM directory, `NB_CERT_STORE_DIR` or `/etc/netbird/certs` | the daemon, directly | +macOS and Windows both keep per-user certificates out of reach of a privileged daemon, +and both are handled the same way: the daemon reads the machine store itself and +launches `netbird posture cert-proof` as the signed-in user for the rest. Only the +signature and the chain come back. The helper, the request and response types and the +subcommand are shared; only the way the child is launched differs. + ## macOS: why the daemon cannot read a login keychain The daemon runs as root from a LaunchDaemon. Its keychain search list is the System @@ -55,23 +62,63 @@ signature and the certificate chain come back.** `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 +## Windows: the service and the signed-in user + +`LocalMachine\MY` is what the service reads, and it is where AD and Intune enrol device +certificates. `CurrentUser\MY` lives in the signed-in user's registry hive with private +keys protected by DPAPI against their profile, so it is only readable while running as +that user. + +The failure mode differs from macOS in an important way: a service that opens +`CURRENT_USER` does **not** get an error. "Current user" resolves to the service +account's own hive, `HKU\S-1-5-18`, so it silently reads an empty and irrelevant store. +There is nothing to log. That is why the service only ever opens `LocalMachine` and asks +a helper for the rest. + +Windows does let a privileged service assume a user identity, which macOS does not for +keychains, so no external tooling is involved: + +```go +windows.WTSQueryUserToken(session, &token) +cmd.SysProcAttr = &syscall.SysProcAttr{Token: syscall.Token(token), CreationFlags: windows.CREATE_NO_WINDOW} +``` + +`CREATE_NO_WINDOW` matters: without it a console window flashes on the user's desktop on +every sync. + +Session selection prefers the physical console, then falls back to any active session, +so remote desktop and VDI hosts work. `WTSQueryUserToken` needs `SE_TCB_NAME`, which +LocalSystem holds and an ordinary process does not, so a user-run `netbird up` skips the +helper and reads the machine store alone. + +In-process impersonation would also work, but it is per-OS-thread while goroutines +migrate freely, so it would need `runtime.LockOSThread` around every key operation. The +child process avoids that class of bug entirely. + +Unlike macOS, the Windows store acquires keys with `CRYPT_ACQUIRE_SILENT_FLAG`, so a key +that would need a prompt fails immediately instead of blocking. That also means a +smartcard PIN can never be satisfied this way. + +## Only the signed-in 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: +A proof from a user store can only ever be produced for **the user whose 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. +- **At the sign-in screen there is no user proof.** macOS reports no console user or + attributes the console to root, and `CurrentConsoleUser` returns false for both. + Windows reports no active session with a token. Only machine proofs are sent, so a + posture check that demands a user certificate fails on a machine nobody has signed + into yet. +- **Signing out changes the answer.** Posture can flip between compliant and + non-compliant across a sign-out, so management should treat "no proof" as its own + state rather than as a failed check, or users get disconnected at the sign-in screen. +- **One session is asked, not all of them.** macOS asks the console user, so other + fast-user-switched accounts are skipped even though their keychains are unlocked. + Windows prefers the console and otherwise takes the first active session. If you ever + need every signed-in user, both platforms would have to enumerate sessions and ask + each one. - **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 diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go index 135d4a882..342229180 100644 --- a/client/internal/certproof/collect_darwin.go +++ b/client/internal/certproof/collect_darwin.go @@ -3,8 +3,6 @@ package certproof import ( "bytes" "context" - "crypto/sha256" - "crypto/x509" "encoding/json" "fmt" "os" @@ -91,52 +89,8 @@ func collectAsConsoleUser(ctx context.Context, challenges []*proto.CertificateCh 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) +// helperStore is the store the helper reads. On macOS the keychain search list of the +// user's own session already is that user's keychain, so the platform default is right. +func helperStore() Store { + return DefaultStore() } diff --git a/client/internal/certproof/collect_other.go b/client/internal/certproof/collect_other.go index e867dc4fc..0891743ec 100644 --- a/client/internal/certproof/collect_other.go +++ b/client/internal/certproof/collect_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows package certproof @@ -10,8 +10,14 @@ import ( ) // 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. +// Only macOS and Windows keep per-user certificates out of reach of a privileged +// daemon, so every other platform reads its store in the daemon itself. func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte) []certposture.Proof { return Collect(ctx, DefaultStore(), checks, peerKey) } + +// helperStore is the store the helper reads. Nothing launches a helper on these +// platforms, so it is the platform default. +func helperStore() Store { + return DefaultStore() +} diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go new file mode 100644 index 000000000..f83e3177b --- /dev/null +++ b/client/internal/certproof/collect_windows.go @@ -0,0 +1,101 @@ +package certproof + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + + "github.com/netbirdio/netbird/shared/management/certposture" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const helperTimeout = 30 * time.Second + +// CollectProofs answers the certificate challenges in checks from every store this +// machine can reach. The service reads the local machine store itself, where AD and +// Intune enrol device certificates, and reaches the signed-in user's store by launching +// a helper with that session's token. A machine at the sign-in screen therefore proves +// device certificates alone. +func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte) []certposture.Proof { + challenges := certificateChallenges(checks) + if len(challenges) == 0 { + logNoChallenges(checks) + return nil + } + + proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey) + + // The helper already runs as the signed-in user, and an ordinary process has no + // right to a session token, so only the service goes looking for one. + if !runningAsLocalSystem() { + return proofs + } + + userProofs, err := collectAsDesktopUser(ctx, challenges, peerKey) + if err != nil { + log.Infof("certificate posture: user certificate store unavailable: %v", err) + } + return mergeProofs(proofs, userProofs) +} + +// helperStore is the store the helper reads. It runs as the signed-in user, so it wants +// that user's store rather than the machine store the service already read. +func helperStore() Store { + return NewUserStore() +} + +// collectAsDesktopUser runs the helper inside the interactive session of the signed-in +// user. Unlike a keychain on macOS, a Windows service can assume a user identity +// directly, so the session token goes straight into the child process. +func collectAsDesktopUser(ctx context.Context, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { + user, ok := CurrentDesktopUser() + if !ok { + return nil, nil + } + defer user.Close() + + 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() + + cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof") + cmd.SysProcAttr = &syscall.SysProcAttr{ + Token: syscall.Token(user.Token), + HideWindow: true, + CreationFlags: windows.CREATE_NO_WINDOW, + } + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + log.Infof("certificate posture: asking the session of %q (session %d) to answer %d challenges", user.Name, user.Session, 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: session of %q returned %d proofs", user.Name, len(resp.Proofs)) + return resp.Proofs, nil +} diff --git a/client/internal/certproof/consoleuser_darwin_test.go b/client/internal/certproof/consoleuser_darwin_test.go index 8e46f6a49..a3ecba217 100644 --- a/client/internal/certproof/consoleuser_darwin_test.go +++ b/client/internal/certproof/consoleuser_darwin_test.go @@ -1,14 +1,9 @@ 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) { @@ -42,29 +37,3 @@ func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) { 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/desktopuser_windows.go b/client/internal/certproof/desktopuser_windows.go new file mode 100644 index 000000000..ae6a7221b --- /dev/null +++ b/client/internal/certproof/desktopuser_windows.go @@ -0,0 +1,126 @@ +package certproof + +import ( + "fmt" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + noActiveSession = 0xFFFFFFFF + + // wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE and wtsActive is WTSActive of + // WTS_CONNECTSTATE_CLASS. Neither is exported by x/sys/windows. + wtsCurrentServer = windows.Handle(0) + wtsActive = 0 +) + +// DesktopUser is an interactive session and the account signed into it. The user's +// certificate store is readable only from a process running as that account, because +// its private keys are protected against the user profile rather than the machine. +type DesktopUser struct { + Session uint32 + Name string + Token windows.Token +} + +// Close releases the session token. +func (u DesktopUser) Close() { + if err := u.Token.Close(); err != nil { + log.Debugf("failed closing desktop session token: %v", err) + } +} + +// CurrentDesktopUser returns a token for the interactive user whose certificate store +// should be asked. The physical console comes first, and an active remote desktop +// session is used when nobody is at the console, which is how servers and VDI hosts are +// normally reached. The second return is false at the sign-in screen, where no +// interactive session exists and only machine certificates can be proven. +// +// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an +// ordinary process does not. +func CurrentDesktopUser() (DesktopUser, bool) { + if session := windows.WTSGetActiveConsoleSessionId(); session != noActiveSession { + if user, ok := desktopUser(session); ok { + return user, true + } + log.Infof("console session %d has nobody signed in, looking for an active remote session", session) + } + + sessions, err := activeSessions() + if err != nil { + log.Infof("cannot enumerate terminal sessions: %v", err) + return DesktopUser{}, false + } + for _, session := range sessions { + if user, ok := desktopUser(session); ok { + return user, true + } + } + + log.Info("no interactive session is signed in, no user certificate store is reachable") + return DesktopUser{}, false +} + +func desktopUser(session uint32) (DesktopUser, bool) { + var token windows.Token + if err := windows.WTSQueryUserToken(session, &token); err != nil { + log.Debugf("no user token for session %d: %v", session, err) + return DesktopUser{}, false + } + + name, err := tokenAccount(token) + if err != nil { + log.Infof("session %d token has no readable account: %v", session, err) + if closeErr := token.Close(); closeErr != nil { + log.Debugf("failed closing session token: %v", closeErr) + } + return DesktopUser{}, false + } + return DesktopUser{Session: session, Name: name, Token: token}, true +} + +func tokenAccount(token windows.Token) (string, error) { + user, err := token.GetTokenUser() + if err != nil { + return "", fmt.Errorf("read token user: %w", err) + } + account, domain, _, err := user.User.Sid.LookupAccount("") + if err != nil { + return "", fmt.Errorf("look up account: %w", err) + } + if domain == "" { + return account, nil + } + return domain + `\` + account, nil +} + +func activeSessions() ([]uint32, error) { + var info *windows.WTS_SESSION_INFO + var count uint32 + if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil { + return nil, fmt.Errorf("enumerate sessions: %w", err) + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info))) + + var active []uint32 + for _, session := range unsafe.Slice(info, count) { + if session.State == wtsActive { + active = append(active, session.SessionID) + } + } + return active, nil +} + +// runningAsLocalSystem reports whether this process is the service. The helper runs as +// the signed-in user and must read its own store rather than launching another helper. +func runningAsLocalSystem() bool { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + log.Debugf("failed reading own token user: %v", err) + return false + } + return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid) +} diff --git a/client/internal/certproof/helper.go b/client/internal/certproof/helper.go index d47b6de24..26d719044 100644 --- a/client/internal/certproof/helper.go +++ b/client/internal/certproof/helper.go @@ -36,7 +36,7 @@ type HelperResponse struct { // 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) + return runHelper(ctx, helperStore(), in, out) } func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error { diff --git a/client/internal/certproof/helper_spawn.go b/client/internal/certproof/helper_spawn.go new file mode 100644 index 000000000..ccf0567a5 --- /dev/null +++ b/client/internal/certproof/helper_spawn.go @@ -0,0 +1,63 @@ +//go:build darwin || windows + +package certproof + +import ( + "crypto/sha256" + "crypto/x509" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/shared/management/certposture" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest { + req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))} + for _, challenge := range challenges { + req.Challenges = append(req.Challenges, HelperChallenge{ + Nonce: challenge.GetNonce(), + CACertificates: challenge.GetCaCertificates(), + }) + } + return req +} + +// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that +// both stores 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: user proof carries an unparsable leaf: %v", err) + return + } + log.Infof("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer) +} diff --git a/client/internal/certproof/helper_spawn_test.go b/client/internal/certproof/helper_spawn_test.go new file mode 100644 index 000000000..5356a9b00 --- /dev/null +++ b/client/internal/certproof/helper_spawn_test.go @@ -0,0 +1,56 @@ +//go:build darwin || windows + +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" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + shared := ca.Issue(t, certtest.ECDSAKey(t), "shared") + userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only") + + device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}} + user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}} + + merged := mergeProofs(device, user) + + require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped") + assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place") + assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended") +} + +func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}} + + merged := mergeProofs(device, nil) + + require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof") + assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged") +} + +func TestHelperRequest_CarriesEveryChallenge(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + challenges := []*proto.CertificateChallenge{ + {Nonce: []byte("first"), CaCertificates: []string{ca.PEM}}, + {Nonce: []byte("second")}, + } + + req := helperRequest(challenges, peerKey) + + require.Len(t, req.Challenges, 2, "every challenge must reach the helper") + assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine") + assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged") + assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged") + assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs") +} diff --git a/client/internal/certproof/systemstore_windows.go b/client/internal/certproof/systemstore_windows.go index 62e86e438..0a91aa557 100644 --- a/client/internal/certproof/systemstore_windows.go +++ b/client/internal/certproof/systemstore_windows.go @@ -46,35 +46,66 @@ func DefaultStore() Store { return NewSystemStore() } -// SystemStore yields the identities of the local machine's personal store, completing -// their chains from the intermediate CA store. Keys are used through CNG and never exported. -type SystemStore struct{} +// SystemStore yields the identities of a personal certificate store, completing their +// chains from the matching intermediate CA store. Keys are used through CNG and never +// exported. +// +// The location decides whose certificates these are. The local machine store is the one +// a service reads; the current user store lives in the signed-in user's registry hive +// with keys protected against their profile, so it is only readable while running as +// that user. +type SystemStore struct { + location uint32 +} +// NewSystemStore reads the local machine store, which is what the daemon uses. func NewSystemStore() *SystemStore { - return &SystemStore{} + return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE} +} + +// NewUserStore reads the calling user's personal store. It is only useful in a process +// already running as that user, which is what the posture helper is. +func NewUserStore() *SystemStore { + return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER} } func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) { - leaves, err := storeCertificates(personalStore) - if err != nil || len(leaves) == 0 { - return nil, err - } - intermediates, err := storeCertificates(intermediateStore) + leaves, err := storeCertificates(s.location, personalStore) if err != nil { return nil, err } + intermediates, err := storeCertificates(s.location, intermediateStore) + if err != nil { + return nil, err + } + log.Infof("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates)) + if len(leaves) == 0 { + return nil, nil + } + pool := slices.Concat(intermediates, leaves) candidates := make([]Candidate, 0, len(leaves)) for _, leaf := range leaves { - candidates = append(candidates, Candidate{Chain: buildChain(leaf, pool), Signer: &systemStoreSigner{leaf: leaf}}) + chain := buildChain(leaf, pool) + log.Infof("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain)) + candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}}) } return candidates, nil } +// String names the store location the way the Windows documentation does. +func (s *SystemStore) String() string { + if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER { + return "CurrentUser" + } + return "LocalMachine" +} + // systemStoreSigner holds only the certificate; the store entry and its key are acquired // at signing time so no handles outlive a call. type systemStoreSigner struct { - leaf *x509.Certificate + leaf *x509.Certificate + location uint32 } func (s *systemStoreSigner) Public() crypto.PublicKey { @@ -86,7 +117,7 @@ func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerO if err != nil { return nil, err } - store, err := openStore(personalStore) + store, err := openStore(s.location, personalStore) if err != nil { return nil, err } @@ -164,8 +195,8 @@ func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byt return result, nil } -func storeCertificates(name string) ([]*x509.Certificate, error) { - store, err := openStore(name) +func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) { + store, err := openStore(location, name) if err != nil { return nil, err } @@ -184,12 +215,12 @@ func storeCertificates(name string) ([]*x509.Certificate, error) { return certs, err } -func openStore(name string) (windows.Handle, error) { +func openStore(location uint32, name string) (windows.Handle, error) { namePtr, err := windows.UTF16PtrFromString(name) if err != nil { return 0, err } - flags := uint32(windows.CERT_SYSTEM_STORE_LOCAL_MACHINE | windows.CERT_STORE_READONLY_FLAG | windows.CERT_STORE_OPEN_EXISTING_FLAG) + flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG) store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr))) if err != nil { return 0, fmt.Errorf("open %s certificate store: %w", name, err)