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)