[client, management] Harden the certificate posture client and keep challenge nonces fresh (#8052)

* implement certificate posture check

* log signal address

* add keychain and cert store support

* 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.

* 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.

* start TPM support

* split goreleaser to support pkcs11 and exclude on docker

* update goreleaser

* go mod tidy

* add tpm pin to netbird config

* split cert and key location and allow key lookup on tpm

* add unsupported flag for mobile devices

* Isolate the cert proof helper from the service environment and cap its output

* Read the PKCS#11 token PIN from NB_TPM_PIN instead of the profile config

* Bound certificate proof collection so a stuck token or keychain cannot hold the sync loop

* Stop retrying a PKCS#11 PIN the token rejected

* Log certificate posture details at debug level

* Sign only nonces and peer keys of the size management issues

* Skip certificate files whose key belongs to another certificate

* Bound PKCS#11 driver sizes, pin template values, and log out only a login the session owns

* Never pass NULL to CFRelease and skip unreadable keychain identities

* Keep the macOS keychain code out of iOS and the PKCS#11 driver out of Android

* Find a chain to each challenge's CAs through every intermediate the store holds

* Require a token label whenever a PKCS#11 PIN is set

* Read user certificates only from the session of the active profile's owner

* Collect certificate proofs again when the owner's session changes and report lost proofs

* Test the PKCS#11 build against SoftHSM in CI and warn once where the build has no driver

* Document where an inline PKCS#11 PIN is stored and how it is protected

* Refuse PKCS#11 URIs that this client cannot honour instead of widening the match

* Trust certificate and key files only when no other user can write or redirect them

* Explain a Windows certificate whose key only a legacy CryptoAPI provider holds

* Use platform absolute module paths in tests and add a real owner session test for Windows

* Match the Windows profile owner by name instead of resolving it through the domain controller

* Keep the certificate stores and TPM library out of the WebAssembly build

* [client] Read TSS2 key files on go-tpm, checked against the library it replaces

The TSS2 parser was the only reason this repository depended on a crypto suite
whose own build tooling it inherits. The replacement sits on go-tpm, which was
already a direct dependency and is in fact what that suite calls underneath, so
this removes a wrapper rather than porting onto a different library: the load,
the derived storage root key and the signing commands are the same calls.

Swapping a parser on the one path a customer actually runs is not something to
assert, so the two are held side by side for this commit. One test feeds the
replacement bytes the old library wrote and requires the same key type, empty
auth flag, parent handle, blobs and decoded public key; the other feeds both the
fixtures the tests are built on, so those are the shape the format calls for and
not merely the shape the new parser reads. The scaffolding goes away with the
dependency in the commit that follows.

The encoder behind the fixtures is written out separately from the parser under
test, so an encoder bug and a decoder bug cannot cancel each other out.

* [client] Drop go.step.sm/crypto and the repo-wide upgrades it imposed

The TSS2 parser was the only thing in the repository that used this module, and
it brought 302 modules into the graph to do it — 35 of them linters, along with
Google Cloud KMS and IAM, the AWS SDK and a terminal styling library. Those are
the module's own development dependencies, which minimal version selection turns
into floors in ours, and they are the whole reason gRPC, protobuf, the AWS SDK,
OpenTelemetry, logrus and five x/ packages had moved. Management, signal, relay
and proxy inherited every one of them for a feature none of them runs.

Removing the import is not enough, because tidy never downgrades: the raised
floors stay written in go.mod. Each one is pinned back to the version main had,
then tidy is left to raise again whatever something still genuinely needs. It
raised nothing: all 43 are back where they were, and go-tpm was already in the
graph at the same version, so the certificate feature now costs no new module at
all.

The differential tests go with it. They existed to check the swap against the
library while both were present, and there is nothing left to compare against.

* [client] Clear the lint findings only the macOS and Windows runners see

golangci-lint analyses one build at a time, so running it on Linux says nothing
about the two platforms CI also lints. Against those builds the feature's
packages reported eight findings, and the structural one is Config.dir: it is
dead on macOS and Windows because neither reads a directory at all, their
collectors take the configuration and discard it. Moving the method beside its
only callers makes that visible in the layout instead of in a linter, and leaves
the gap itself — no file or token store on those platforms — where it belongs,
as something to decide rather than something to silence.

An absent key file beside a certificate was reported as a nil signer with a nil
error, which the caller then had to recognise by its nilness. It is a sentinel
now, so the meaning is in the error rather than in the absence of one.

The rest follow the standard library: the elliptic coordinates and the private
scalar come from the encoding helpers rather than the deprecated big.Int fields,
and an error string loses its trailing colon.

Lint is clean on linux, darwin and windows; the hardware TPM path was exercised
separately against a real device and passes.

* Accept the TSS2 emptyAuth boolean OpenSSL writes and persistent parents on 32-bit builds

* Count the certificates field in the peer meta store test

* Check the store directory before listing it, refuse group-writable files, and reject a URI with two PIN sources

* Share a PKCS#11 login between sessions and send each PIN at most once at a time

* Collect certificate proofs again when the meta sync carrying them failed

* Use no Windows user store when a domainless owner matches accounts of several domains

* Use no user certificate store when the active profile's owner cannot be read

* Document the PIN sources on CertPKCS11URI and keep the README PIN example off the command line

* Test that the PKCS#11 URI stays out of the debug bundle and run the wrong-PIN test only on a disposable token

* Refuse a TPM PSS signature request for the maximum salt length

* Add the certificate fields to the network map golden data

* Retry posture checks whose meta sync timed out instead of dropping them

* Start no system info gathering while a timed-out one is still running

* Guard the applied posture checks across goroutines and keep refreshing proofs while a pending update times out

* Log what a successful certificate proof helper wrote to stderr

* Send recollected certificate proofs to management only when the proven chains changed

* Explain a macOS keychain key whose access list does not allow netbird

* Kill the whole macOS certificate helper process group when it times out

* End sudo option parsing before the macOS certificate helper binary

* Hold off system info gathering only while a timed-out one is still running

* Collect certificate proofs on the posture watcher instead of under the sync lock

* Read the certificate store directory and PKCS#11 URI from the daemon environment, not the profile config

* Install the RPM sysconfig file readable by root only and show the certificate posture variables

* Move the certificate posture README into the package doc and the docs site

* Name NB_CERT_PKCS11_URI in the PIN-without-token error

* Keep the file check results of the latest-started system info refresh

* Give the full import command for a keychain key netbird may not use, and correct the package doc

* Restrict the service environment file to root on every package install

* Search only the System keychain in the macOS daemon and only the login keychain in the user helper

* Let the certificate proof helper read the PKCS#11 token from the environment on Linux

* Ask a macOS user's keychain again only after an hour when it proved nothing

* Clear the lint findings in certificate posture

* Hold off the keychain helper only after a completed or timed-out run, independent of CA order

* Keep free functions out of the method lists of PKCS11Store, URI and Challenger

* Name the post-install permission helper in snake case and shorten the sysconfig certificate block

* Drop the certificate store directory from certproof.Config, which only NB_CERT_STORE_DIR sets

* [management] Renew certificate challenge nonces on quiet accounts

A certificate challenge nonce is accepted for its own window and the one before
it, and it only reaches a peer attached to a network map. An account where
nothing changes sends no map, so after a day the peer re-sends the nonce it
still holds, verification rejects its whole proof set, and the certificates
stored for it are dropped. It fails the certificate check and loses every policy
gated on it until some unrelated change happens to push a map. The outage
repairs itself in seconds, which is what makes it expensive: it is intermittent,
it only hits stable networks, and it is not reproducible on demand.

Push the account's peers an update often enough that the nonce they hold is
never close to expiring. Only accounts whose posture checks actually ask for a
certificate are tracked, so a deployment without the feature does no extra work.

The refresh runs from one goroutine over a map of accounts rather than a timer
per account: the period is hours, so one pass every few minutes costs nothing
next to it, and there is no timer to re-arm when an account that falls due
sooner appears. Each account's first run is offset by a hash of its ID, because
the challenge window is global and an instance restart would otherwise arm every
account in the same moment.

The push carries no administrative change, so it is counted as a refresh rather
than an update and stays out of the figures that track what was edited.

(cherry picked from commit 7ad4a0df37)

* [management] Make the certificate challenge window one knob to turn

Renewal was timed against the window in two different ways: the period derived
from it, the sweep interval did not. Shortening the window to watch a renewal in
an end-to-end run would have left the refresher still looking for due accounts
every quarter of an hour, so nothing would have been renewed in time and the
test would have reported the feature broken.

Derive the sweep from the period, within bounds that keep a very short window
from spinning and a normal one from checking less often than is useful, and
allow the window itself to be set through the environment so a run can take
seconds instead of half a day. A value that cannot be parsed or falls outside
the bounds keeps the default, because a window nobody intended is a security
property nobody chose, and an override is logged at warning level since it sets
how long a device keeps passing the check after its key is gone.

Every instance has to be given the same value: the window is part of the nonce,
so instances that disagree reject each other's.

(cherry picked from commit 0e38fcf409)

* [management] Pin the property that makes per-peer nonce state unnecessary

A nonce carries the window it was minted in, not the instant, and is accepted
for that window and the one before it. So a peer re-stamped at least once per
window can never be left holding one outside the accepted pair, whenever it was
last served and however much life its own nonce had left. That is the whole
reason management tracks nothing per peer, and it was resting on an argument
rather than a test.

The phases are part of the property, not decoration: accounts are deliberately
given a refresh phase of their own, so the guarantee has to hold off the window
boundary too. The negative case shows why that matters — a cadence of exactly
two windows lands inside the grace window when it is aligned to the boundary and
leaves a gap when it is not.

(cherry picked from commit dee68facfd)

* [management] Renew challenges only for the peers that answer one

The refresh pushed an update to every connected peer of the account, while only
the peers a certificate check applies to carry a nonce. On an account where a
handful of peers sit behind the check and the rest do not, everyone was woken
several times a day to be handed a map that changed nothing for them.

Push to the sources of the enabled policies whose posture checks include a
certificate check, which is exactly the set that is sent a challenge.

Resolving the set the other way round than the gRPC layer does is the risk here:
a peer the refresh forgets stops being renewed and falls out of its policies
silently, which is the failure this whole mechanism exists to prevent. So the
selection is held against processPeerPostureChecks, the per-peer rule that
decides who receives a challenge in the first place, by a test that asks both
the same question and requires the same answer.

(cherry picked from commit dc4d0e0274)

* [management] Derive certificate challenge nonces from the stored encryption key

The nonce secret came from the server's WireGuard key, which is generated afresh
in every process and never persisted. A nonce carries no state, so the only
thing that lets one instance verify what another issued is deriving the same
secret — and that premise, written in the comment above the challenger, was not
met: every instance had its own key.

A peer reconnecting after a restart therefore presented a nonce minted under the
previous secret, verification failed with a mismatch, its whole proof set was
rejected and the certificates stored for it were dropped until it signed again.
Reproduced three times on the lab, each one logging "nonce was not issued to
this peer", which only a changed secret produces. On a single instance it costs
seconds of lost policy access per restart; across instances it is not transient
at all, because every reconnect that lands elsewhere is rejected the same way.

Derive from the data store encryption key instead: it is generated once, written
back to the configuration and read by every instance, so it survives restarts
and is shared. Where none is configured the secret falls back to the WireGuard
key with a warning — degraded but still unpredictable, which is the property
that matters most: a peer able to guess it could mint the nonces of future
windows, sign them while its key is present and keep passing after it is gone.

The challenger is now built once and passed to the two places that need it,
rather than re-derived per message.

(cherry picked from commit 278f2f3807)

* [management] Register an account for renewal where its nonce is issued

Renewal was armed when a peer connected or when a posture check was saved, both
of which ask the store whether the account has a certificate check. That misses
the case it most needs to catch: the check is created through one instance while
the peers are connected to another, so the instance serving them never learns it
has anything to renew and their nonce expires. It also charged a query to every
peer connect in every account, including the ones that will never use the
feature, which a fleet reconnecting after a restart pays all at once.

Register where the nonce is actually stamped instead. A nonce is verified from a
shared secret and so travels between instances, but the renewal that keeps it
fresh cannot: only the instance holding a peer's stream can push to it. Issuing
and renewing now line up by construction — an instance renews exactly the
accounts it has issued nonces for — and an instance that never issues one has
nothing to renew, so there is no case left to miss.

The registration is a map insert with no store access, which is what lets it sit
on a path taken by every login and every initial sync.

Reported by Viktor Liu, who also proposed registering at the point of issue.

(cherry picked from commit 2d16dd7d7cf54762f2e64c5630ea092f32ef63ab)

* [management] Register for renewal on pushed updates, not only on connect

Registering where the nonce is stamped only covered the login and the initial
sync, which both happen when a peer opens a stream. That left out the path the
mechanism exists for.

On the cloud the network map controller is wrapped so that an update publishes
to an event bus instead of pushing locally: an instance handling a REST change
broadcasts, and every instance holding a peer of that account pushes to its own.
Those pushes stamp a nonce through the update handler, and nothing there
registered, so an instance learned about an account only when one of its peers
happened to reconnect. For a quiet fleet that is the original bug: the check is
created, the peers are told about it, and nobody renews what they were told.

Registering on the pushed update closes it, and is the difference between
stamping and marking a peer connected — one happens on every push, the other
only when a stream opens. Reported by Viktor Liu; the broadcast that makes it
work was pointed out by Pascal Fischer.

(cherry picked from commit 59efe8d93e53bacdf57cb546f4ab2c19dc4eddab)

* [management] Let the challenge refresh loop stop with the manager that owns it

The loop was started on a context explicitly detached from the caller's, so
nothing could ever stop it. Production is unaffected either way, since
BuildManager is called with context.Background(), but a test that builds a
manager leaked a sweeping goroutine for the rest of the run, and a shutdown
path added later would have had no way to reach it.

Take the manager's context as the request buffer built on the line above
already does. The test pins the contract the loop offers, so a detached
context cannot come back inside Start either.

* [management] Bound one account's challenge refresh so it cannot starve the rest

Resolving which peers answer a challenge reads the store three times, and the
refresher sweeps accounts one after another on a single goroutine. A read that
never returns held the sweep for the life of the process, so every other
account on the instance stopped being renewed and its peers fell out of the
policies gated on the check: one account's bad luck became an outage for all
of them.

Give each refresh the sweep interval it is allowed to occupy, capped at 30s so
a 12-hour window does not grant minutes to a query that should take
milliseconds. A refresh that runs out of time keeps its account tracked, since
a deadline says nothing about whether that account still has a certificate
check.

* [management] Send challenge refreshes down the path the rest of management uses

The refresh dispatched through UpdateAffectedPeers, the one variant that takes
no reason, so it was missing from the update counters and coalesced with
nothing. An administrator editing a policy while the sweep ran made the
account's network map twice over, and UpdateOperationRefresh, added for
exactly this caller, was never referenced.

Buffer it with a posture_check/refresh reason instead. The periodic push is
now visible in the metrics as what it is, distinct from an edit, and the send
detaches from the sweep deadline on its own, so that deadline bounds the store
reads it was meant for.

* [management] Keep the certificate challenge comments to what the history does not say

Four of these ran to three and four times the comment budget, the longest at
992 characters. Most of the excess argued against designs that were never
written or explained a bug that no longer exists in the code, which is what
the commit that fixed it is for.

What is left is the part a reader cannot recover from the code: that the
nonce secret has to be persisted and unpredictable, that stamping and
renewing are decided together because only the serving instance can push, and
that the target rule is the inverse of processPeerPostureChecks.

* Keep the newest posture checks pending whatever made their meta sync fail

* Report no lost certificate when the engine stops during a proof collection

* Share the proof collection single-flight across engine restarts

* Close a PKCS#11 module that loads but cannot be used

* Fix the pending checks comments

* Renew certificate challenges only for the peers streamed to this instance

* Ignore a challenge stamp from an older sync stream of the same peer

* Kill the Windows certificate proof helper with its whole process tree

* Expect the challenge untrack in the session ownership test

* Drop an invalid certificate proof without discarding the valid ones

* Start a system info gathering beside one that has been stuck for ten timeouts

---------

Co-authored-by: pascal <pascal@netbird.io>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Viktor Liu
2026-10-09 15:42:18 +02:00
committed by GitHub
co-authored by pascal mlsmaycon riccardom
parent 53a14551c8
commit f0a40e4395
98 changed files with 5491 additions and 899 deletions
-277
View File
@@ -1,277 +0,0 @@
# 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 | `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: `CertStoreDir` in the profile config, else `NB_CERT_STORE_DIR`, else `/etc/netbird/certs` | the daemon, directly |
| Linux | a `TSS2 PRIVATE KEY` file in that directory, signed by the TPM | the daemon, through `/dev/tpmrm0` |
| Linux | a PKCS#11 token, tpm2-pkcs11 for one, enabled by `CertPKCS11PIN` in the profile config | the daemon, through the token's module, in builds with the `pkcs11` tag |
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
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.
## 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.
## Linux: keys held by the TPM
Enrollment tooling on Linux keeps a TPM-resident key as a `TSS2 PRIVATE KEY` PEM file,
the format of draft-bottomley-tpm2-keys that tpm2-openssl, tpm2-tss-engine and
`tpm2_encodeobject` write. The file holds the key wrapped by its parent; the TPM is the
only thing that can use it. Drop it next to the certificate as usual:
```
openssl genpkey -provider tpm2 -algorithm EC -pkeyopt group:P-256 -out /etc/netbird/certs/device.key
openssl req -provider tpm2 -provider default -new -key /etc/netbird/certs/device.key -subj /CN=device -out device.csr
```
Sign the CSR with the organisation CA and store the result as `device.pem`. The store
parses the key file without touching the TPM, so the certificate is listed as a
candidate like any other, and every signature opens `/dev/tpmrm0`, loads the key under
its parent, signs, flushes and closes again. `NB_TPM_DEVICE` overrides the device path.
What the key file may look like:
- **Parent.** A persistent handle such as `0x81000001` is used as is. The owner
hierarchy, which both tpm2-openssl and tpm2-tss-engine default to, means the key was
created under a transient primary from the TCG default ECC P-256 template, and that
same primary is derived again before loading.
- **No authorization value.** A key created with a password needs someone to type it,
which the daemon cannot arrange, so the certificate is skipped with a log line rather
than blocking on a TPM auth failure.
- **RSA-2048 or P-256, sometimes P-384.** Those are what the PC Client profile requires
of a TPM; P-384 depends on the chip. The TPM chooses the RSA-PSS salt itself, which is
why management verifies PSS proofs with `rsa.PSSSaltLengthAuto`.
Windows needs none of this: a certificate enrolled into the TPM sits behind the Microsoft
Platform Crypto Provider and the CNG path above signs with it unchanged. macOS has no
TPM; its Secure Enclave keys are reachable only through the keychain path.
To exercise the path without hardware, run a software TPM and point the end-to-end test
at it:
```
swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
NB_TPM_DEVICE=/tmp/swtpm.sock go test ./client/internal/certproof/ -run TestCollect_TPMKeyEndToEnd -v
```
## Linux: keys behind a PKCS#11 token
Distributions that follow Red Hat's guidance reach the TPM through tpm2-pkcs11, a PKCS#11
module whose token holds both the key and, after `tpm2_ptool addcert`, the certificate.
The store reads that token when the profile config, `/etc/netbird/config.json` by default,
carries the token's user PIN:
```json
"CertPKCS11PIN": "1234"
```
That alone opens the first token the p11-kit proxy exposes, which is tpm2-pkcs11 on a
stock setup that has registered it. `CertPKCS11URI`, an RFC 7512 URI, narrows that down
on a host with several tokens or without p11-kit:
```json
"CertPKCS11URI": "pkcs11:token=netbird?module-path=/usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so"
```
`token` selects the token by label, or the first token present when absent. `module-path`
names the library to load; `module-name=tpm2_pkcs11` resolves to `libtpm2_pkcs11.so` on
the loader's search path, and with neither the p11-kit proxy is loaded, which exposes every
module the system has registered. The URI may carry the PIN itself, as `pin-value` inline
or `pin-source` naming a file, and `CertPKCS11PIN` takes precedence over both. Without any
PIN no login happens, and tpm2-pkcs11 then shows no private keys at all. Every other
attribute is ignored.
The certificate may live on the token or in the PEM directory: `CertStoreDir` in the
profile config, else `NB_CERT_STORE_DIR`, else `/etc/netbird/certs`. On the token,
certificates and private keys are paired by `CKA_ID`,
which is what `tpm2_ptool addcert` and `pkcs11-tool` set. In the directory, a certificate
file without a key of its own is paired with the token key whose public key it carries, so
`device.pem` alone next to a key that only the TPM holds is enough; the token's public key
object, which `tpm2_ptool addkey` and `import` create alongside the private one, is what
the store compares against. Chains are completed from the certificates on the token and in
the directory together, so intermediates may sit in either place.
Each operation opens a session, logs in, works, logs out and closes, so no token handle
outlives a call, and the PEM directory keeps working when the token does not: the two are
queried together and a failing token is logged rather than hiding file certificates.
Two consequences of the PIN are worth knowing. It is a secret on disk, which the profile
config already is: it holds the WireGuard private key and is written readable by root
alone, and the debug bundle's config dump leaves `CertPKCS11PIN` out. And a wrong PIN
counts against the TPM's dictionary-attack lockout, which is shared with everything else
on the machine that uses the TPM.
The module is loaded at runtime without cgo, through `purego`, which means the binary is
dynamically linked against libc. The store is therefore compiled in only with `-tags pkcs11`
on linux/amd64 and linux/arm64: the deb and rpm packages are built that way, since they
target glibc distributions, while the release tarballs and the Alpine-based container
images keep the fully static build. Without the tag, setting `CertPKCS11PIN` logs that
the build lacks the support.
To exercise the path without hardware, initialise a SoftHSM token and run the end-to-end
test, which imports a key and certificate itself:
```
softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
```
## Only the signed-in user can be validated
This is the central limitation of the design, and it is deliberate.
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 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
"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. |
+65 -21
View File
@@ -2,10 +2,13 @@ package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
@@ -25,14 +28,24 @@ func Collect(ctx context.Context, store Store, checks []*proto.Checks, 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))
log.Debugf("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.
// Only nonces of the size management issues are signed, for a peer key of the size of
// ours, so the keys behind the store never sign arbitrary caller-chosen data.
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)
if len(peerKey) != wgtypes.KeyLen {
log.Warnf("certificate posture: refusing to sign for a %d byte peer key", len(peerKey))
return nil
}
challenges = wellFormed(challenges)
if len(challenges) == 0 {
return nil
}
log.Debugf("certificate posture: answering %d certificate challenges from store %T", len(challenges), store)
candidates, err := store.Candidates(ctx)
if err != nil {
@@ -40,10 +53,10 @@ func CollectChallenges(ctx context.Context, store Store, challenges []*proto.Cer
return nil
}
if len(candidates) == 0 {
log.Info("certificate posture: certificate store holds no candidates, no proof will be sent")
log.Debug("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))
log.Debugf("certificate posture: store holds %d candidate certificates", len(candidates))
now := time.Now()
proven := make(map[[sha256.Size]byte]struct{})
@@ -54,7 +67,7 @@ func CollectChallenges(ctx context.Context, store Store, challenges []*proto.Cer
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()))
log.Debugf("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 {
@@ -62,53 +75,84 @@ func CollectChallenges(ctx context.Context, store Store, challenges []*proto.Cer
continue
}
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)
chain, err := certposture.VerifiedChain(leaf, candidate.issuers(), roots, now)
if err != nil {
log.Debugf("certificate posture: challenge %d rejected %q issued by %q: %v", i, leaf.Subject, leaf.Issuer, err)
continue
}
matched = true
fingerprint := sha256.Sum256(leaf.Raw)
// The same leaf can chain to different CAs for different challenges, and
// management checks each chain against each check's CAs, so a proof is
// deduplicated by its whole chain rather than by its leaf.
fingerprint := chainFingerprint(chain)
if _, done := proven[fingerprint]; done {
log.Infof("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
log.Debugf("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
break
}
proof, err := prove(candidate, challenge.GetNonce(), peerKey)
proof, err := prove(candidate.Signer, chain, challenge.GetNonce(), peerKey)
if err != nil {
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))
log.Debugf("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.Debugf("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))
log.Debugf("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs))
return proofs
}
// HasChallenges reports whether any of checks asks for a certificate proof.
func HasChallenges(checks []*proto.Checks) bool {
return len(certificateChallenges(checks)) > 0
}
func certificateChallenges(checks []*proto.Checks) []*proto.CertificateChallenge {
var challenges []*proto.CertificateChallenge
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil && len(challenge.GetNonce()) > 0 {
if challenge := check.GetCertificateChallenge(); challenge != nil {
challenges = append(challenges, challenge)
}
}
return challenges
return wellFormed(challenges)
}
func prove(candidate Candidate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(candidate.Signer, nonce, peerKey)
// wellFormed drops challenges whose nonce is not one management could have issued.
func wellFormed(challenges []*proto.CertificateChallenge) []*proto.CertificateChallenge {
var kept []*proto.CertificateChallenge
for _, challenge := range challenges {
if len(challenge.GetNonce()) != certposture.NonceSize {
log.Debugf("certificate posture: skipping challenge with a %d byte nonce", len(challenge.GetNonce()))
continue
}
kept = append(kept, challenge)
}
return kept
}
func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(signer, nonce, peerKey)
if err != nil {
return certposture.Proof{}, err
}
chain := make([][]byte, 0, len(candidate.Chain))
for _, cert := range candidate.Chain {
chain = append(chain, cert.Raw)
der := make([][]byte, 0, len(chain))
for _, cert := range chain {
der = append(der, cert.Raw)
}
return certposture.Proof{Nonce: nonce, Chain: chain, SigAlg: sigAlg, Signature: sig}, nil
return certposture.Proof{Nonce: nonce, Chain: der, SigAlg: sigAlg, Signature: sig}, nil
}
func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte {
buf := make([]byte, 0, len(chain)*sha256.Size)
for _, cert := range chain {
certHash := sha256.Sum256(cert.Raw)
buf = append(buf, certHash[:]...)
}
return sha256.Sum256(buf)
}
+71 -30
View File
@@ -1,14 +1,15 @@
//go:build !ios
package certproof
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
@@ -19,12 +20,15 @@ import (
const helperTimeout = 30 * time.Second
// userHelperBackoff holds off asking a user's keychain again after it proved nothing.
var userHelperBackoff = newHelperBackoff()
// 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, _ Config) []certposture.Proof {
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
@@ -38,59 +42,96 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
if cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsConsoleUser(ctx, challenges, peerKey)
userProofs, err := collectAsConsoleUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: console user keychain unavailable: %v", err)
log.Debugf("certificate posture: console user keychain unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the user whose keychain a collection would include: the console
// user when it owns the active profile, or empty when no user keychain would be asked. A
// change means a collection made earlier no longer reflects what this Mac can prove.
func UserContext(cfg Config) string {
if os.Geteuid() != 0 || cfg.OwnerUnknown {
return ""
}
user, ok := CurrentConsoleUser()
if !ok || !user.isOwner(cfg.ProfileOwner) {
return ""
}
return strconv.FormatUint(uint64(user.UID), 10) + ":" + user.Name
}
// 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) {
func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentConsoleUser()
if !ok {
return nil, nil
}
if !user.isOwner(owner) {
log.Debugf("certificate posture: console user %s does not own the active profile, no user keychain is asked", user.Name)
return nil, nil
}
uid := strconv.FormatUint(uint64(user.UID), 10)
backoffKey := helperBackoffKey(uid, challenges)
if !userHelperBackoff.allow(backoffKey, time.Now()) {
log.Debugf("certificate posture: the keychain of uid %s proved nothing recently, not asking again yet", uid)
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)
}
parent := ctx
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
// Absolute paths, because the daemon's PATH is configurable through the service
// environment, and sudo selects the user by uid so the name never has to round-trip.
cmd := exec.CommandContext(ctx, "/bin/launchctl", "asuser", uid, "/usr/bin/sudo", "-u", "#"+uid, "-H", "--", binary, "posture", "cert-proof")
killHelperGroupOnCancel(cmd)
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()))
log.Debugf("certificate posture: asking the desktop session of uid %s to answer %d challenges", uid, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
// A run that completed, or ran into the timeout waiting on a prompt nobody answered,
// tells whether the keychain proves anything. A launch or output failure, or a run the
// caller cut short, says nothing about it and must not hold off the next one.
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) && parent.Err() == nil
if err == nil || timedOut {
userHelperBackoff.record(backoffKey, err == nil && len(proofs) > 0, time.Now())
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
if err != nil {
return nil, fmt.Errorf("run helper as uid %s: %w", uid, err)
}
log.Infof("certificate posture: desktop session of %q returned %d proofs", user.Name, len(resp.Proofs))
return resp.Proofs, nil
log.Debugf("certificate posture: desktop session of uid %s returned %d proofs", uid, len(proofs))
return proofs, nil
}
// 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.
// helperStore is the store the helper reads: the user's login keychain alone. The
// session's search list also holds the System keychain, which the daemon reads itself,
// and using a System keychain key from the user's session would ask for an
// administrator's approval.
func helperStore() Store {
return DefaultStore()
home, err := os.UserHomeDir()
if err != nil {
log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err)
return NewKeychainStore()
}
login := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
if _, err := os.Stat(login); err != nil {
// Keychains created before macOS 10.12 keep the old file name.
login = filepath.Join(home, "Library", "Keychains", "login.keychain")
}
return NewKeychainStore(login)
}
+31
View File
@@ -0,0 +1,31 @@
//go:build js
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs proves nothing in a browser: it has no TPM, certificate store or file
// store to sign with, so the stores stay out of the WebAssembly build.
func CollectProofs(context.Context, []*proto.Checks, []byte, Config) []certposture.Proof {
return nil
}
// UserContext identifies the user whose certificates a collection would include. A
// browser has no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// DefaultStore is empty in a browser.
func DefaultStore() Store {
return Stores{}
}
func helperStore() Store {
return DefaultStore()
}
+10 -3
View File
@@ -1,4 +1,4 @@
//go:build !darwin && !windows
//go:build ((!darwin && !windows) || ios) && !js
package certproof
@@ -17,8 +17,15 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
}
// UserContext identifies the user whose certificates a collection would include. These
// platforms have no per-user store, so it never changes.
func UserContext(Config) string {
return ""
}
// helperStore is the store the helper reads. Nothing launches a helper on these
// platforms, so it is the platform default.
// platforms, so it is the store the daemon reads, configured from the same environment,
// which lets an administrator check a setup by running the helper by hand.
func helperStore() Store {
return DefaultStore()
return storeWithToken(Config{PKCS11: PKCS11FromEnv()})
}
+154 -4
View File
@@ -2,6 +2,9 @@ package certproof
import (
"context"
"crypto/rand"
"crypto/x509"
"math/big"
"os"
"path/filepath"
"testing"
@@ -22,7 +25,7 @@ func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := t.TempDir()
dir := storeDir(t)
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
@@ -61,7 +64,7 @@ func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
}
func TestCollect_NothingToProve(t *testing.T) {
dir := t.TempDir()
dir := storeDir(t)
key := certtest.ECDSAKey(t)
ca := certtest.NewCA(t, "root")
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
@@ -74,7 +77,7 @@ func TestCollect_NothingToProve(t *testing.T) {
{"no checks", NewFileStore(dir), nil},
{"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
{"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -89,7 +92,7 @@ func TestFileStore_ChainWithIntermediate(t *testing.T) {
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := t.TempDir()
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := NewFileStore(dir).Candidates(context.Background())
@@ -102,7 +105,154 @@ func TestFileStore_ChainWithIntermediate(t *testing.T) {
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
// storeDir is a PEM directory the store accepts: t.TempDir follows the umask, which
// leaves the directory group-writable on systems with a user-private group umask.
func storeDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
return dir
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
func TestCollectChallenges_RefusesMalformedInput(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
store := NewFileStore(dir)
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now())
tests := []struct {
name string
nonce []byte
peerKey []byte
want int
}{
{"issued nonce and peer key are signed", nonce, peerKey, 1},
{"short nonce is not signed", nonce[:8], peerKey, 0},
{"oversized nonce is not signed", append(append([]byte{}, nonce...), 0), peerKey, 0},
{"short peer key is not signed", nonce, peerKey[:16], 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenges := []*proto.CertificateChallenge{{Nonce: tt.nonce, CaCertificates: []string{ca.PEM}}}
assert.Len(t, CollectChallenges(context.Background(), store, challenges, tt.peerKey), tt.want,
"the device key signs only what management could have issued")
})
}
}
func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := storeDir(t)
// A stale key next to a renewed certificate, sorted before the good pair, must not
// produce a proof that management rejects and stop the search there.
writeFile(t, dir, "a-renewed.crt", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "renewed")))
writeFile(t, dir, "a-renewed.key", certtest.KeyPEM(t, certtest.ECDSAKey(t)))
goodKey := certtest.ECDSAKey(t)
good := ca.Issue(t, goodKey, "good")
writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate")
assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
proofs := CollectChallenges(context.Background(), NewFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey)
require.Len(t, proofs, 1)
_, err = challenger.Verify(proofs[0], peerKey, now)
assert.NoError(t, err, "the proof sent is one management accepts")
}
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
type staticStore []Candidate
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
// Renewing a CA with the same key pair leaves two certificates with the same
// subject and key in the store. The expired one sorts first here.
expiredTmpl := *intermediate.Cert
expiredTmpl.SerialNumber = big.NewInt(1)
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
require.NoError(t, err)
expired, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
pool := []*x509.Certificate{expired, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Len(t, chain, 2)
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
verified, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
"management's own check accepts the chain the proof carries")
}
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
rootA := certtest.NewCA(t, "root-a")
rootB := certtest.NewCA(t, "root-b")
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
crossTmpl := *issuer.Cert
crossTmpl.SerialNumber = big.NewInt(2)
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
require.NoError(t, err)
cross, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := issuer.Issue(t, key, "device")
pool := []*x509.Certificate{issuer.Cert, cross}
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
challenges := []*proto.CertificateChallenge{
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
}
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
for _, root := range []*certtest.CA{rootA, rootB} {
matched := false
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, now)
require.NoError(t, err)
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
}
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
}
}
+32 -25
View File
@@ -1,13 +1,10 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
@@ -25,7 +22,7 @@ const helperTimeout = 30 * time.Second
// 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, _ Config) []certposture.Proof {
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
@@ -36,17 +33,32 @@ func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte,
// 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() {
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return proofs
}
userProofs, err := collectAsDesktopUser(ctx, challenges, peerKey)
userProofs, err := collectAsDesktopUser(ctx, cfg.ProfileOwner, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: user certificate store unavailable: %v", err)
log.Debugf("certificate posture: user certificate store unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// UserContext identifies the session whose store a collection would include: a session
// of the profile owner, or empty when no user store would be asked. A change means a
// collection made earlier no longer reflects what this machine can prove.
func UserContext(cfg Config) string {
if !runningAsLocalSystem() || cfg.OwnerUnknown {
return ""
}
user, ok := CurrentDesktopUser(cfg.ProfileOwner)
if !ok {
return ""
}
defer user.Close()
return fmt.Sprintf("%d:%s", user.Session, user.Name)
}
// 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 {
@@ -56,8 +68,8 @@ func helperStore() Store {
// 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()
func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentDesktopUser(owner)
if !ok {
return nil, nil
}
@@ -68,34 +80,29 @@ func collectAsDesktopUser(ctx context.Context, challenges []*proto.CertificateCh
return nil, fmt.Errorf("resolve own binary: %w", err)
}
payload, err := json.Marshal(helperRequest(challenges, peerKey))
// The user's own environment, not the service's: the service environment may carry
// secrets such as a setup key that the signed-in user must not be able to read.
env, err := user.Token.Environ(false)
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
return nil, fmt.Errorf("build environment of %s: %w", user.Name, err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
cmd.Env = env
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()))
log.Debugf("certificate posture: asking session %d to answer %d challenges", user.Session, len(challenges))
proofs, err := runHelperCmd(cmd, helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("run helper in session %d: %w", user.Session, err)
}
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
log.Debugf("certificate posture: session %d returned %d proofs", user.Session, len(proofs))
return proofs, nil
}
+85
View File
@@ -0,0 +1,85 @@
package certproof
import (
"context"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const collectTimeout = 45 * time.Second
// collecting is the single-flight flag Collectors share by default. It outlives the
// engine, since a collection abandoned in a token, TPM or keychain call keeps running
// after the engine that started it has stopped, and the next engine must not start
// another one on top of it.
var collecting atomic.Bool
// Collector runs CollectProofs with a deadline and at most one collection at a time in
// the process. Token, TPM and keychain calls cannot be interrupted, so a collection that
// overruns is abandoned rather than awaited, and a new one is refused until it has
// finished. The zero value is ready to use.
type Collector struct {
// busy overrides the process-wide single-flight flag when set.
busy *atomic.Bool
// timeout overrides collectTimeout when set.
timeout time.Duration
}
// Collect answers the certificate challenges in checks, returning no proofs when there
// are no challenges, when a previous collection is still running, or when this one does
// not finish in time. Missing proofs fail the certificate check on management.
func (c *Collector) Collect(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return c.collect(ctx, checks, func(ctx context.Context) []certposture.Proof {
return CollectProofs(ctx, checks, peerKey, cfg)
})
}
func (c *Collector) collect(ctx context.Context, checks []*proto.Checks, run func(context.Context) []certposture.Proof) []certposture.Proof {
if len(certificateChallenges(checks)) == 0 {
return nil
}
busy := c.flag()
if !busy.CompareAndSwap(false, true) {
log.Warnf("certificate posture: previous proof collection is still running, sending no proofs")
return nil
}
ctx, cancel := context.WithTimeout(ctx, c.deadline())
defer cancel()
done := make(chan []certposture.Proof, 1)
go func() {
// The slot is freed before the result is delivered, so a caller that starts the
// next collection right after this one returned is not turned away.
proofs := run(ctx)
busy.Store(false)
done <- proofs
}()
select {
case proofs := <-done:
return proofs
case <-ctx.Done():
log.Warnf("certificate posture: proof collection did not finish within %s, sending no proofs", c.deadline())
return nil
}
}
func (c *Collector) flag() *atomic.Bool {
if c.busy != nil {
return c.busy
}
return &collecting
}
func (c *Collector) deadline() time.Duration {
if c.timeout > 0 {
return c.timeout
}
return collectTimeout
}
+126
View File
@@ -0,0 +1,126 @@
package certproof
import (
"context"
"sync/atomic"
"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/proto"
)
var challengeChecks = []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{
Nonce: certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()),
}}}
func TestCollector_SkipsChecksWithoutChallenges(t *testing.T) {
var c Collector
called := false
proofs := c.collect(context.Background(), []*proto.Checks{{Files: []string{"/bin/agent"}}}, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no store is touched when no check carries a challenge")
}
func TestCollector_ReturnsProofs(t *testing.T) {
var c Collector
want := []certposture.Proof{{Nonce: []byte("nonce")}}
proofs := c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want })
assert.Equal(t, want, proofs, "a collection that finishes in time is returned as is")
}
func TestCollector_AbandonsStuckCollection(t *testing.T) {
c := Collector{timeout: 50 * time.Millisecond, busy: new(atomic.Bool)}
release := make(chan struct{})
finished := make(chan struct{})
// A token or keychain call that ignores its context and blocks well past the deadline.
stuck := func(context.Context) []certposture.Proof {
defer close(finished)
<-release
return []certposture.Proof{{Nonce: []byte("late")}}
}
start := time.Now()
proofs := c.collect(context.Background(), challengeChecks, stuck)
assert.Nil(t, proofs, "an overrunning collection yields no proofs")
assert.Less(t, time.Since(start), time.Second, "the caller is released at the deadline, not when the call returns")
called := false
proofs = c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return nil
})
assert.Nil(t, proofs)
assert.False(t, called, "no second collection starts while the first is still running")
close(release)
<-finished
require.Eventually(t, func() bool { return !c.busy.Load() }, time.Second, 5*time.Millisecond, "the collector frees up once the stuck call returns")
want := []certposture.Proof{{Nonce: []byte("nonce")}}
assert.Equal(t, want, c.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return want }),
"collection works again after the stuck call returned")
}
func TestCollector_CancelsContextAtDeadline(t *testing.T) {
c := Collector{timeout: 20 * time.Millisecond, busy: new(atomic.Bool)}
cancelled := make(chan struct{})
c.collect(context.Background(), challengeChecks, func(ctx context.Context) []certposture.Proof {
<-ctx.Done()
close(cancelled)
return nil
})
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("a collection that honours its context, like the helper process, must see it cancelled")
}
}
// TestCollector_SingleFlightAcrossCollectors: each engine has its own Collector, and a
// collection stuck in a token call outlives the engine that started it, so the next
// engine's Collector must not start another one until it has finished.
func TestCollector_SingleFlightAcrossCollectors(t *testing.T) {
shared := new(atomic.Bool)
first := Collector{timeout: 20 * time.Millisecond, busy: shared}
second := Collector{timeout: time.Second, busy: shared}
release := make(chan struct{})
stuck := func(context.Context) []certposture.Proof {
<-release
return nil
}
assert.Nil(t, first.collect(context.Background(), challengeChecks, stuck), "the first collection is abandoned at its deadline")
called := false
proofs := second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof {
called = true
return []certposture.Proof{{}}
})
assert.Nil(t, proofs, "no proofs while the abandoned collection still runs")
assert.False(t, called, "a second collection does not start on top of the abandoned one")
close(release)
require.Eventually(t, func() bool { return !shared.Load() }, time.Second, 5*time.Millisecond)
assert.Len(t, second.collect(context.Background(), challengeChecks, func(context.Context) []certposture.Proof { return []certposture.Proof{{}} }), 1,
"collections resume once the abandoned one finished")
}
// TestCollector_ZeroValuesShareTheProcessFlag: the zero value uses the process-wide flag.
func TestCollector_ZeroValuesShareTheProcessFlag(t *testing.T) {
var a, b Collector
assert.Same(t, a.flag(), b.flag(), "separate Collectors share one single-flight flag")
}
@@ -1,8 +1,11 @@
//go:build !ios
package certproof
import (
"bytes"
"fmt"
"strconv"
"sync"
"github.com/ebitengine/purego"
@@ -37,21 +40,21 @@ type ConsoleUser struct {
// 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)
log.Debugf("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")
log.Debug("no console user is logged in, no login keychain is reachable")
return ConsoleUser{}, false
}
defer cfRelease(name)
defer release(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)
log.Debugf("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
@@ -67,6 +70,13 @@ func (u ConsoleUser) hasDesktop() bool {
return u.UID != 0
}
// isOwner reports whether the console user is owner, the account of the active profile,
// which is recorded as a short user name or, for an account without one, a numeric uid.
// With no owner the console user counts, as macOS has a single console user.
func (u ConsoleUser) isOwner(owner string) bool {
return owner == "" || owner == u.Name || owner == strconv.FormatUint(uint64(u.UID), 10)
}
func cfString(str uintptr) string {
buf := make([]byte, consoleNameBufSize)
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
@@ -1,3 +1,5 @@
//go:build !ios
package certproof
import (
@@ -37,3 +39,13 @@ 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 TestConsoleUser_IsOwner(t *testing.T) {
user := ConsoleUser{Name: "maycon", UID: 501, GID: 20}
assert.True(t, user.isOwner(""), "a profile without owner accepts the single console user")
assert.True(t, user.isOwner("maycon"), "the owner by short name")
assert.True(t, user.isOwner("501"), "the owner recorded as a numeric uid")
assert.False(t, user.isOwner("viktor"), "another account's profile must not read this user's keychain")
assert.False(t, user.isOwner("502"), "another uid is another account")
}
@@ -2,6 +2,7 @@ package certproof
import (
"fmt"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
@@ -10,11 +11,14 @@ import (
const (
noActiveSession = 0xFFFFFFFF
servicesSession = 0
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE and wtsActive is WTSActive of
// WTS_CONNECTSTATE_CLASS. Neither is exported by x/sys/windows.
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE; wtsActive and wtsDisconnected are
// WTSActive and WTSDisconnected of WTS_CONNECTSTATE_CLASS. None is exported by
// x/sys/windows.
wtsCurrentServer = windows.Handle(0)
wtsActive = 0
wtsDisconnected = 4
)
// DesktopUser is an interactive session and the account signed into it. The user's
@@ -33,35 +37,82 @@ func (u DesktopUser) Close() {
}
}
// 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.
// CurrentDesktopUser returns a token for the session whose certificate store should be
// asked: a session of owner, the account the active profile belongs to, with the
// physical console preferred over remote sessions. With no owner only the console user
// counts. Picking any signed-in user instead would let whoever else is logged in to a
// terminal server or VDI host decide the result. The second return is false when no
// such session exists, and only machine certificates can then 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)
func CurrentDesktopUser(owner string) (DesktopUser, bool) {
console := windows.WTSGetActiveConsoleSessionId()
if owner == "" {
return consoleUser(console)
}
sessions, err := activeSessions()
sessions, err := userSessions(console)
if err != nil {
log.Infof("cannot enumerate terminal sessions: %v", err)
log.Debugf("cannot enumerate terminal sessions: %v", err)
return DesktopUser{}, false
}
var found DesktopUser
var ok bool
for _, session := range sessions {
if user, ok := desktopUser(session); ok {
return user, true
user, signedIn := desktopUser(session)
if !signedIn {
continue
}
switch {
case !sameAccountName(user.Name, owner):
user.Close()
case !ok:
found, ok = user, true
case strings.EqualFold(user.Name, found.Name):
// Another session of the same account; the first one in preference order wins.
user.Close()
default:
// An owner recorded without a domain matches accounts of several domains here.
// Picking one would let another domain's user answer for the owner.
log.Debugf("certificate posture: profile owner %s matches both %s and %s, no user certificate store is used", owner, found.Name, user.Name)
user.Close()
found.Close()
return DesktopUser{}, false
}
}
if !ok {
log.Debugf("certificate posture: profile owner %s has no signed-in session, no user certificate store is reachable", owner)
}
return found, ok
}
log.Info("no interactive session is signed in, no user certificate store is reachable")
return DesktopUser{}, false
func consoleUser(console uint32) (DesktopUser, bool) {
if console == noActiveSession || console == servicesSession {
log.Debug("no console session, no user certificate store is reachable")
return DesktopUser{}, false
}
user, ok := desktopUser(console)
if !ok {
log.Debugf("console session %d has nobody signed in, no user certificate store is reachable", console)
}
return user, ok
}
// sameAccountName compares DOMAIN\account names case-insensitively, as Windows does, and an
// owner given without a domain against the account part alone. The session side comes from
// the session token's own SID, which Windows resolves from its cache of signed-in users.
// Resolving the owner name to a SID instead would ask the domain controller, which on a
// laptop that cannot reach it yet blocks for tens of seconds, past the collection deadline.
func sameAccountName(sessionName, owner string) bool {
if strings.EqualFold(sessionName, owner) {
return true
}
if strings.Contains(owner, `\`) {
return false
}
_, account, found := strings.Cut(sessionName, `\`)
return found && strings.EqualFold(account, owner)
}
func desktopUser(session uint32) (DesktopUser, bool) {
@@ -73,7 +124,7 @@ func desktopUser(session uint32) (DesktopUser, bool) {
name, err := tokenAccount(token)
if err != nil {
log.Infof("session %d token has no readable account: %v", session, err)
log.Debugf("session %d token has no readable account: %v", session, err)
if closeErr := token.Close(); closeErr != nil {
log.Debugf("failed closing session token: %v", closeErr)
}
@@ -97,7 +148,10 @@ func tokenAccount(token windows.Token) (string, error) {
return domain + `\` + account, nil
}
func activeSessions() ([]uint32, error) {
// userSessions lists the sessions a user can be signed in to, console first, then
// active remote sessions, then disconnected ones, whose user is still signed in. Session
// 0 is skipped: it hosts services and never belongs to an interactive user.
func userSessions(console uint32) ([]uint32, error) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
@@ -105,13 +159,19 @@ func activeSessions() ([]uint32, error) {
}
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)
var sessions []uint32
if console != noActiveSession && console != servicesSession {
sessions = append(sessions, console)
}
for _, state := range []uint32{wtsActive, wtsDisconnected} {
for _, session := range unsafe.Slice(info, count) {
if session.SessionID == servicesSession || session.SessionID == console || session.State != state {
continue
}
sessions = append(sessions, session.SessionID)
}
}
return active, nil
return sessions, nil
}
// runningAsLocalSystem reports whether this process is the service. The helper runs as
@@ -0,0 +1,61 @@
package certproof
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSameAccountName(t *testing.T) {
tests := []struct {
session, owner string
want bool
}{
{`CORP\alice`, `CORP\alice`, true},
{`CORP\alice`, `corp\ALICE`, true},
{`CORP\alice`, `alice`, true},
{`CORP\alice`, `OTHER\alice`, false},
{`CORP\alice`, `bob`, false},
{`alice`, `alice`, true},
{`CORP\alice`, `CORP\alic`, false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, sameAccountName(tt.session, tt.owner), "session %q, owner %q", tt.session, tt.owner)
}
}
// CurrentDesktopUser against the real session manager: an owner no session belongs to
// must never yield a session, whoever else is signed in.
func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) {
user, ok := CurrentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`)
if ok {
user.Close()
}
assert.False(t, ok, "a profile owner without a session must not borrow another user's store")
}
// TestCurrentDesktopUser_FindsTheOwnersSession runs against a real machine as LocalSystem,
// with NB_TEST_DESKTOP_OWNER naming an account that is signed in. That account's session
// must be found, and only that account's.
func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) {
owner := os.Getenv("NB_TEST_DESKTOP_OWNER")
if owner == "" {
t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem")
}
user, ok := CurrentDesktopUser(owner)
require.True(t, ok, "the signed-in owner %s must have a session", owner)
defer user.Close()
t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name)
assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name)
assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner")
console, consoleOK := CurrentDesktopUser("")
if consoleOK {
defer console.Close()
t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name)
} else {
t.Log("without an owner nobody counts: no user at the console")
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package certproof answers certificate posture challenges: it finds the certificates a
// peer holds a private key for and signs the challenge nonce, bound to the peer's
// WireGuard key, with that key. Management verifies the signature and the chain against
// the CAs of the check, so only a holder of the key passes and a proof made for one peer
// cannot be replayed by another. Keys in the OS stores, the TPM and a PKCS#11 token are
// used through the platform, which signs; a plain PEM key file is the exception, parsed
// and used in the daemon's memory.
//
// Machine stores are read by the daemon itself: the Windows LocalMachine store, the macOS
// System keychain, and on Linux a directory of PEM files, TSS2 key files the TPM signs
// with, and a PKCS#11 token. On Linux the directory and the token URI come from the
// daemon's environment (NB_CERT_STORE_DIR, NB_CERT_PKCS11_URI), as does the token PIN
// (NB_TPM_PIN); none of them is read from the profile config.
//
// User stores cannot be read by a privileged daemon, so it starts this binary as
// "netbird posture cert-proof" inside the user's session and receives only signatures
// and chains on its stdout. On macOS uid is not what unlocks a login keychain, the
// session's bootstrap namespace is, which is why the helper enters it through launchctl
// asuser. On Windows a service opening CURRENT_USER silently reads its own empty hive, so
// the helper runs with the session's token instead. Its output is untrusted: sizes are
// capped and only proofs for requested nonces are kept.
//
// A user proof shows that some process in that user's session could use a key whose
// certificate chains to the CA. It does not show which binary signed, or that the key is
// hardware-bound; device stores are the stronger signal.
package certproof
+1 -1
View File
@@ -57,7 +57,7 @@ func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) er
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))
log.Debugf("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)
@@ -0,0 +1,78 @@
package certproof
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"sync"
"time"
"github.com/netbirdio/netbird/shared/management/proto"
)
// helperQuietPeriod is how long a user whose helper proved nothing is not asked again
// for the same CAs. On macOS each helper run may show a keychain prompt, so retrying on
// every collection would put up a new one each time the user ignored or denied the last.
const helperQuietPeriod = time.Hour
// helperBackoff remembers, per user and set of CAs asked about, until when the helper
// is not launched again because its last run proved nothing.
type helperBackoff struct {
mu sync.Mutex
until map[string]time.Time
}
func newHelperBackoff() *helperBackoff {
return &helperBackoff{until: map[string]time.Time{}}
}
// allow reports whether the helper may be launched for key now.
func (b *helperBackoff) allow(key string, now time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
return !now.Before(b.until[key])
}
// record stores the outcome of a helper run for key: one that proved something clears
// the back-off, one that proved nothing starts it. Expired entries are dropped, so the
// map holds only users currently held off.
func (b *helperBackoff) record(key string, proven bool, now time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
for other, until := range b.until {
if !now.Before(until) {
delete(b.until, other)
}
}
if proven {
delete(b.until, key)
return
}
b.until[key] = now.Add(helperQuietPeriod)
}
// helperBackoffKey identifies a user and the set of CAs the challenges accept, in any
// order, leaving out the nonces, which rotate without changing what the user is asked to
// prove.
func helperBackoffKey(user string, challenges []*proto.CertificateChallenge) string {
sets := make([]string, 0, len(challenges))
for _, challenge := range challenges {
cas := make([]string, 0, len(challenge.GetCaCertificates()))
for _, ca := range challenge.GetCaCertificates() {
sum := sha256.Sum256([]byte(ca))
cas = append(cas, hex.EncodeToString(sum[:]))
}
slices.Sort(cas)
sets = append(sets, strings.Join(cas, ","))
}
slices.Sort(sets)
h := sha256.New()
h.Write([]byte(user))
for _, set := range sets {
h.Write([]byte{0})
h.Write([]byte(set))
}
return hex.EncodeToString(h.Sum(nil))
}
@@ -0,0 +1,69 @@
package certproof
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/proto"
)
// TestHelperBackoff: a user whose helper proved nothing, for one ignoring or denying a
// keychain prompt, is not asked again for the same CAs within the quiet period, while a
// success or other CAs are asked at once.
func TestHelperBackoff(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
key := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}})
assert.True(t, b.allow(key, now), "a user never asked is asked")
b.record(key, false, now)
assert.False(t, b.allow(key, now.Add(helperQuietPeriod-time.Second)), "no new prompt within the quiet period")
assert.True(t, b.allow(key, now.Add(helperQuietPeriod)), "asked again once the quiet period passed")
other := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-b"}}})
assert.True(t, b.allow(other, now), "a check for other CAs is asked at once")
assert.True(t, b.allow(helperBackoffKey("502", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-a"}}}), now), "another user is asked at once")
b.record(key, true, now)
assert.True(t, b.allow(key, now), "a run that proved something clears the quiet period")
}
// TestHelperBackoffKey_IgnoresNonces: nonces rotate every window without changing what
// the user is asked to prove, so they do not reset the quiet period.
func TestHelperBackoffKey_IgnoresNonces(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("one"), CaCertificates: []string{"ca"}}})
b := helperBackoffKey("501", []*proto.CertificateChallenge{{Nonce: []byte("two"), CaCertificates: []string{"ca"}}})
assert.Equal(t, a, b, "the key does not depend on the nonce")
}
// TestHelperBackoffKey_IgnoresOrder: the same CAs, listed in another order or with the
// challenges reordered, are the same question to the user.
func TestHelperBackoffKey_IgnoresOrder(t *testing.T) {
a := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-1", "ca-2"}},
{CaCertificates: []string{"ca-3"}},
})
b := helperBackoffKey("501", []*proto.CertificateChallenge{
{CaCertificates: []string{"ca-3"}},
{CaCertificates: []string{"ca-2", "ca-1"}},
})
assert.Equal(t, a, b, "the key does not depend on the order of CAs or challenges")
c := helperBackoffKey("501", []*proto.CertificateChallenge{{CaCertificates: []string{"ca-1", "ca-2", "ca-3"}}})
assert.NotEqual(t, a, c, "the same CAs grouped into other challenges are another question")
}
// TestHelperBackoff_PrunesExpired: entries whose quiet period passed are dropped when the
// next outcome is recorded, so the map does not grow with every user and CA set seen.
func TestHelperBackoff_PrunesExpired(t *testing.T) {
now := time.Now()
b := newHelperBackoff()
b.record("old", false, now)
b.record("new", false, now.Add(helperQuietPeriod))
assert.NotContains(t, b.until, "old", "an expired entry is pruned")
assert.Contains(t, b.until, "new", "a current entry is kept")
}
@@ -0,0 +1,10 @@
//go:build !unix && !windows
package certproof
import "os/exec"
// startHelper starts cmd.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,35 @@
//go:build unix
package certproof
import (
"errors"
"os/exec"
"syscall"
)
// killHelperGroupOnCancel puts cmd in a process group of its own and kills the whole
// group when cmd's context ends. The helper may run below launchers such as launchctl
// and sudo, which do not pass a kill on, and it keeps the output pipes open, so killing
// only the direct child would leave the helper running and Wait blocked until the
// helper exits on its own, for one waiting on a keychain prompt nobody answers.
func killHelperGroupOnCancel(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.WaitDelay = helperWaitDelay
}
// startHelper starts cmd. Killing the helper with everything below it is arranged by
// killHelperGroupOnCancel where the helper runs under a launcher.
func startHelper(cmd *exec.Cmd) (func(), error) {
return func() {}, cmd.Start()
}
@@ -0,0 +1,77 @@
//go:build windows
package certproof
import (
"fmt"
"os/exec"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// startHelper starts cmd inside a job object that is terminated when cmd's context ends
// and closed, killing whatever is left in it, once the helper has been awaited. Killing
// only the helper on Windows leaves its descendants running, and one that holds the
// output pipe would block Wait until it exits, so WaitDelay bounds that wait as well.
func startHelper(cmd *exec.Cmd) (func(), error) {
job, err := newKillOnCloseJob()
if err != nil {
return nil, err
}
closeJob := func() {
if err := windows.CloseHandle(job); err != nil {
log.Debugf("failed to close certificate proof helper job: %v", err)
}
}
cmd.Cancel = func() error {
return windows.TerminateJobObject(job, 1)
}
cmd.WaitDelay = helperWaitDelay
if err := cmd.Start(); err != nil {
closeJob()
return nil, err
}
// A helper outside the job is still killed directly and bounded by WaitDelay; only
// its descendants would outlive it.
if err := assignToJob(job, cmd.Process.Pid); err != nil {
log.Debugf("failed to put certificate proof helper %d in its job: %v", cmd.Process.Pid, err)
cmd.Cancel = func() error { return cmd.Process.Kill() }
}
return closeJob, nil
}
// newKillOnCloseJob creates a job object whose processes are killed when its last handle
// is closed.
func newKillOnCloseJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, fmt.Errorf("create job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return 0, fmt.Errorf("set job object limits: %w", err)
}
return job, nil
}
// assignToJob puts the process pid into job.
func assignToJob(job windows.Handle, pid int) error {
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
if err != nil {
return fmt.Errorf("open process: %w", err)
}
defer func() {
_ = windows.CloseHandle(process)
}()
return windows.AssignProcessToJobObject(job, process)
}
@@ -0,0 +1,29 @@
//go:build windows
package certproof
import (
"context"
"os/exec"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRunHelperCmd_KillsHelperTree: the helper's child holds stdout and runs for a minute,
// like a helper stuck below a launcher. Killing only the helper would leave Wait blocked
// on the child's pipe until WaitDelay gives up; terminating the job ends both at once.
func TestRunHelperCmd_KillsHelperTree(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "cmd.exe", "/c", "ping -n 60 127.0.0.1")
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), helperWaitDelay, "the whole tree is killed at the timeout, not left for WaitDelay")
}
+126
View File
@@ -0,0 +1,126 @@
package certproof
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
)
const (
maxHelperStdout = 1 << 20
maxHelperStderr = 4 << 10
// helperWaitDelay bounds how long a helper's output is awaited after it was killed.
helperWaitDelay = 2 * time.Second
)
var errHelperOutputTooLarge = errors.New("helper output exceeds the size limit")
// runHelperCmd feeds req to the helper process cmd and returns the proofs it answered.
// The helper runs as an unprivileged user who can control its output, so both streams
// are capped and only proofs for a nonce req asked about are kept, one per challenge.
func runHelperCmd(cmd *exec.Cmd, req HelperRequest) ([]certposture.Proof, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
stdout := &cappedBuffer{limit: maxHelperStdout}
stderr := &cappedBuffer{limit: maxHelperStderr}
cmd.Stdin = bytes.NewReader(payload)
cmd.Stdout = stdout
cmd.Stderr = stderr
release, err := startHelper(cmd)
if err != nil {
return nil, fmt.Errorf("start helper: %w", err)
}
defer release()
if err := cmd.Wait(); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
logHelperStderr(stderr.String())
if stdout.truncated {
return nil, errHelperOutputTooLarge
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
return requestedProofs(req, resp.Proofs), nil
}
// logHelperStderr records what a helper that succeeded wrote to stderr, such as a user
// certificate it could not sign with, which is otherwise only visible in the user's
// session. Each line is quoted because the helper's user controls its content.
func logHelperStderr(stderr string) {
for _, line := range strings.Split(strings.TrimSpace(stderr), "\n") {
if line = strings.TrimSpace(line); line != "" {
log.Debugf("certificate posture helper: %q", line)
}
}
}
// requestedProofs keeps the proofs whose nonce belongs to one of req's challenges, at
// most as many as req has challenges.
func requestedProofs(req HelperRequest, proofs []certposture.Proof) []certposture.Proof {
var kept []certposture.Proof
for _, proof := range proofs {
if len(kept) == len(req.Challenges) {
break
}
if !req.asked(proof.Nonce) {
log.Debugf("certificate posture: dropping helper proof for a nonce that was not requested")
continue
}
kept = append(kept, proof)
}
return kept
}
func (r HelperRequest) asked(nonce []byte) bool {
for _, challenge := range r.Challenges {
if len(challenge.Nonce) > 0 && bytes.Equal(challenge.Nonce, nonce) {
return true
}
}
return false
}
// cappedBuffer keeps the first limit bytes written to it and discards the rest, so a
// misbehaving child cannot grow the parent's memory without bound. The buffer is a
// named field rather than embedded: an embedded bytes.Buffer would promote ReadFrom,
// which io.Copy prefers over Write, bypassing the cap.
type cappedBuffer struct {
buf bytes.Buffer
limit int
truncated bool
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if room := b.limit - b.buf.Len(); room < len(p) {
b.truncated = true
if room > 0 {
b.buf.Write(p[:room])
}
return len(p), nil
}
return b.buf.Write(p)
}
func (b *cappedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *cappedBuffer) String() string {
return b.buf.String()
}
@@ -0,0 +1,119 @@
//go:build !windows && !js
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// fakeHelper is a child process that drains its stdin and then prints script's output,
// standing in for a helper running in a user session the daemon cannot trust.
func fakeHelper(t *testing.T, script string) *exec.Cmd {
t.Helper()
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
}
func printJSON(t *testing.T, v any) string {
t.Helper()
out, err := json.Marshal(v)
require.NoError(t, err)
return fmt.Sprintf("printf '%%s' '%s'", out)
}
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
resp := HelperResponse{Proofs: []certposture.Proof{
{Nonce: []byte("injected"), Signature: []byte("x")},
{Nonce: []byte("asked"), Signature: []byte("first")},
{Nonce: []byte("asked"), Signature: []byte("second")},
}}
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
require.NoError(t, err)
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
}
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
_, err := runHelperCmd(fakeHelper(t, script), req)
assert.ErrorIs(t, err, errHelperOutputTooLarge)
}
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
_, err := runHelperCmd(fakeHelper(t, script), req)
require.Error(t, err)
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
}
// TestRunHelperCmd_LogsStderrOfSuccessfulHelper covers a helper that answers but warns,
// for one about a certificate it could not sign with: the warning reaches the daemon's
// log, quoted so a helper cannot forge log lines with embedded newlines.
func TestRunHelperCmd_LogsStderrOfSuccessfulHelper(t *testing.T) {
var logged bytes.Buffer
log.SetOutput(&logged)
level := log.GetLevel()
log.SetLevel(log.DebugLevel)
t.Cleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(level)
})
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
script := "printf 'failed signing certificate proof for CN=user\\nforged line\\n' >&2; " + printJSON(t, HelperResponse{})
_, err := runHelperCmd(fakeHelper(t, script), req)
require.NoError(t, err)
assert.Contains(t, logged.String(), "failed signing certificate proof for CN=user", "the helper's warning reaches the daemon log")
assert.NotContains(t, logged.String(), "\nforged line", "helper output cannot start a log line of its own")
}
// TestRunHelperCmd_KillsHelperBelowLauncher stands in for launchctl and sudo starting the
// helper: the direct child spawns a grandchild that holds stdout and never exits, like a
// helper waiting on a keychain prompt. The timeout must end both, not wait for the
// grandchild.
func TestRunHelperCmd_KillsHelperBelowLauncher(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "sleep 30 & cat >/dev/null; wait")
killHelperGroupOnCancel(cmd)
start := time.Now()
_, err := runHelperCmd(cmd, HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}})
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second, "the helper is killed at the timeout, not awaited until it exits")
}
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
assert.ErrorContains(t, err, "decode helper response")
}
+3 -3
View File
@@ -1,4 +1,4 @@
//go:build darwin || windows
//go:build (darwin && !ios) || windows
package certproof
@@ -56,8 +56,8 @@ func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
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)
log.Debugf("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)
log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
}
@@ -1,4 +1,4 @@
//go:build darwin || windows
//go:build (darwin && !ios) || windows
package certproof
+3 -3
View File
@@ -16,7 +16,7 @@ import (
func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := t.TempDir()
dir := storeDir(t)
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
@@ -47,7 +47,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
require.NoError(t, err)
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader(request), &stdout))
require.NoError(t, runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
@@ -56,7 +56,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
var stdout bytes.Buffer
err := runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader([]byte("not json")), &stdout)
err := runHelper(context.Background(), NewFileStore(storeDir(t)), 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")
+171 -51
View File
@@ -1,3 +1,5 @@
//go:build !ios
package certproof
import (
@@ -20,9 +22,17 @@ const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
errSecItemNotFound = -25300
errSecItemNotFound = -25300
errSecInteractionNotAllowed = -25308
)
// errKeyNeedsApproval reports a key whose access list does not include netbird, so using
// it needs the user's approval, which a daemon has no UI to ask for.
var errKeyNeedsApproval = errors.New("the key's access control requires user approval for netbird; " +
"import the identity with netbird allowed (security import <identity.p12> -k <keychain> " +
"-T /Applications/NetBird.app/Contents/MacOS/netbird), " +
"or set AllowAllAppsAccess in the MDM certificate payload, which allows every application")
var (
keychainOnce sync.Once
keychainErr error
@@ -33,8 +43,10 @@ var (
secCertificateCopyData func(cert uintptr) uintptr
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
secKeychainOpen func(path *byte, keychain *uintptr) int32
secKeychainCopySearchList func(searchList *uintptr) int32
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
cfArrayCreate func(alloc uintptr, values *uintptr, count int, callBacks uintptr) uintptr
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
cfArrayGetCount func(array uintptr) int
@@ -45,37 +57,56 @@ var (
cfErrorGetCode func(err uintptr) int
cfRelease func(ref uintptr)
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks uintptr
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
kSecMatchSearchList uintptr
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, kCFTypeArrayCallBacks uintptr
)
// DefaultStore is the keychain search list of the daemon, which for the root daemon is
// the System keychain where MDM installs device identities.
// systemKeychain is where MDM installs device identities.
const systemKeychain = "/Library/Keychains/System.keychain"
// DefaultStore is the System keychain for the root daemon, so it never touches a user's
// keychain, whose keys would ask for approval in a session the daemon has no UI in. Run
// by an ordinary user, it is that user's keychain search list.
func DefaultStore() Store {
if os.Geteuid() == 0 {
return NewKeychainStore(systemKeychain)
}
return NewKeychainStore()
}
// KeychainStore yields the identities of the process's keychain search list, reached
// through purego so the client keeps building with CGO_ENABLED=0.
type KeychainStore struct{}
// KeychainStore yields the identities of a set of keychains, or of the process's keychain
// search list when none is named, reached through purego so the client keeps building
// with CGO_ENABLED=0.
type KeychainStore struct {
keychains []string
}
func NewKeychainStore() *KeychainStore {
return &KeychainStore{}
// NewKeychainStore returns a store that searches the keychain files at paths, or the
// process's keychain search list when no path is given.
func NewKeychainStore(paths ...string) *KeychainStore {
return &KeychainStore{keychains: paths}
}
func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
if err := loadKeychain(); err != nil {
return nil, err
}
searchList, done, err := searchListOf(s.keychains)
if err != nil {
return nil, err
}
defer done()
var leaves []*x509.Certificate
err := eachIdentity(func(_ uintptr, der []byte) (bool, error) {
err = eachIdentity(searchList, func(_ uintptr, der []byte) (bool, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
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)
log.Debugf("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
})
@@ -84,24 +115,24 @@ func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
}
// 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()
pool, err := keychainCertificates(searchList)
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))
log.Debugf("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))
log.Debugf("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 {
chain := buildChain(leaf, pool)
log.Infof("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
log.Debugf("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)
log.Debugf("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}})
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf, keychains: s.keychains}, Intermediates: pool})
}
return candidates, nil
}
@@ -109,7 +140,8 @@ func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
// keychainSigner holds only the certificate; the identity is looked up again at signing
// time so no keychain references outlive a call.
type keychainSigner struct {
leaf *x509.Certificate
leaf *x509.Certificate
keychains []string
}
func (s *keychainSigner) Public() crypto.PublicKey {
@@ -121,11 +153,17 @@ 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)
log.Debugf("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
searchList, done, err := searchListOf(s.keychains)
if err != nil {
return nil, err
}
defer done()
algorithm := keychainAlgorithm(scheme)
var signature []byte
err = eachIdentity(func(identity uintptr, der []byte) (bool, error) {
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
if !bytes.Equal(der, s.leaf.Raw) {
return false, nil
}
@@ -138,7 +176,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))
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
return signature, nil
}
@@ -158,37 +196,94 @@ func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
}
defer cfRelease(key)
defer release(key)
data := cfDataCreate(0, &digest[0], len(digest))
defer cfRelease(data)
if data == 0 {
return nil, errors.New("CFDataCreate returned NULL")
}
defer release(data)
var cfErr uintptr
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
if signature == 0 {
defer cfRelease(cfErr)
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", cfErrorGetCode(cfErr))
if cfErr == 0 {
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
}
defer release(cfErr)
code := cfErrorGetCode(cfErr)
if code == errSecInteractionNotAllowed {
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
}
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
}
defer cfRelease(signature)
defer release(signature)
return dataBytes(signature), nil
}
func eachIdentity(fn func(identity uintptr, der []byte) (bool, error)) error {
return eachMatching(kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
// or yields 0, the process's own search list, when paths is empty. The caller calls done
// once it no longer uses the list.
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
if len(paths) == 0 {
return 0, func() {}, nil
}
list, err := openSearchList(paths)
if err != nil {
return 0, nil, err
}
return list, func() { release(list) }, nil
}
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
// caller releases the array.
func openSearchList(paths []string) (uintptr, error) {
refs := make([]uintptr, 0, len(paths))
defer func() {
for _, ref := range refs {
release(ref)
}
}()
for _, path := range paths {
cpath := append([]byte(path), 0)
var keychain uintptr
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
}
refs = append(refs, keychain)
}
// The array retains the keychains, so the references opened here are released.
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
if list == 0 {
return 0, errors.New("CFArrayCreate returned NULL")
}
return list, nil
}
// eachIdentity calls fn with every identity in searchList, or in the process's search
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
// skipped rather than ending the walk.
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
var cert uintptr
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
return true, fmt.Errorf("SecIdentityCopyCertificate: %d", status)
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
return false, nil
}
der := certificateDER(cert)
cfRelease(cert)
release(cert)
if der == nil {
log.Debug("skipping keychain identity whose certificate has no DER data")
return false, nil
}
return fn(identity, der)
})
}
func keychainCertificates() ([]*x509.Certificate, error) {
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var unparsable int
err := eachMatching(kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
certs = append(certs, cert)
return false, nil
@@ -196,30 +291,34 @@ func keychainCertificates() ([]*x509.Certificate, error) {
unparsable++
return false, nil
})
log.Infof("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
return certs, err
}
func eachMatching(class uintptr, name string, fn func(item uintptr) (bool, error)) error {
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
if searchList != 0 {
keys = append(keys, kSecMatchSearchList)
values = append(values, searchList)
}
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
defer cfRelease(query)
defer release(query)
var items uintptr
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)
log.Debugf("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)
log.Debugf("keychain %s query returned OSStatus %d", name, status)
return fmt.Errorf("SecItemCopyMatching: %d", status)
}
defer cfRelease(items)
defer release(items)
n := cfArrayGetCount(items)
log.Infof("keychain %s query returned %d items", name, n)
log.Debugf("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
@@ -228,23 +327,40 @@ func eachMatching(class uintptr, name string, fn func(item uintptr) (bool, error
return nil
}
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
// returns NULL, which it does for an object that is not a valid certificate.
func certificateDER(cert uintptr) []byte {
data := secCertificateCopyData(cert)
defer cfRelease(data)
if data == 0 {
return nil
}
defer release(data)
return dataBytes(data)
}
func dataBytes(data uintptr) []byte {
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), cfDataGetLength(data)))
n := cfDataGetLength(data)
if n <= 0 {
return nil
}
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
}
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
// several Security calls return NULL on failure, so every release goes through here.
func release(ref uintptr) {
if ref != 0 {
cfRelease(ref)
}
}
func loadKeychain() error {
keychainOnce.Do(func() {
if keychainErr = resolveKeychain(); keychainErr != nil {
log.Infof("macOS keychain unavailable for certificate posture: %v", keychainErr)
log.Debugf("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())
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
logSearchList()
})
return keychainErr
@@ -254,21 +370,21 @@ func loadKeychain() error {
// 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")
log.Debug("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)
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
return
}
defer cfRelease(list)
defer release(list)
n := cfArrayGetCount(list)
log.Infof("keychain search list contains %d keychains", n)
log.Debugf("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)))
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
}
}
@@ -301,6 +417,8 @@ func resolveKeychain() error {
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
{&secCertificateCopyData, security, "SecCertificateCopyData"},
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
{&secKeychainOpen, security, "SecKeychainOpen"},
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
@@ -329,12 +447,14 @@ func resolveKeychain() error {
{&kSecMatchLimit, security, "kSecMatchLimit", true},
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
{&kSecReturnRef, security, "kSecReturnRef", true},
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
} {
addr, err := purego.Dlsym(global.lib, global.name)
if err != nil {
@@ -356,7 +476,7 @@ func resolveKeychain() error {
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)
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
return
}
purego.RegisterFunc(ptr, symbol)
+54
View File
@@ -0,0 +1,54 @@
package certproof
import (
"crypto/sha256"
"encoding/binary"
"errors"
"sync"
)
// errPINRejectedBefore is returned instead of logging in with a PIN the token already
// refused: every failed login counts towards the token's lockout, which for a TPM is
// shared with everything else on the machine, and proofs are collected on every sync.
var errPINRejectedBefore = errors.New("PKCS#11 token rejected this PIN before, not trying it again")
// errPINNeedsToken refuses a PIN that names no token to log in to.
var errPINNeedsToken = errors.New("a PKCS#11 PIN needs the token named in NB_CERT_PKCS11_URI, as token=<label>")
// rejectedPINs outlives a single store, since a store is built for each collection.
var rejectedPINs = &pinLatch{keys: map[[sha256.Size]byte]struct{}{}}
// pinLoginMu serializes logging in to a PKCS#11 token with a PIN, from checking the latch
// to recording a rejection.
var pinLoginMu sync.Mutex
// pinLatch remembers PINs a token rejected. Keys are hashes, so the PIN itself is not
// kept in memory any longer than the store that read it.
type pinLatch struct {
mu sync.Mutex
keys map[[sha256.Size]byte]struct{}
}
func (l *pinLatch) has(key [sha256.Size]byte) bool {
l.mu.Lock()
defer l.mu.Unlock()
_, ok := l.keys[key]
return ok
}
func (l *pinLatch) add(key [sha256.Size]byte) {
l.mu.Lock()
defer l.mu.Unlock()
l.keys[key] = struct{}{}
}
// rejectedPINKey identifies a PIN for one token of one module, so a PIN another token
// rejected is still tried on the token it belongs to.
func rejectedPINKey(module, token string, pin []byte) [sha256.Size]byte {
var buf []byte
for _, part := range [][]byte{[]byte(module), []byte(token), pin} {
buf = binary.BigEndian.AppendUint64(buf, uint64(len(part)))
buf = append(buf, part...)
}
return sha256.Sum256(buf)
}
@@ -0,0 +1,29 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRejectedPINKey_ScopesToModuleTokenAndPIN(t *testing.T) {
base := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.Equal(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("1234")), "the same PIN on the same token is the same key")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "netbird", []byte("4321")), "a corrected PIN must be tried")
assert.NotEqual(t, base, rejectedPINKey("/lib/a.so", "piv", []byte("1234")), "a PIN rejected by one token is still tried on another")
assert.NotEqual(t, base, rejectedPINKey("/lib/b.so", "netbird", []byte("1234")), "a PIN rejected through one module is still tried through another")
assert.NotEqual(t, rejectedPINKey("ab", "", []byte("1")), rejectedPINKey("a", "b", []byte("1")),
"field boundaries are part of the key, so shifted fields do not collide")
}
func TestPINLatch_RemembersRejectedKeys(t *testing.T) {
latch := &pinLatch{keys: map[[32]byte]struct{}{}}
rejected := rejectedPINKey("/lib/a.so", "netbird", []byte("0000"))
other := rejectedPINKey("/lib/a.so", "netbird", []byte("1234"))
assert.False(t, latch.has(rejected), "nothing is rejected before a login fails")
latch.add(rejected)
assert.True(t, latch.has(rejected), "a rejected PIN is not tried again")
assert.False(t, latch.has(other), "other PINs are unaffected")
}
+75 -36
View File
@@ -8,12 +8,22 @@ import (
"errors"
"fmt"
"io"
"os"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
const (
// PINEnv carries the user PIN of the PKCS#11 token. It is read from the daemon's
// environment only, so the PIN never lands in the profile config or on a command line.
PINEnv = "NB_TPM_PIN"
// PKCS11URIEnv carries the RFC 7512 URI of the token. Like the PIN it lives in the
// daemon's environment rather than the profile config, since it may carry pin-value.
PKCS11URIEnv = "NB_CERT_PKCS11_URI"
)
// PKCS11Config names the token whose certificates the store yields. URI is an RFC 7512
// PKCS#11 URI, or empty for the first token the p11-kit proxy exposes. PIN is the user
// PIN, and takes precedence over a pin-value or pin-source the URI carries.
@@ -22,6 +32,12 @@ type PKCS11Config struct {
PIN string
}
// PKCS11FromEnv returns the token named by NB_CERT_PKCS11_URI with the PIN set in
// NB_TPM_PIN, either empty when unset.
func PKCS11FromEnv() PKCS11Config {
return PKCS11Config{URI: os.Getenv(PKCS11URIEnv), PIN: os.Getenv(PINEnv)}
}
// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes
// TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the
// convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM
@@ -34,16 +50,22 @@ type PKCS11Store struct {
// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in
// certDir without a key of their own are paired with the token's keys by public key.
//
// A PIN is only accepted together with a token label: without one the PIN would go to
// whichever token the module lists first, which on a machine with a smartcard plugged
// in may be the card, and every wrong PIN counts towards that card's lockout.
func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) {
store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir}
if cfg.URI == "" {
return store, nil
if cfg.URI != "" {
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
}
store.uri = parsed
}
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
if (cfg.PIN != "" || store.uri.HasPIN()) && store.uri.Token == "" {
return nil, errPINNeedsToken
}
store.uri = parsed
return store, nil
}
@@ -62,7 +84,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
if err != nil {
return nil, err
}
log.Infof("%s holds %d certificates, %d certificate files without a key wait for its keys", s, len(certs), len(fileChains))
log.Debugf("%s holds %d certificates, %d certificate files without a key wait for its keys", s, len(certs), len(fileChains))
pool := make([]*x509.Certificate, 0, len(certs))
for _, cert := range certs {
@@ -75,7 +97,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
var candidates []Candidate
for _, cert := range certs {
if _, err := privateKey(session, cert.id); err != nil {
log.Infof("%s certificate %q has no usable private key: %v", s, cert.cert.Subject, err)
log.Debugf("%s certificate %q has no usable private key: %v", s, cert.cert.Subject, err)
continue
}
candidates = append(candidates, s.candidate(cert.cert, cert.id, pool))
@@ -102,8 +124,8 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate {
chain := buildChain(leaf, pool)
log.Infof("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}}
log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool}
}
// fileChains reads the certificate files in the PEM directory that carry no key of their
@@ -127,6 +149,49 @@ func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) {
return chains, nil
}
func (s *PKCS11Store) String() string {
if s.uri.Token == "" {
return "PKCS#11 token"
}
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
}
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
module, err := pkcs11.Load(s.uri.Module())
if err != nil {
return nil, err
}
pin, err := s.userPIN()
if err != nil {
return nil, err
}
if pin == nil {
return module.OpenSession(s.uri.Token, nil)
}
// The check, the login and recording a rejection happen under one lock, so two
// collections running at once cannot both send a PIN the token is about to refuse.
pinLoginMu.Lock()
defer pinLoginMu.Unlock()
key := rejectedPINKey(s.uri.Module(), s.uri.Token, pin)
if rejectedPINs.has(key) {
return nil, errPINRejectedBefore
}
session, err := module.OpenSession(s.uri.Token, pin)
if pkcs11.PINRejected(err) {
rejectedPINs.add(key)
return nil, fmt.Errorf("%s rejected the PIN, not retrying it until the daemon restarts: %w", s, err)
}
return session, err
}
func (s *PKCS11Store) userPIN() ([]byte, error) {
if s.pin != "" {
return []byte(s.pin), nil
}
return s.uri.PIN()
}
type tokenKey struct {
id []byte
public crypto.PublicKey
@@ -167,32 +232,6 @@ func (k tokenKeys) idFor(pub crypto.PublicKey) ([]byte, bool) {
return nil, false
}
func (s *PKCS11Store) String() string {
if s.uri.Token == "" {
return "PKCS#11 token"
}
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
}
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
module, err := pkcs11.Load(s.uri.Module())
if err != nil {
return nil, err
}
pin, err := s.userPIN()
if err != nil {
return nil, err
}
return module.OpenSession(s.uri.Token, pin)
}
func (s *PKCS11Store) userPIN() ([]byte, error) {
if s.pin != "" {
return []byte(s.pin), nil
}
return s.uri.PIN()
}
type tokenCertificate struct {
cert *x509.Certificate
id []byte
+71 -8
View File
@@ -11,6 +11,8 @@ import (
"errors"
"math/big"
"os"
"runtime"
"strconv"
"testing"
"time"
@@ -23,7 +25,12 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
const testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
const (
testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
// testPKCS11DisposableEnv marks the token as disposable, allowing tests that spend
// attempts of its PIN lockout counter.
testPKCS11DisposableEnv = "NB_TEST_PKCS11_DISPOSABLE"
)
type failingStore struct{}
@@ -34,7 +41,7 @@ func (failingStore) Candidates(context.Context) ([]Candidate, error) {
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key := certtest.ECDSAKey(t)
dir := t.TempDir()
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background())
@@ -182,7 +189,7 @@ func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrValue, k.D.FillBytes(make([]byte, 32))),
attr(pkcs11.AttrValue, ecPrivateScalar(t, k)),
}
case *rsa.PrivateKey:
k.Precompute()
@@ -252,7 +259,7 @@ func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) {
// token, the certificate is a PEM file in the directory, and the two are paired by public
// key because nothing on the token carries the certificate's CKA_ID.
func TestCollect_PKCS11KeyWithFileCertificate(t *testing.T) {
dir := t.TempDir()
dir := storeDir(t)
store, uri := pkcs11TestStore(t, dir)
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
@@ -279,7 +286,7 @@ func TestCollect_PKCS11KeyWithFileCertificate(t *testing.T) {
func TestPKCS11Store_FileChains(t *testing.T) {
ca := certtest.NewCA(t, "corp")
dir := t.TempDir()
dir := storeDir(t)
// Only certificate files without a key of their own belong to the token; the file
// store answers for the others, and non-certificate files are ignored.
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "device")))
@@ -310,9 +317,9 @@ func TestNewPKCS11Store_PIN(t *testing.T) {
wantPIN []byte
wantModule string
}{
{"pin alone opens the first p11-kit token", PKCS11Config{PIN: "1234"}, []byte("1234"), pkcs11.DefaultModule},
{"pin field wins over pin-value", PKCS11Config{URI: "pkcs11:?module-path=/lib/x.so&pin-value=0000", PIN: "1234"}, []byte("1234"), "/lib/x.so"},
{"uri pin-value stands in for a missing field", PKCS11Config{URI: "pkcs11:?pin-value=0000"}, []byte("0000"), pkcs11.DefaultModule},
{"pin with a token label opens that token through p11-kit", PKCS11Config{URI: "pkcs11:token=netbird", PIN: "1234"}, []byte("1234"), pkcs11.DefaultModule},
{"pin field wins over pin-value", PKCS11Config{URI: "pkcs11:token=netbird?module-path=" + absModule("x.so") + "&pin-value=0000", PIN: "1234"}, []byte("1234"), absModule("x.so")},
{"uri pin-value stands in for a missing field", PKCS11Config{URI: "pkcs11:token=netbird?pin-value=0000"}, []byte("0000"), pkcs11.DefaultModule},
{"no pin at all means no login", PKCS11Config{URI: "pkcs11:token=netbird"}, nil, pkcs11.DefaultModule},
}
for _, tt := range tests {
@@ -328,4 +335,60 @@ func TestNewPKCS11Store_PIN(t *testing.T) {
_, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "")
assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults")
for name, cfg := range map[string]PKCS11Config{
"env pin without uri": {PIN: "1234"},
"env pin, uri lacks token": {URI: "pkcs11:?module-path=" + absModule("x.so"), PIN: "1234"},
"inline pin-value only": {URI: "pkcs11:?pin-value=0000"},
"pin-source only": {URI: "pkcs11:?pin-source=file:/etc/netbird/pkcs11.pin"},
} {
_, err := NewPKCS11Store(cfg, "")
assert.ErrorIs(t, err, errPINNeedsToken, "%s: a PIN must not go to whichever token is listed first", name)
}
}
// TestPKCS11Store_WrongPINIsTriedOnce logs in to the real token with a wrong PIN: the
// token refuses it, and the next collection refuses to send the same PIN again rather
// than spending another attempt of the token's lockout counter. The one attempt it does
// spend counts against a real token's lockout, so it runs only on a token marked
// disposable through NB_TEST_PKCS11_DISPOSABLE.
func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) {
disposable, _ := strconv.ParseBool(os.Getenv(testPKCS11DisposableEnv))
if !disposable {
t.Skipf("set %s=1 to spend a wrong-PIN attempt on the token", testPKCS11DisposableEnv)
}
_, uri := pkcs11TestStore(t, "")
wrongPIN := "wrong-pin-" + t.Name()
store, err := NewPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "")
require.NoError(t, err)
_, err = store.Candidates(context.Background())
require.Error(t, err)
assert.True(t, pkcs11.PINRejected(err), "the token itself rejects the PIN: %v", err)
_, err = store.Candidates(context.Background())
assert.ErrorIs(t, err, errPINRejectedBefore, "the rejected PIN is not sent to the token again")
good, err := NewPKCS11Store(PKCS11Config{URI: uri}, "")
require.NoError(t, err)
_, err = good.Candidates(context.Background())
assert.NoError(t, err, "the correct PIN for the same token is unaffected")
}
// absModule is an absolute module path on the platform the test runs on, as module-path
// must be absolute.
func absModule(name string) string {
if runtime.GOOS == "windows" {
return `C:\lib\` + name
}
return "/lib/" + name
}
// ecPrivateScalar returns the raw private scalar the token stores in CKA_VALUE.
func ecPrivateScalar(t *testing.T, k *ecdsa.PrivateKey) []byte {
t.Helper()
raw, err := k.Bytes()
require.NoError(t, err)
return raw
}
+85 -34
View File
@@ -9,22 +9,41 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/tpm"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
// maxStoreFileSize bounds a certificate or key file of the PEM directory. A chain with
// its key is a few kilobytes.
maxStoreFileSize = 1 << 20
)
// errKeyMismatch rejects a key that does not belong to the certificate it sits with: it
// would sign a proof management can only reject, in place of a usable later candidate.
var (
errKeyMismatch = errors.New("private key does not match the certificate")
errNoSiblingKey = errors.New("no key file next to the certificate")
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
// path to a challenge's CAs can be found even where Chain followed a different issuer,
// such as an expired copy of a renewed intermediate.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
Chain []*x509.Certificate
Signer crypto.Signer
Intermediates []*x509.Certificate
}
// issuers is every certificate other than the leaf that a path may run through.
func (c Candidate) issuers() []*x509.Certificate {
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
@@ -33,19 +52,20 @@ type Store interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// Config selects where the Linux daemon looks for certificates: Dir is the PEM directory,
// empty for NB_CERT_STORE_DIR or /etc/netbird/certs, and PKCS11 names a token whose keys
// sign for certificates on the token or in that directory.
// Config selects where the daemon looks for certificates. PKCS11 names a token whose keys
// sign for certificates on the token or in the PEM directory, which NB_CERT_STORE_DIR
// names on Linux, /etc/netbird/certs by default.
//
// ProfileOwner is the OS account the active profile belongs to. On macOS and Windows only
// that account's certificate store is consulted for user certificates, so on a machine
// with several people signed in the result does not depend on who else is logged in.
// Empty means the profile has no owner, and only the user at the physical console counts.
// OwnerUnknown means the owner could not be determined, and no user store is consulted:
// guessing would let whoever sits at the console answer for the profile.
type Config struct {
Dir string
PKCS11 PKCS11Config
}
func (c Config) dir() string {
if c.Dir != "" {
return c.Dir
}
return StoreDir()
PKCS11 PKCS11Config
ProfileOwner string
OwnerUnknown bool
}
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
@@ -89,7 +109,16 @@ func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
}
// certFiles lists the certificate files in dir, none when the directory does not exist.
// The directory is checked before it is listed, so one that others can write to is
// refused before its entries are read.
func certFiles(dir string) ([]string, error) {
err := checkStoreDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("refusing certificate store: %w", err)
}
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
@@ -109,7 +138,7 @@ func certFiles(dir string) ([]string, error) {
// loadPEM reads a certificate file and its private key, held in the file itself or in
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
data, err := os.ReadFile(path)
data, err := readStoreFile(path)
if err != nil {
return nil, nil, err
}
@@ -120,25 +149,47 @@ func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
if len(chain) == 0 {
return nil, nil, errors.New("no certificate")
}
if signer != nil {
return chain, signer, nil
}
keyData, err := os.ReadFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if errors.Is(err, os.ErrNotExist) {
return chain, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("read key file: %w", err)
}
if _, signer, err = parsePEM(keyData); err != nil {
return nil, nil, err
}
if signer == nil {
return nil, nil, errors.New("no private key in key file")
signer, err = siblingKey(path)
switch {
case errors.Is(err, errNoSiblingKey):
// A certificate with no key of its own: the token store pairs it later.
return chain, nil, nil
case err != nil:
return nil, nil, err
}
}
if !samePublicKey(signer.Public(), chain[0].PublicKey) {
return nil, nil, errKeyMismatch
}
return chain, signer, nil
}
// siblingKey reads the private key from the "<name>.key" file next to a certificate
// file, reporting errNoSiblingKey when there is none.
func siblingKey(path string) (crypto.Signer, error) {
keyData, err := readStoreFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if errors.Is(err, os.ErrNotExist) {
return nil, errNoSiblingKey
}
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
_, signer, err := parsePEM(keyData)
if err != nil {
return nil, err
}
if signer == nil {
return nil, errors.New("no private key in key file")
}
return signer, nil
}
func samePublicKey(a, b crypto.PublicKey) bool {
equaler, ok := a.(interface{ Equal(crypto.PublicKey) bool })
return ok && equaler.Equal(b)
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
@@ -155,7 +206,7 @@ func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tpm.KeyPEMType:
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tss2KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
@@ -169,8 +220,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case tpm.KeyPEMType:
return tpm.ParseKey(block.Bytes)
case tss2KeyPEMType:
return parseTSS2Key(block.Bytes)
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
+18 -4
View File
@@ -1,8 +1,16 @@
//go:build !darwin && !windows
//go:build ((!darwin && !windows) || ios) && !js
package certproof
import log "github.com/sirupsen/logrus"
import (
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
var unsupportedTokenOnce sync.Once
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
func DefaultStore() Store {
@@ -12,11 +20,17 @@ func DefaultStore() Store {
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
// names one. The token pairs the directory's key-less certificates with its own keys.
func storeWithToken(cfg Config) Store {
files := NewFileStore(cfg.dir())
files := NewFileStore(StoreDir())
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
return files
}
token, err := NewPKCS11Store(cfg.PKCS11, cfg.dir())
if !pkcs11.Supported() {
unsupportedTokenOnce.Do(func() {
log.Warnf("ignoring the configured PKCS#11 token: %v", pkcs11.ErrUnsupported)
})
return files
}
token, err := NewPKCS11Store(cfg.PKCS11, StoreDir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
+22 -14
View File
@@ -1,4 +1,4 @@
//go:build !darwin && !windows
//go:build ((!darwin && !windows) || ios) && !js
package certproof
@@ -7,26 +7,34 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
func TestStoreWithToken(t *testing.T) {
dir := t.TempDir()
dir := storeDir(t)
t.Setenv(StoreDirEnv, dir)
files, ok := storeWithToken(Config{Dir: dir}).(*FileStore)
require.True(t, ok, "a directory alone reads that directory alone")
assert.Equal(t, dir, files.dir, "the configured directory replaces the default")
files, ok = storeWithToken(Config{}).(*FileStore)
require.True(t, ok, "nothing configured reads the PEM directory alone")
assert.Equal(t, StoreDir(), files.dir, "no directory configured falls back to the environment or the default")
files, ok := storeWithToken(Config{}).(*FileStore)
require.True(t, ok, "no token configured reads the PEM directory alone")
assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory")
for name, cfg := range map[string]PKCS11Config{
"pin alone": {PIN: "1234"},
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
} {
store, ok := storeWithToken(Config{Dir: dir, PKCS11: cfg}).(Stores)
configured := map[string]PKCS11Config{
"env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"},
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
}
if !pkcs11.Supported() {
for name, cfg := range configured {
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: cfg}),
"%s: a build without PKCS#11 support reads the PEM directory alone", name)
}
return
}
for name, cfg := range configured {
store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores)
require.True(t, ok, "%s joins the token to the PEM directory", name)
require.Len(t, store, 2, name)
token, ok := store[1].(*PKCS11Store)
+4 -6
View File
@@ -13,7 +13,6 @@ import (
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
@@ -28,15 +27,14 @@ func TestFileStore_TPMKeyFile(t *testing.T) {
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := t.TempDir()
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
// A key that needs a password can never be used silently, so its certificate is skipped.
locked := certtest.ECDSAKey(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), withAuth))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth()))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
@@ -66,7 +64,7 @@ func TestCollect_TPMKeyEndToEnd(t *testing.T) {
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := t.TempDir()
dir := storeDir(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
@@ -88,7 +86,7 @@ func createTPMKey(t *testing.T) (public, private []byte) {
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tss2.ECCSRKTemplate)
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tpmtest.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
+17
View File
@@ -0,0 +1,17 @@
//go:build !js
package certproof
import (
"crypto"
"github.com/netbirdio/netbird/client/internal/tpm"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file.
const tss2KeyPEMType = tpm.KeyPEMType
// parseTSS2Key returns a signer for a TPM-held key file that signs inside the TPM.
func parseTSS2Key(der []byte) (crypto.Signer, error) {
return tpm.ParseKey(der)
}
@@ -0,0 +1,16 @@
//go:build js
package certproof
import (
"crypto"
"errors"
)
// tss2KeyPEMType is the PEM block type of a TPM 2.0 key file. A browser has no TPM, so
// the TPM library stays out of the WebAssembly build and such a key is refused.
const tss2KeyPEMType = "TSS2 PRIVATE KEY"
func parseTSS2Key([]byte) (crypto.Signer, error) {
return nil, errors.New("TPM keys are not supported in this build")
}
@@ -0,0 +1,34 @@
//go:build !unix
package certproof
import (
"fmt"
"io"
"os"
)
// readStoreFile reads a certificate or key file of the PEM directory, up to the size
// limit. Ownership is checked on Unix only; elsewhere the PEM directory is not the store
// the daemon reads by default.
func readStoreFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s is over the %d byte limit", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir accepts any directory where the PEM directory is not a default store.
func checkStoreDir(string) error {
return nil
}
+101
View File
@@ -0,0 +1,101 @@
//go:build unix
package certproof
import (
"errors"
"fmt"
"io"
"os"
"syscall"
)
// readStoreFile reads a certificate or key file of the PEM directory as the daemon may
// trust it: the path must not be a symlink and must be a regular file that only root or
// this process's own user can write. Otherwise a user able to place or redirect a file
// could make the daemon sign with a key of their choosing, or with a root-only key kept
// elsewhere on the machine. The file is checked through the descriptor it is read from,
// so it cannot be swapped between the check and the read.
func readStoreFile(path string) ([]byte, error) {
// O_NONBLOCK keeps a FIFO planted in the directory from blocking the open; the
// regular-file check below then refuses it.
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", path)
}
if err := checkTrustedOwner(info); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if info.Size() > maxStoreFileSize {
return nil, fmt.Errorf("%s is %d bytes, over the %d byte limit", path, info.Size(), maxStoreFileSize)
}
data, err := io.ReadAll(io.LimitReader(f, maxStoreFileSize+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxStoreFileSize {
return nil, fmt.Errorf("%s grew past the %d byte limit while being read", path, maxStoreFileSize)
}
return data, nil
}
// checkStoreDir refuses a PEM directory that someone other than root or this process's
// user could add files to or rename files in. The configured path may be a symlink, as
// distributions place certificate directories behind them, but only one root or this
// process's user owns: otherwise whoever owns the link could point it at any directory.
func checkStoreDir(dir string) error {
link, err := os.Lstat(dir)
if err != nil {
return err
}
if link.Mode()&os.ModeSymlink != 0 {
if err := checkTrustedUID(link); err != nil {
return fmt.Errorf("symlink %s: %w", dir, err)
}
}
info, err := os.Stat(dir)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", dir)
}
if err := checkTrustedOwner(info); err != nil {
return fmt.Errorf("%s: %w", dir, err)
}
return nil
}
// checkTrustedOwner requires info to belong to root or to this process's user and to be
// writable by nobody else. Group write is refused even for root's group, which ordinary
// users may be members of. An owner that cannot be read is refused.
func checkTrustedOwner(info os.FileInfo) error {
if err := checkTrustedUID(info); err != nil {
return err
}
if perm := info.Mode().Perm(); perm&0o022 != 0 {
return fmt.Errorf("writable by group or other users (mode %#o)", perm)
}
return nil
}
// checkTrustedUID requires info to belong to root or to this process's user.
func checkTrustedUID(info os.FileInfo) error {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("owner cannot be determined")
}
if st.Uid != 0 && int(st.Uid) != os.Geteuid() {
return fmt.Errorf("owned by uid %d, not by root", st.Uid)
}
return nil
}
@@ -0,0 +1,136 @@
//go:build unix
package certproof
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
// devicePair writes a certificate and its key as separate files into dir.
func devicePair(t *testing.T, dir string) {
t.Helper()
ca := certtest.NewCA(t, "corp-root")
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.crt", certtest.CertPEM(ca.Issue(t, key, "device")))
writeFile(t, dir, "device.key", certtest.KeyPEM(t, key))
}
func candidates(t *testing.T, dir string) ([]Candidate, error) {
t.Helper()
return NewFileStore(dir).Candidates(context.Background())
}
func TestFileStore_AcceptsAPrivatePair(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate and key only their owner can write are used")
}
func TestFileStore_RefusesASymlinkedKey(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
// The real key moves elsewhere and the directory only links to it, as a user able
// to write the directory would do to point the daemon at a key it should not use.
elsewhere := filepath.Join(storeDir(t), "other.key")
require.NoError(t, os.Rename(filepath.Join(dir, "device.key"), elsewhere))
require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key reached through a symlink is never used")
}
func TestFileStore_RefusesASymlinkedCertificateFile(t *testing.T) {
dir := storeDir(t)
other := storeDir(t)
devicePair(t, other)
require.NoError(t, os.Symlink(filepath.Join(other, "device.crt"), filepath.Join(dir, "device.crt")))
require.NoError(t, os.Symlink(filepath.Join(other, "device.key"), filepath.Join(dir, "device.key")))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a certificate file that is a symlink is skipped")
}
func TestFileStore_RefusesFilesOthersCanWrite(t *testing.T) {
for name, mode := range map[string]os.FileMode{"world-writable": 0o602, "group-writable": 0o620} {
t.Run(name, func(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(filepath.Join(dir, "device.key"), mode))
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Empty(t, got, "a key %s may have been replaced by someone else, even when the group is root's", name)
})
}
}
func TestFileStore_RefusesADirectoryOthersCanWrite(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
_, err := candidates(t, dir)
assert.ErrorContains(t, err, "refusing certificate store", "files in a directory anyone can write are not trusted")
}
func TestFileStore_SkipsAFIFOWithoutBlocking(t *testing.T) {
dir := storeDir(t)
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "trap.pem"), 0o600))
devicePair(t, dir)
got, err := candidates(t, dir)
require.NoError(t, err)
assert.Len(t, got, 1, "a FIFO is skipped and the real pair still loads")
}
func TestReadStoreFile_RefusesOversizedFiles(t *testing.T) {
dir := storeDir(t)
path := filepath.Join(dir, "huge.pem")
require.NoError(t, os.WriteFile(path, make([]byte, maxStoreFileSize+1), 0o600))
_, err := readStoreFile(path)
assert.ErrorContains(t, err, "limit")
}
func TestFileStore_AcceptsASymlinkedDirectoryItsOwnerControls(t *testing.T) {
target := storeDir(t)
devicePair(t, target)
link := filepath.Join(storeDir(t), "certs")
require.NoError(t, os.Symlink(target, link))
// The link belongs to this process's user, as one root creates belongs to root.
got, err := candidates(t, link)
require.NoError(t, err)
assert.Len(t, got, 1, "a certificate directory behind a symlink the owner controls is read")
}
func TestFileStore_ChecksTheDirectoryBeforeListingIt(t *testing.T) {
dir := storeDir(t)
devicePair(t, dir)
require.NoError(t, os.Chmod(dir, 0o777))
paths, err := certFiles(dir)
assert.ErrorContains(t, err, "refusing certificate store")
assert.Nil(t, paths, "nothing in a directory anyone can write to is listed")
}
func TestFileStore_MissingDirectoryIsEmpty(t *testing.T) {
paths, err := certFiles(filepath.Join(storeDir(t), "missing"))
assert.NoError(t, err, "a store directory that does not exist yet is not an error")
assert.Empty(t, paths)
}
@@ -23,9 +23,15 @@ const (
cryptAcquireSilentFlag = 0x00000040
cryptAcquirePreferNCryptKeyFlag = 0x00020000
certNCryptKeySpec = 0xFFFFFFFF
atKeyExchange = 1
atSignature = 2
bcryptPadPSS = 0x00000008
)
// errLegacyKey reports a private key CNG cannot open: it lives in a legacy CryptoAPI
// provider, which only offers PKCS#1 v1.5 and no RSA-PSS or ECDSA.
var errLegacyKey = errors.New("private key is held by a legacy CryptoAPI provider, which certificate posture cannot sign with")
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
@@ -78,7 +84,7 @@ func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
if err != nil {
return nil, err
}
log.Infof("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
log.Debugf("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
if len(leaves) == 0 {
return nil, nil
}
@@ -87,8 +93,8 @@ func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
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}})
log.Debugf("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}, Intermediates: pool})
}
return candidates, nil
}
@@ -153,7 +159,7 @@ func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte)
if callerFree != 0 {
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
}
return nil, errors.New("legacy CryptoAPI keys are not supported")
return nil, legacyKeyError(keySpec)
}
if callerFree != 0 {
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
@@ -181,6 +187,19 @@ func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte)
return ecdsaSignatureASN1(signature)
}
// legacyKeyError describes a key acquired through CryptoAPI rather than CNG, naming its
// key spec and the remedy, since the certificate otherwise looks usable in the store.
func legacyKeyError(keySpec uint32) error {
spec := fmt.Sprintf("key spec %d", keySpec)
switch keySpec {
case atKeyExchange:
spec = "AT_KEYEXCHANGE"
case atSignature:
spec = "AT_SIGNATURE"
}
return fmt.Errorf("%w (%s); re-enrol it with a certificate template whose provider is a CNG key storage provider", errLegacyKey, spec)
}
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
var result uint32
var signaturePtr uintptr
@@ -0,0 +1,16 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLegacyKeyError_NamesTheKeySpecAndTheRemedy(t *testing.T) {
for spec, want := range map[uint32]string{atKeyExchange: "AT_KEYEXCHANGE", atSignature: "AT_SIGNATURE", 7: "key spec 7"} {
err := legacyKeyError(spec)
assert.ErrorIs(t, err, errLegacyKey, "callers can tell a legacy key from other failures")
assert.ErrorContains(t, err, want, "the key spec found is named")
assert.ErrorContains(t, err, "key storage provider", "the error says how to fix the enrolment")
}
}
+34 -4
View File
@@ -76,6 +76,9 @@ type ConnectClient struct {
// netMgr gates every reconnection loop on OS-reported network
// availability and sweeps connections on network change.
netMgr *netevents.Manager
profileOwner string
profileOwnerUnknown bool
}
// ConnectClientOption configures optional ConnectClient behavior.
@@ -86,6 +89,18 @@ func WithNetEvents(events *netevents.Manager) ConnectClientOption {
return func(c *ConnectClient) { c.netMgr = events }
}
// WithProfileOwner names the OS account the active profile belongs to, whose own
// certificate store answers user certificate posture checks.
func WithProfileOwner(username string) ConnectClientOption {
return func(c *ConnectClient) { c.profileOwner = username }
}
// WithUnknownProfileOwner records that the active profile's owner could not be
// determined, so no user's certificate store answers certificate posture checks.
func WithUnknownProfileOwner() ConnectClientOption {
return func(c *ConnectClient) { c.profileOwnerUnknown = true }
}
func NewConnectClient(
ctx context.Context,
config *profilemanager.Config,
@@ -421,6 +436,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
engineConfig.CertStore.ProfileOwner = c.profileOwner
engineConfig.CertStore.OwnerUnknown = c.profileOwnerUnknown
// Leave StateDir empty when there is no state path so a disk-backed
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
if path != "" {
@@ -676,10 +693,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
LazyConnection: lazyconn.ParseState(config.LazyConnection),
CertStore: certproof.Config{
Dir: config.CertStoreDir,
PKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN},
},
CertStore: certStoreConfig(config),
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
@@ -707,6 +721,22 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
return engineConf, nil
}
// certStoreConfig reads where certificate posture finds certificates from the daemon's
// environment, NB_CERT_STORE_DIR and NB_CERT_PKCS11_URI with NB_TPM_PIN. The profile
// config fields that once held them are ignored, and a value left there is reported
// once, so a setup relying on it does not silently stop proving.
func certStoreConfig(config *profilemanager.Config) certproof.Config {
if config.CertStoreDir != "" || config.CertPKCS11URI != "" {
legacyCertConfigOnce.Do(func() {
log.Warnf("certificate posture: CertStoreDir and CertPKCS11URI in the profile config are ignored, set %s and %s in the daemon's environment instead",
certproof.StoreDirEnv, certproof.PKCS11URIEnv)
})
}
return certproof.Config{PKCS11: certproof.PKCS11FromEnv()}
}
var legacyCertConfigOnce sync.Once
func selectMTU(localMTU uint16, peerMTU int32) uint16 {
var finalMTU uint16 = iface.DefaultMTU
if localMTU > 0 {
+26
View File
@@ -0,0 +1,26 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// TestCertStoreConfig_ReadsEnvironmentNotProfile: the token URI may carry the PIN, so it
// and the store directory come from the daemon's environment, and values left in the
// profile config from an older version are ignored.
func TestCertStoreConfig_ReadsEnvironmentNotProfile(t *testing.T) {
t.Setenv(certproof.PKCS11URIEnv, "pkcs11:token=env")
t.Setenv(certproof.PINEnv, "1234")
cfg := certStoreConfig(&profilemanager.Config{
CertStoreDir: "/from/profile",
CertPKCS11URI: "pkcs11:token=profile?pin-value=9999",
})
assert.Equal(t, "pkcs11:token=env", cfg.PKCS11.URI, "the URI comes from the environment")
assert.Equal(t, "1234", cfg.PKCS11.PIN, "the PIN comes from the environment")
}
+1 -1
View File
@@ -647,7 +647,7 @@ const (
jsonKeyServiceEnv = "service_env_vars"
)
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential"}
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential", "pin", "pkcs11"}
// addServiceParams reads the service.json file and adds a sanitized version to the bundle.
// Non-NB_ env vars and vars with sensitive names are masked. Other NB_ values are anonymized.
+10
View File
@@ -458,6 +458,9 @@ func TestIsSensitiveEnvVar(t *testing.T) {
{"NB_CLIENT_SECRET", true},
{"NB_PASSWORD", true},
{"NB_CREDENTIAL", true},
// The token PIN, and the token URI, which may carry the PIN as pin-value.
{"NB_TPM_PIN", true},
{"NB_CERT_PKCS11_URI", true},
{"NB_LOG_LEVEL", false},
{"NB_MANAGEMENT_URL", false},
{"NB_HOSTNAME", false},
@@ -848,6 +851,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
"probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with",
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
"CertPKCS11URI": "deprecated and ignored; sensitive: the URI may carry the token PIN as pin-value",
"CertStoreDir": "deprecated and ignored: the directory comes from NB_CERT_STORE_DIR",
}
mURL, _ := url.Parse("https://api.example.com:443")
@@ -891,6 +896,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
CertPKCS11URI: "pkcs11:token=netbird?pin-value=pin-secret",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
@@ -915,6 +921,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")
// A leftover CertPKCS11URI may carry the token PIN as pin-value.
assert.NotContains(t, rendered, "CertPKCS11URI", "PKCS#11 URI field must not be serialized into the debug bundle")
assert.NotContains(t, rendered, "pin-secret", "PKCS#11 PIN must not leak into the debug bundle")
val := reflect.ValueOf(cfg).Elem()
typ := val.Type()
var missing []string
+72 -24
View File
@@ -275,8 +275,26 @@ type Engine struct {
dnsServer dns.Server
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks
// checks are the client-applied posture checks that need to be evaluated on the client.
// Writers hold syncMsgMux and checksMu; readers hold either, see appliedChecks.
checks []*mgmProto.Checks
checksMu sync.RWMutex
// pendingChecks are the newest received checks whose meta sync failed; the posture
// watcher retries them. Both are guarded by syncMsgMux, and
// hasPendingChecks lets the watcher skip the lock when nothing is pending.
pendingChecks []*mgmProto.Checks
hasPendingChecks atomic.Bool
// infoTimeout overrides systemInfoTimeout when set.
infoTimeout time.Duration
// certProofs answers the certificate challenges in checks within a bounded time, and
// certState remembers what it last proved.
certProofs certproof.Collector
certState certPostureState
// certWake wakes the posture watcher, which owns proof collection; certSendMu orders
// the meta syncs that carry proofs.
certWake chan struct{}
certSendMu sync.Mutex
infoSource system.InfoSource
@@ -381,6 +399,7 @@ func NewEngine(
stateManager: services.StateManager,
portForwardManager: portforward.NewManager(),
checks: services.Checks,
certWake: make(chan struct{}, 1),
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
@@ -667,6 +686,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.portForwardManager.Start(e.ctx, uint16(e.config.WgPort))
}()
e.shutdownWg.Add(1)
go func(ctx context.Context) {
defer e.shutdownWg.Done()
e.watchCertificatePosture(ctx)
}(e.ctx)
// Set the WireGuard interface for rosenpass after interface is up
if e.rpManager != nil {
e.rpManager.SetInterface(e.wgInterface)
@@ -1234,24 +1259,54 @@ func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error
func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
// if checks are equal, we skip the update
if isChecksEqual(e.checks, checks) {
e.clearPendingChecks()
return nil
}
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
return nil
if err := e.syncChecksMeta(checks); err != nil {
// The newest checks become the pending ones whatever failed, so the watcher
// never retries a set they superseded.
e.pendingChecks = checks
e.hasPendingChecks.Store(true)
if errors.Is(err, errSystemInfoTimeout) {
return nil
}
return err
}
e.applyInfoFlags(info)
e.attachCertificateProofs(info, checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
}
e.checks = checks
e.setAppliedChecks(checks)
e.clearPendingChecks()
return nil
}
// appliedChecks returns the posture checks in effect, for callers that do not hold
// syncMsgMux. The slice is replaced, never modified, so it may be read after return.
func (e *Engine) appliedChecks() []*mgmProto.Checks {
e.checksMu.RLock()
defer e.checksMu.RUnlock()
return e.checks
}
// setAppliedChecks replaces the posture checks in effect. The caller holds syncMsgMux.
func (e *Engine) setAppliedChecks(checks []*mgmProto.Checks) {
e.checksMu.Lock()
e.checks = checks
e.checksMu.Unlock()
e.wakeCertificatePosture()
}
// clearPendingChecks drops checks whose meta sync was still owed. The caller holds
// syncMsgMux.
func (e *Engine) clearPendingChecks() {
e.pendingChecks = nil
e.hasPendingChecks.Store(false)
}
func (e *Engine) infoGatherTimeout() time.Duration {
if e.infoTimeout > 0 {
return e.infoTimeout
}
return systemInfoTimeout
}
// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
func (e *Engine) applyInfoFlags(info *system.Info) {
info.SetFlags(
@@ -1275,17 +1330,10 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
)
}
// attachCertificateProofs answers the certificate challenges in checks with the
// 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.CollectProofs(e.ctx, checks, peerKey[:], e.config.CertStore)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
e.attachCertificateProofs(info, e.appliedChecks())
return info
}
@@ -1301,7 +1349,7 @@ func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context)
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
e.attachCertificateProofs(info, e.appliedChecks())
return info
}
}
@@ -1499,7 +1547,7 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), e.appliedChecks(), e.overlayAddresses()...)
if !ok {
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
}
+1 -1
View File
@@ -88,7 +88,7 @@ func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.T
return time.Time{}, errors.New("management client is not initialised")
}
info, err := system.GetInfoWithChecks(ctx, e.checks)
info, err := system.GetInfoWithChecks(ctx, e.appliedChecks())
if err != nil {
log.Warnf("failed to collect system info for session extend: %v", err)
info = system.GetInfo(ctx)
+358
View File
@@ -0,0 +1,358 @@
package internal
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/certproof"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
const (
// certContextPollInterval is how often the engine checks whether the user who can
// answer certificate challenges has changed, such as a login after an autostart.
certContextPollInterval = time.Minute
// certRetryInterval is how long after a collection that proved nothing it is tried
// again with the same user, for stores that come up late: a keychain unlocked after
// login, or a TPM resource manager started after the daemon.
certRetryInterval = 5 * time.Minute
)
// undeliveredContext never matches a real user context, so a collection whose meta sync
// failed is seen as stale on the next poll.
const undeliveredContext = "\x00undelivered"
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
var errSystemInfoTimeout = errors.New("system info gathering timed out")
// certPostureState remembers what the last certificate proof collection saw, so the
// engine can collect again when it went stale and tell the user when proofs go missing.
//
// Collection runs only on the posture watcher, never on the sync path, because a token,
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
// last collection cached. delivered identifies the chains management last received, so
// a collection that proves the same chains is not sent again: every meta sync makes
// management recompute and push network maps to the peer and its neighbours.
type certPostureState struct {
mu sync.Mutex
attempted bool
attemptedAt time.Time
userContext string
proven bool
delivered string
hasDelivery bool
cached []certposture.Proof
cachedFor string
}
// record stores the outcome of a collection for the challenges identified by
// challengesKey and reports whether it changed from proven to unproven or back. The first
// collection only counts as a change when it proved nothing.
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
s.mu.Lock()
defer s.mu.Unlock()
proven := len(proofs) > 0
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
s.attempted = true
s.attemptedAt = now
s.userContext = userContext
s.proven = proven
s.cached = proofs
s.cachedFor = challengesKey
return changed
}
// undelivered marks the last collection as not having reached management, so the next
// poll collects and sends again instead of treating it as current.
func (s *certPostureState) undelivered() {
s.mu.Lock()
defer s.mu.Unlock()
if s.attempted {
s.userContext = undeliveredContext
}
s.hasDelivery = false
}
// markDelivered records the chains of proofs as the ones management holds.
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
s.mu.Lock()
defer s.mu.Unlock()
s.delivered = provenChainsKey(proofs)
s.hasDelivery = true
}
// sameAsDelivered reports whether proofs prove exactly the chains management already
// holds. Nonces and signatures are left out: they differ on every signing while the
// result management stores, the verified chains, stays the same.
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
}
// needsCollection reports whether the cached proofs no longer reflect what the device
// can prove for the challenges identified by challengesKey: they answer other
// challenges, the user who can answer has changed, or nothing was proven and the retry
// interval has passed.
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case !s.attempted || s.cachedFor != challengesKey:
return true
case userContext != s.userContext:
return true
default:
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
}
}
// cachedProofs returns the cached proofs management still accepts for the challenges in
// checks: those whose nonce is current, or from the window before, for one of them. Proofs
// signed for the previous nonce bridge the time until the watcher has signed the new one.
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
s.mu.Lock()
defer s.mu.Unlock()
var nonces [][]byte
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil {
nonces = append(nonces, challenge.GetNonce())
}
}
var accepted []certposture.Proof
for _, proof := range s.cached {
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
}) {
accepted = append(accepted, proof)
}
}
return accepted
}
// provenChainsKey identifies the set of chains in proofs, independent of their order.
func provenChainsKey(proofs []certposture.Proof) string {
keys := make([]string, 0, len(proofs))
for _, proof := range proofs {
h := sha256.New()
for _, cert := range proof.Chain {
writeLengthPrefixed(h, cert)
}
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
}
slices.Sort(keys)
return strings.Join(keys, ",")
}
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
func challengesKey(checks []*mgmProto.Checks) string {
h := sha256.New()
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
writeLengthPrefixed(h, challenge.GetNonce())
for _, ca := range challenge.GetCaCertificates() {
writeLengthPrefixed(h, []byte(ca))
}
writeLengthPrefixed(h, nil)
}
return hex.EncodeToString(h.Sum(nil))
}
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
_, _ = h.Write(size[:])
_, _ = h.Write(data)
}
// attachCertificateProofs attaches the cached proofs management accepts for the
// challenges in checks, without collecting: the caller is on the sync path. The info is
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
// count as delivered. The watcher is woken to collect when the cache is out of date.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
if !certproof.HasChallenges(checks) {
info.CertificateProofs = nil
return
}
info.CertificateProofs = e.certState.cachedProofs(checks)
e.certState.markDelivered(info.CertificateProofs)
e.wakeCertificatePosture()
}
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
// its next tick. It never blocks.
func (e *Engine) wakeCertificatePosture() {
if e.certWake == nil {
return
}
select {
case e.certWake <- struct{}{}:
default:
}
}
// publishCertificatePostureEvent tells the user when the device stops proving any
// certificate, which management treats as failing every certificate posture check, and
// when it proves one again. Without it the loss of access would have no visible cause.
func (e *Engine) publishCertificatePostureEvent(proven bool) {
if e.statusRecorder == nil {
return
}
if proven {
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
"certificate posture: a certificate is proven again",
"A certificate required by your organization's device policy is available again.", nil)
return
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
"certificate posture: no certificate could be proven",
"NetBird could not use a certificate required by your organization's device policy. "+
"Access to some resources may be blocked until one is available.", nil)
}
// watchCertificatePosture owns certificate proof collection until ctx is done. It
// collects when woken by new checks or a sync, and on every tick when the cached proofs
// went stale, such as after a login following an autostart.
func (e *Engine) watchCertificatePosture(ctx context.Context) {
ticker := time.NewTicker(certContextPollInterval)
defer ticker.Stop()
for {
// A pending update that times out again stays pending; the applied checks'
// proofs are still refreshed so a lost certificate is reported meanwhile.
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to sync posture checks that timed out before: %v", err)
}
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
log.Warnf("failed to refresh certificate posture proofs: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-e.certWake:
}
}
}
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
// gathering the system info, and applies them once it succeeds. Without it they would
// wait for the next network map that changes the checks.
func (e *Engine) retryPendingChecks() error {
if !e.hasPendingChecks.Load() {
return nil
}
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.stopping() || !e.hasPendingChecks.Load() {
return nil
}
checks := e.pendingChecks
log.Debugf("posture checks: retrying the meta sync that timed out")
if err := e.syncChecksMeta(checks); err != nil {
return err
}
e.setAppliedChecks(checks)
e.clearPendingChecks()
return nil
}
// refreshCertificateProofs collects the proofs for the applied checks when the cached
// ones are out of date, and sends them to management when they prove other chains than
// it holds. It holds no engine lock while collecting.
func (e *Engine) refreshCertificateProofs() error {
checks := e.appliedChecks()
if e.stopping() || !certproof.HasChallenges(checks) {
return nil
}
key := challengesKey(checks)
userContext := certproof.UserContext(e.config.CertStore)
if !e.certState.needsCollection(key, userContext, time.Now()) {
return nil
}
log.Debugf("certificate posture: collecting proofs")
peerKey := e.config.WgPrivateKey.PublicKey()
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
// A collection cut short by the engine stopping proved nothing about the device, and
// must not be reported to the user as a lost certificate.
if e.stopping() {
return nil
}
if e.certState.record(key, userContext, proofs, time.Now()) {
e.publishCertificatePostureEvent(len(proofs) > 0)
}
if e.certState.sameAsDelivered(proofs) {
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
return nil
}
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// stopping reports whether the engine is shutting down.
func (e *Engine) stopping() bool {
select {
case <-e.ctx.Done():
return true
default:
return false
}
}
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
// with the cached certificate proofs. The caller holds syncMsgMux.
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
e.certState.undelivered()
return errSystemInfoTimeout
}
e.applyInfoFlags(info)
return e.sendMetaWithProofs(info, checks)
}
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
// are serialized and read the cache under that lock, so a send from the sync path cannot
// overwrite newer proofs the watcher sent in the meantime.
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
e.certSendMu.Lock()
defer e.certSendMu.Unlock()
info.CertificateProofs = e.certState.cachedProofs(checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
e.certState.undelivered()
return fmt.Errorf("sync meta: %w", err)
}
e.certState.markDelivered(info.CertificateProofs)
return nil
}
+213
View File
@@ -0,0 +1,213 @@
//go:build !windows && !darwin
package internal
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
mgmt "github.com/netbirdio/netbird/shared/management/client"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertPostureState_Record(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var proven certPostureState
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
var unproven certPostureState
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
}
func TestCertPostureState_NeedsCollection(t *testing.T) {
now := time.Now()
one := []certposture.Proof{{}}
var s certPostureState
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
s.record("k", "501:alice", one, now)
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
s.record("k", "", nil, now)
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
s.record("k", "501:alice", one, now)
s.undelivered()
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
}
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
// window and accepts the previous one, so a cached proof keeps being attached across one
// rotation, while the watcher signs the new nonce, and not across two.
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
challenger := certposture.NewChallenger([]byte("secret"))
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
now := time.Now()
checksAt := func(at time.Time) []*mgmProto.Checks {
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
}
var s certPostureState
current := checksAt(now)
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
}
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
// whose management client records the proofs of every meta sync.
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
t.Helper()
// The store refuses a group-writable directory, which t.TempDir yields under a
// user-private-group umask.
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o700))
t.Setenv(certproof.StoreDirEnv, dir)
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
e := &Engine{
ctx: context.Background(),
syncMsgMux: &sync.Mutex{},
config: &EngineConfig{WgPrivateKey: key},
statusRecorder: recorder,
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
}
ca := certtest.NewCA(t, "corp-root")
peerKey := key.PublicKey()
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
return e, dir, ca
}
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
t.Helper()
deviceKey := certtest.ECDSAKey(t)
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
return pem
}
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
// watcher collected, so a certificate that appears in the store is not proven on the sync
// path until the watcher ran.
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
writeDeviceCert(t, dir, ca)
info := &system.Info{}
e.attachCertificateProofs(info, e.checks)
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
require.NoError(t, e.refreshCertificateProofs())
e.attachCertificateProofs(info, e.checks)
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
}
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
// certificate gets one warning, and one notice when it can again.
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
recorder := peer.NewRecorder("")
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
warnings := func() int {
n := 0
for _, ev := range recorder.GetEventHistory() {
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
n++
}
}
return n
}
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
events := recorder.GetEventHistory()
require.NotEmpty(t, events)
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
}
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
// and push network maps, so a collection that proves the same chains as before must not
// send, while a new certificate or a failed delivery must.
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
var sent [][]certposture.Proof
failSync := false
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
if failSync {
return errors.New("management unavailable")
}
sent = append(sent, info.CertificateProofs)
return nil
})
// expire makes the last collection due for the unproven retry.
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
// switchUser makes the last collection stale as if another user signed in.
switchUser := func() { e.certState.userContext = "someone else" }
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 1, "the first collection is sent")
assert.Empty(t, sent[0], "nothing to prove yet")
expire()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 1, "still proving nothing is not sent again")
pem := writeDeviceCert(t, dir, ca)
expire()
require.NoError(t, e.refreshCertificateProofs())
require.Len(t, sent, 2, "a newly proven certificate is sent")
assert.Len(t, sent[1], 1, "the new proof is attached")
switchUser()
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
failSync = true
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
switchUser()
require.Error(t, e.refreshCertificateProofs())
failSync = false
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
// Management missed the empty proof set, so it still holds the chain the device proves
// again now; after a failed delivery the state is unknown, and it is sent regardless.
require.NoError(t, e.refreshCertificateProofs())
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
}
+105
View File
@@ -359,6 +359,111 @@ func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
assert.Equal(t, 2, syncMetaCalls)
}
// TestEngine_FailedUpdateReplacesOlderPendingChecks: checks A time out and stay pending,
// then checks B fail to sync for another reason. B must replace A as pending, or the
// watcher would later apply the superseded A.
func TestEngine_FailedUpdateReplacesOlderPendingChecks(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun107",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33102,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: &mgmt.MockClient{SyncMetaFunc: func(*system.Info) error { return errors.New("management unavailable") }},
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checksA := []*mgmtProto.Checks{{Files: []string{"/checks/a"}}}
checksB := []*mgmtProto.Checks{{Files: []string{"/checks/b"}}}
engine.infoTimeout = time.Nanosecond
require.NoError(t, engine.updateChecksIfNew(checksA))
require.Equal(t, checksA, engine.pendingChecks, "precondition: the timed-out checks are pending")
// Let the abandoned gathering finish so B gets as far as the meta sync.
engine.infoTimeout = 0
require.Eventually(t, func() bool {
_, ok := engine.infoSource.Refresh(ctx, 10*time.Second, nil)
return ok
}, 10*time.Second, 10*time.Millisecond)
require.Error(t, engine.updateChecksIfNew(checksB), "the meta sync of B fails")
assert.Equal(t, checksB, engine.pendingChecks, "the newest checks replace the older pending ones")
}
// TestEngine_PendingChecksRetriedAfterInfoTimeout covers a check update whose system info
// gathering times out: the update is kept pending rather than dropped, and the posture
// watcher's retry sends it and applies the checks, without waiting for management to
// send different checks.
func TestEngine_PendingChecksRetriedAfterInfoTimeout(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
var synced []*system.Info
mgmClient := &mgmt.MockClient{
SyncMetaFunc: func(info *system.Info) error {
synced = append(synced, info)
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun106",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33101,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
// Gathering cannot finish within a nanosecond, so the sync times out.
engine.infoTimeout = time.Nanosecond
require.NoError(t, engine.updateChecksIfNew(checks), "a timed-out gathering is not an error")
assert.Empty(t, synced, "nothing is sent when gathering timed out")
assert.Nil(t, engine.checks, "timed-out checks are not applied")
require.True(t, engine.hasPendingChecks.Load(), "timed-out checks are kept pending")
// The timed-out gathering keeps running in the background, and no new one starts
// until it exits, so the retry may time out again before it goes through.
engine.infoTimeout = 0
var retryErr error
require.Eventually(t, func() bool {
retryErr = engine.retryPendingChecks()
return retryErr == nil || !errors.Is(retryErr, errSystemInfoTimeout)
}, 10*time.Second, 10*time.Millisecond, "the pending checks are sent once the earlier gathering exits")
require.NoError(t, retryErr)
require.Len(t, synced, 1, "the retry sends the meta sync")
assert.Len(t, synced[0].Files, 1, "the retry evaluates the pending checks")
assert.Equal(t, checks, engine.checks, "the retried checks are applied")
assert.False(t, engine.hasPendingChecks.Load(), "nothing is pending after the retry")
require.NoError(t, engine.retryPendingChecks())
require.NoError(t, engine.updateChecksIfNew(checks))
assert.Len(t, synced, 1, "applied checks are not sent again")
}
func TestEngine_UpdateNetworkMap(t *testing.T) {
// test setup
key, err := wgtypes.GeneratePrivateKey()
+56 -12
View File
@@ -1,4 +1,4 @@
//go:build pkcs11 && linux && (amd64 || arm64)
//go:build pkcs11 && linux && !android && (amd64 || arm64)
package pkcs11
@@ -10,6 +10,7 @@ import (
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
@@ -25,6 +26,12 @@ const (
userTypeUser = 0x1
findBatch = 32
// Upper bounds on sizes a module reports, so a buggy module cannot make the daemon
// allocate without limit. Certificates and keys are far smaller; an RSA-16384
// signature is 2 KiB.
maxAttributeLen = 1 << 20
maxSignatureLen = 1 << 16
)
type version struct {
@@ -115,11 +122,25 @@ type module struct {
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
}
func load(path string) (driver, error) {
// Supported reports whether this build can load PKCS#11 modules.
func Supported() bool {
return true
}
func load(path string) (_ driver, err error) {
lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
if err != nil {
return nil, fmt.Errorf("open PKCS#11 module %s: %w", path, err)
}
// A module that loads but cannot be used is not cached, so each later attempt opens
// it again; close it here or every attempt keeps another reference to the library.
defer func() {
if err != nil {
if closeErr := purego.Dlclose(lib); closeErr != nil {
log.Debugf("failed closing PKCS#11 module %s: %v", path, closeErr)
}
}
}()
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
if err != nil {
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
@@ -211,7 +232,7 @@ func (m *module) login(session uint, pin []byte) error {
}
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
runtime.KeepAlive(pin)
if rv != rvOK && rv != rvUserAlreadyLoggedIn {
if rv != rvOK {
return Error{Op: "C_Login", Code: uint(rv)}
}
return nil
@@ -222,9 +243,10 @@ func (m *module) logout(session uint) {
}
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
attrs := toAttributes(template)
var pinner runtime.Pinner
attrs := toAttributes(template, &pinner)
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
runtime.KeepAlive(template)
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
}
@@ -237,6 +259,9 @@ func (m *module) findObjects(session uint, template []Attribute) ([]Object, erro
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
}
if count > findBatch {
return nil, fmt.Errorf("C_FindObjects reported %d handles for a batch of %d", count, findBatch)
}
for _, handle := range batch[:count] {
objects = append(objects, Object(handle))
}
@@ -257,13 +282,21 @@ func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
if attr.len == 0 {
return nil, nil
}
if attr.len > maxAttributeLen {
return nil, fmt.Errorf("attribute 0x%x reports %d bytes, over the %d byte limit", typ, attr.len, maxAttributeLen)
}
value := make([]byte, attr.len)
var pinner runtime.Pinner
pinner.Pin(&value[0])
attr.value = unsafe.Pointer(&value[0])
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
runtime.KeepAlive(value)
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len > ulong(len(value)) {
return nil, fmt.Errorf("attribute 0x%x grew from %d to %d bytes between calls", typ, len(value), attr.len)
}
return value[:attr.len], nil
}
@@ -272,14 +305,15 @@ func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]
return nil, errors.New("nothing to sign")
}
native := mechanism{typ: ulong(mech.Type)}
var params *pssParams
var pinner runtime.Pinner
if mech.PSS != nil {
params = &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
params := &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
pinner.Pin(params)
native.parameter = unsafe.Pointer(params)
native.len = ulong(unsafe.Sizeof(*params))
}
rv := m.cSignInit(ulong(session), &native, ulong(key))
runtime.KeepAlive(params)
pinner.Unpin()
if rv != rvOK {
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
}
@@ -288,31 +322,41 @@ func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
if size == 0 || size > maxSignatureLen {
return nil, fmt.Errorf("C_Sign reports a %d byte signature", size)
}
signature := make([]byte, size)
rv = m.cSign(ulong(session), &data[0], ulong(len(data)), &signature[0], &size)
runtime.KeepAlive(data)
if rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
if size > ulong(len(signature)) {
return nil, fmt.Errorf("C_Sign wrote %d bytes into a %d byte buffer", size, len(signature))
}
return signature[:size], nil
}
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
attrs := toAttributes(template)
var pinner runtime.Pinner
attrs := toAttributes(template, &pinner)
var object ulong
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
runtime.KeepAlive(template)
pinner.Unpin()
if rv != rvOK {
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
}
return Object(object), nil
}
func toAttributes(template []Attribute) []attribute {
// toAttributes builds the C template for template. The values stay Go memory referenced
// from Go memory passed to C, so each is pinned; the caller unpins after the call.
func toAttributes(template []Attribute, pinner *runtime.Pinner) []attribute {
attrs := make([]attribute, len(template))
for i, a := range template {
attrs[i].typ = ulong(a.Type)
if len(a.Value) > 0 {
pinner.Pin(&a.Value[0])
attrs[i].value = unsafe.Pointer(&a.Value[0])
attrs[i].len = ulong(len(a.Value))
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build pkcs11 && linux && (amd64 || arm64)
//go:build pkcs11 && linux && !android && (amd64 || arm64)
package pkcs11
+6 -1
View File
@@ -1,7 +1,12 @@
//go:build !(pkcs11 && linux && (amd64 || arm64))
//go:build !(pkcs11 && linux && !android && (amd64 || arm64))
package pkcs11
// Supported reports whether this build can load PKCS#11 modules.
func Supported() bool {
return false
}
func load(string) (driver, error) {
return nil, ErrUnsupported
}
@@ -0,0 +1,19 @@
package pkcs11
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPINRejected(t *testing.T) {
for _, code := range []uint{rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked} {
err := fmt.Errorf("open session: %w", Error{Op: "C_Login", Code: code})
assert.True(t, PINRejected(err), "CKR 0x%x refuses the PIN, also when wrapped", code)
}
assert.False(t, PINRejected(Error{Op: "C_Login", Code: 0x30}), "a device error says nothing about the PIN")
assert.False(t, PINRejected(errors.New("CKR_PIN_INCORRECT")), "only a PKCS#11 return value counts")
assert.False(t, PINRejected(nil))
}
+77 -8
View File
@@ -44,6 +44,11 @@ const (
MGF1SHA384 = 0x3
rvOK = 0x0
rvPINIncorrect = 0xa0
rvPINInvalid = 0xa1
rvPINLenRange = 0xa2
rvPINExpired = 0xa3
rvPINLocked = 0xa4
rvUserAlreadyLoggedIn = 0x100
rvAlreadyInitialized = 0x191
)
@@ -63,6 +68,25 @@ func (e Error) Error() string {
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
}
// PINRejected reports whether err is the token refusing the user PIN. Retrying the same
// PIN cannot succeed, and each attempt counts towards the token's lockout.
func PINRejected(err error) bool {
var e Error
if !errors.As(err, &e) {
return false
}
switch e.Code {
case rvPINIncorrect, rvPINInvalid, rvPINLenRange, rvPINExpired, rvPINLocked:
return true
}
return false
}
func isCode(err error, code uint) bool {
var e Error
return errors.As(err, &e) && e.Code == code
}
var returnValueNames = map[uint]string{
0x2: "CKR_HOST_MEMORY",
0x3: "CKR_SLOT_ID_INVALID",
@@ -77,6 +101,9 @@ var returnValueNames = map[uint]string{
0x71: "CKR_MECHANISM_PARAM_INVALID",
0x82: "CKR_OBJECT_HANDLE_INVALID",
0xa0: "CKR_PIN_INCORRECT",
0xa1: "CKR_PIN_INVALID",
0xa2: "CKR_PIN_LEN_RANGE",
0xa3: "CKR_PIN_EXPIRED",
0xa4: "CKR_PIN_LOCKED",
0xb3: "CKR_SESSION_HANDLE_INVALID",
0xd0: "CKR_TEMPLATE_INCOMPLETE",
@@ -120,6 +147,11 @@ type Token struct {
// process exit releases everything anyway.
type Module struct {
d driver
// loginMu guards logins, the number of open sessions relying on the user login of
// each slot, and serializes logging in so a PIN is never sent twice at once.
loginMu sync.Mutex
logins map[uint]int
}
var (
@@ -173,14 +205,48 @@ func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session
if pin == nil {
return s, nil
}
if err := m.d.login(handle, pin); err != nil {
if err := m.acquireLogin(token.Slot, handle, pin); err != nil {
s.Close()
return nil, err
}
s.loggedIn = true
s.module, s.slot = m, token.Slot
return s, nil
}
// acquireLogin makes sure the user is logged in to the token in slot and counts the
// session as one relying on it. PKCS#11 login state belongs to the application, not to
// a session: every session with the token shares it, and logging out from any of them
// ends it for all. So the PIN is sent only when no session holds the login yet, and the
// last session to close logs out.
func (m *Module) acquireLogin(slot, handle uint, pin []byte) error {
m.loginMu.Lock()
defer m.loginMu.Unlock()
if m.logins[slot] > 0 {
m.logins[slot]++
return nil
}
if err := m.d.login(handle, pin); err != nil && !isCode(err, rvUserAlreadyLoggedIn) {
return err
}
if m.logins == nil {
m.logins = make(map[uint]int)
}
m.logins[slot] = 1
return nil
}
// releaseLogin ends the session's share of the login, logging out when it is the last.
func (m *Module) releaseLogin(slot, handle uint) {
m.loginMu.Lock()
defer m.loginMu.Unlock()
m.logins[slot]--
if m.logins[slot] > 0 {
return
}
delete(m.logins, slot)
m.d.logout(handle)
}
func (m *Module) token(label string) (Token, error) {
tokens, err := m.Tokens()
if err != nil {
@@ -197,16 +263,19 @@ func (m *Module) token(label string) (Token, error) {
return Token{}, fmt.Errorf("no token labelled %q among %d tokens", label, len(tokens))
}
// Session is an open session with one token. Close logs out again if the session logged in.
// Session is an open session with one token. Close releases the session's share of the
// login, logging out when no other session relies on it.
type Session struct {
d driver
handle uint
loggedIn bool
d driver
handle uint
// module is set when the session relies on the token's login.
module *Module
slot uint
}
func (s *Session) Close() {
if s.loggedIn {
s.d.logout(s.handle)
if s.module != nil {
s.module.releaseLogin(s.slot, s.handle)
}
s.d.closeSession(s.handle)
}
+5 -5
View File
@@ -87,9 +87,9 @@ func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
if len(raw) != 1+2*size || raw[0] != 4 {
return nil, errors.New("CKA_EC_POINT is not an uncompressed point")
}
return &ecdsa.PublicKey{
Curve: curve,
X: new(big.Int).SetBytes(raw[1 : 1+size]),
Y: new(big.Int).SetBytes(raw[1+size:]),
}, nil
key, err := ecdsa.ParseUncompressedPublicKey(curve, raw)
if err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
return key, nil
}
+72
View File
@@ -0,0 +1,72 @@
package pkcs11
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeDriver stands in for a loaded module and records the calls a session makes.
type fakeDriver struct {
loginErr error
logins int
logouts int
closes int
}
func (f *fakeDriver) tokens() ([]Token, error) { return []Token{{Slot: 1, Label: "netbird"}}, nil }
func (f *fakeDriver) openSession(uint, bool) (uint, error) { return 7, nil }
func (f *fakeDriver) closeSession(uint) { f.closes++ }
func (f *fakeDriver) login(uint, []byte) error { f.logins++; return f.loginErr }
func (f *fakeDriver) logout(uint) { f.logouts++ }
func (f *fakeDriver) findObjects(uint, []Attribute) ([]Object, error) {
return nil, nil
}
func (f *fakeDriver) attribute(uint, Object, uint) ([]byte, error) { return nil, nil }
func (f *fakeDriver) sign(uint, Mechanism, Object, []byte) ([]byte, error) {
return nil, nil
}
func (f *fakeDriver) createObject(uint, []Attribute) (Object, error) { return 0, nil }
func TestOpenSession_LogsOutOnlyALoginItOwns(t *testing.T) {
t.Run("own login is logged out on close", func(t *testing.T) {
d := &fakeDriver{}
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
s.Close()
assert.Equal(t, 1, d.logouts, "the session that logged in logs out again")
})
t.Run("a login shared by sessions ends with the last one", func(t *testing.T) {
d := &fakeDriver{}
m := &Module{d: d}
first, err := m.OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
second, err := m.OpenSession("netbird", []byte("1234"))
require.NoError(t, err)
assert.Equal(t, 1, d.logins, "the second session reuses the login instead of sending the PIN again")
first.Close()
assert.Zero(t, d.logouts, "logging out now would end the login the second session still uses")
second.Close()
assert.Equal(t, 1, d.logouts, "the last session to close logs out")
assert.Equal(t, 2, d.closes, "both sessions are closed")
})
t.Run("an existing login is taken over", func(t *testing.T) {
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvUserAlreadyLoggedIn}}
s, err := (&Module{d: d}).OpenSession("netbird", []byte("1234"))
require.NoError(t, err, "an existing login is good enough to use the token")
s.Close()
assert.Equal(t, 1, d.logouts, "no session of this client holds the login any more, so it ends")
})
t.Run("rejected pin closes the session", func(t *testing.T) {
d := &fakeDriver{loginErr: Error{Op: "C_Login", Code: rvPINIncorrect}}
_, err := (&Module{d: d}).OpenSession("netbird", []byte("0000"))
assert.True(t, PINRejected(err), "the PIN error reaches the caller")
assert.Zero(t, d.logouts, "nothing to log out after a failed login")
assert.Equal(t, 1, d.closes, "the session opened for the login is closed")
})
}
+68 -31
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
@@ -13,7 +14,7 @@ import (
const DefaultModule = "p11-kit-proxy.so"
// URI is the subset of an RFC 7512 PKCS#11 URI this client understands: the token label,
// the module to load and where the user PIN comes from. Unknown attributes are ignored.
// the module to load and where the user PIN comes from.
type URI struct {
Token string
ModulePath string
@@ -21,53 +22,57 @@ type URI struct {
pinSource string
}
// ParseURI parses raw following RFC 7512 section 2.3. A path attribute other than token
// is refused rather than ignored: the path narrows which token is used, and ignoring a
// constraint such as serial would widen the match to whichever token is listed first,
// where the RFC calls for no match at all. Duplicate attributes are refused, a
// module-path must be absolute and a module-name must be a bare name. Unknown query
// attributes are ignored, as the RFC asks.
func ParseURI(raw string) (*URI, error) {
rest, ok := strings.CutPrefix(raw, "pkcs11:")
if !ok {
return nil, errors.New("PKCS#11 URI must start with pkcs11:")
return nil, errors.New("PKCS#11 URI does not start with the pkcs11 scheme")
}
path, query, _ := strings.Cut(rest, "?")
u := &URI{}
if err := eachAttribute(path, ";", func(name, value string) {
if name == "token" {
u.Token = value
}
}); err != nil {
if err := eachAttribute(path, ";", u.setPathAttribute); err != nil {
return nil, err
}
err := eachAttribute(query, "&", func(name, value string) {
switch name {
case "module-path":
u.ModulePath = value
case "module-name":
u.ModulePath = "lib" + value + ".so"
case "pin-value":
u.pinValue = &value
case "pin-source":
u.pinSource = value
}
})
if err != nil {
if err := eachAttribute(query, "&", u.setQueryAttribute); err != nil {
return nil, err
}
// RFC 7512 section 2.4: a URI with both pin-value and pin-source should be refused.
if u.pinValue != nil && u.pinSource != "" {
return nil, errors.New("PKCS#11 URI carries both pin-value and pin-source")
}
return u, nil
}
func eachAttribute(list, sep string, fn func(name, value string)) error {
if list == "" {
return nil
func (u *URI) setPathAttribute(name, value string) error {
if name != "token" {
return fmt.Errorf("PKCS#11 URI path attribute %q is not supported, only token selects a token", name)
}
for _, pair := range strings.Split(list, sep) {
name, value, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
u.Token = value
return nil
}
func (u *URI) setQueryAttribute(name, value string) error {
switch name {
case "module-path":
if !filepath.IsAbs(value) {
return fmt.Errorf("PKCS#11 URI module-path %q must be absolute", value)
}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
u.ModulePath = value
case "module-name":
if value == "" || strings.ContainsAny(value, `/\`) || strings.Contains(value, "..") {
return fmt.Errorf("PKCS#11 URI module-name %q must be a module name, not a path", value)
}
fn(name, value)
u.ModulePath = "lib" + value + ".so"
case "pin-value":
u.pinValue = &value
case "pin-source":
u.pinSource = value
}
return nil
}
@@ -80,6 +85,11 @@ func (u *URI) Module() string {
return u.ModulePath
}
// HasPIN reports whether the URI carries a PIN, inline or as a pin-source.
func (u *URI) HasPIN() bool {
return u.pinValue != nil || u.pinSource != ""
}
// PIN returns the user PIN, or nil when the URI carries none and no login should happen.
// A pin-source names a file whose single line is the PIN.
func (u *URI) PIN() ([]byte, error) {
@@ -96,3 +106,30 @@ func (u *URI) PIN() ([]byte, error) {
}
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
}
// eachAttribute splits list on sep and calls fn for every name=value pair, refusing a
// name that appears twice.
func eachAttribute(list, sep string, fn func(name, value string) error) error {
if list == "" {
return nil
}
seen := make(map[string]struct{})
for _, pair := range strings.Split(list, sep) {
name, value, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
}
if _, dup := seen[name]; dup {
return fmt.Errorf("PKCS#11 URI attribute %s appears more than once", name)
}
seen[name] = struct{}{}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
}
if err := fn(name, value); err != nil {
return err
}
}
return nil
}
+38 -6
View File
@@ -3,6 +3,7 @@ package pkcs11
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -19,9 +20,9 @@ func TestParseURI(t *testing.T) {
}{
{
name: "token with module path and pin value",
raw: "pkcs11:token=netbird?module-path=/usr/lib/libtpm2_pkcs11.so&pin-value=1234",
raw: "pkcs11:token=netbird?module-path=" + absModule("libtpm2_pkcs11.so") + "&pin-value=1234",
wantToken: "netbird",
wantModule: "/usr/lib/libtpm2_pkcs11.so",
wantModule: absModule("libtpm2_pkcs11.so"),
wantPIN: []byte("1234"),
},
{
@@ -31,8 +32,8 @@ func TestParseURI(t *testing.T) {
wantModule: "libtpm2_pkcs11.so",
},
{
name: "percent encoding and unknown attributes",
raw: "pkcs11:model=SoftHSM%20v2;token=my%20token;serial=1?max-sessions=1",
name: "percent encoding, unknown query attributes are ignored",
raw: "pkcs11:token=my%20token?max-sessions=1&vendor-flag=on",
wantToken: "my token",
wantModule: DefaultModule,
},
@@ -62,9 +63,31 @@ func TestParseURI(t *testing.T) {
}
func TestParseURI_Rejections(t *testing.T) {
for _, raw := range []string{"pkcs11", "https://example.com", "pkcs11:token", "pkcs11:token=%zz"} {
tests := map[string]string{
"no scheme": "pkcs11",
"other scheme": "https://example.com",
"attribute no value": "pkcs11:token",
"bad percent encoding": "pkcs11:token=%zz",
// RFC 7512 2.3: an unrecognized path attribute matches nothing. Ignoring it
// instead would let the token listed first answer for the one serial names.
"unsupported serial": "pkcs11:token=netbird;serial=1234",
"unsupported model": "pkcs11:model=SoftHSM%20v2;token=netbird",
"object selector": "pkcs11:token=netbird;object=device",
"vendor path attribute": "pkcs11:token=netbird;vendor-slot=2",
"duplicate token": "pkcs11:token=a;token=b",
"duplicate module-path": "pkcs11:token=a?module-path=" + absModule("a.so") + "&module-path=" + absModule("b.so"),
"duplicate pin-value": "pkcs11:token=a?pin-value=1&pin-value=2",
"relative module-path": "pkcs11:token=a?module-path=lib/x.so",
"bare module-path": "pkcs11:token=a?module-path=libtpm2_pkcs11.so",
"module-name with path": "pkcs11:token=a?module-name=../../tmp/evil",
"module-name with slash": "pkcs11:token=a?module-name=tmp/evil",
"module-name with dotdot": "pkcs11:token=a?module-name=..",
"empty module-name": "pkcs11:token=a?module-name=",
"pin-value and pin-source": "pkcs11:token=a?pin-value=1&pin-source=file:/etc/pin",
}
for name, raw := range tests {
_, err := ParseURI(raw)
assert.Error(t, err, raw)
assert.Error(t, err, "%s: %s must be refused", name, raw)
}
}
@@ -85,3 +108,12 @@ func TestURI_PINFromFile(t *testing.T) {
_, err = uri.PIN()
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
}
// absModule is an absolute module path on the platform the test runs on, as module-path
// must be absolute.
func absModule(name string) string {
if runtime.GOOS == "windows" {
return `C:\lib\` + name
}
return "/lib/" + name
}
+8 -12
View File
@@ -185,19 +185,15 @@ type Config struct {
ClientCertKeyPair *tls.Certificate `json:"-"`
// CertStoreDir is the directory of PEM certificates, with their keys or with keys a
// PKCS#11 token holds, that answer certificate posture checks on Linux. Empty means
// NB_CERT_STORE_DIR or /etc/netbird/certs; see client/internal/certproof/README.md.
CertStoreDir string
// CertStoreDir is no longer read: certificate posture takes the directory from
// NB_CERT_STORE_DIR in the daemon's environment. The field is kept only to report a
// value left from an earlier version.
CertStoreDir string `json:",omitempty"`
// CertPKCS11PIN is the user PIN of the PKCS#11 token, tpm2-pkcs11 for one, whose
// certificates answer certificate posture checks on Linux. Setting it enables the
// token store; see client/internal/certproof/README.md.
CertPKCS11PIN string
// CertPKCS11URI is the RFC 7512 URI selecting that token and its module. Empty means
// the first token the p11-kit proxy exposes.
CertPKCS11URI string
// CertPKCS11URI is no longer read, as the URI may carry the token PIN: certificate
// posture takes it from NB_CERT_PKCS11_URI in the daemon's environment. The field is
// kept only to report a value left from an earlier version.
CertPKCS11URI string `json:",omitempty"`
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
// Runtime-only: re-derived from MDM policy on each load, never persisted.
+118 -16
View File
@@ -2,11 +2,17 @@ package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"io"
"math/big"
"go.step.sm/crypto/tpm/tss2"
legacy "github.com/google/go-tpm/legacy/tpm2"
"github.com/google/go-tpm/tpmutil"
)
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
@@ -15,29 +21,46 @@ const KeyPEMType = "TSS2 PRIVATE KEY"
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
// eccSRKTemplate is the TCG reference ECC-P256 storage root key. A key whose parent is
// a hierarchy rather than a persistent handle was wrapped by the primary this template
// derives, and tpm2-openssl and tpm2-tss-engine derive the same one, so the TPM
// reproduces the identical parent from the hierarchy seed without anything being stored.
var eccSRKTemplate = legacy.Public{
Type: legacy.AlgECC,
NameAlg: legacy.AlgSHA256,
Attributes: legacy.FlagStorageDefault | legacy.FlagNoDA,
ECCParameters: &legacy.ECCParams{
Symmetric: &legacy.SymScheme{Alg: legacy.AlgAES, KeyBits: 128, Mode: legacy.AlgCFB},
Sign: &legacy.SigScheme{Alg: legacy.AlgNull},
CurveID: legacy.CurveNISTP256,
},
}
// ParseKey reads a TSS2 key file and returns a signer that produces every signature
// inside the TPM; only the digest goes in and only the signature comes out. A key with
// a persistent parent is loaded under it, a key whose parent is a hierarchy under the
// TCG default ECC primary that tpm2-openssl and tpm2-tss-engine derive as well. Keys
// guarded by an authorization value are rejected, since nothing can supply it without
// prompting.
// inside the TPM; only the digest goes in and only the signature comes out. Keys guarded
// by an authorization value are rejected, since nothing can supply it without prompting.
func ParseKey(der []byte) (crypto.Signer, error) {
key, err := tss2.ParsePrivateKey(der)
key, err := parseTSS2(der)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
return nil, err
}
if !key.EmptyAuth {
return nil, ErrKeyNeedsAuth
}
public, err := key.Public()
public, err := legacy.DecodePublic(key.PublicKey[2:])
if err != nil {
return nil, fmt.Errorf("decode TSS2 public area: %w", err)
}
pub, err := public.Key()
if err != nil {
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
}
return &keySigner{key: key, public: public}, nil
return &keySigner{key: key, public: pub}, nil
}
type keySigner struct {
key *tss2.TPMKey
key *tss2Key
public crypto.PublicKey
}
@@ -45,17 +68,96 @@ func (s *keySigner) Public() crypto.PublicKey {
return s.public
}
func (s *keySigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
// Sign loads the key under its parent, signs, and releases both handles. The TPM is
// opened per signature so no handle outlives the call, which matters on a device whose
// transient object slots are few and shared with everything else on the host.
func (s *keySigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
rwc, err := Open()
if err != nil {
return nil, err
}
defer func() { _ = rwc.Close() }()
signer, err := tss2.CreateSigner(rwc, s.key)
parent := tpmutil.Handle(s.key.Parent)
if !persistentHandle(s.key.Parent) {
parent, _, err = legacy.CreatePrimary(rwc, parent, legacy.PCRSelection{}, "", "", eccSRKTemplate)
if err != nil {
return nil, fmt.Errorf("create TPM primary: %w", err)
}
defer func() { _ = legacy.FlushContext(rwc, parent) }()
}
public, private := s.key.blobs()
handle, _, err := legacy.Load(rwc, parent, "", public, private)
if err != nil {
return nil, fmt.Errorf("load TSS2 key: %w", err)
return nil, fmt.Errorf("load TPM key: %w", err)
}
defer func() { _ = legacy.FlushContext(rwc, handle) }()
switch pub := s.public.(type) {
case *ecdsa.PublicKey:
return signECDSA(rwc, handle, digest, pub.Curve)
case *rsa.PublicKey:
return signRSA(rwc, handle, digest, opts)
default:
return nil, fmt.Errorf("unsupported TPM key type %T", s.public)
}
}
// signECDSA returns the signature as the ASN.1 sequence crypto.Signer is defined to
// return; the TPM hands back the two integers on their own.
func signECDSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, curve elliptic.Curve) ([]byte, error) {
hash, err := eccHash(curve)
if err != nil {
return nil, err
}
sig, err := legacy.Sign(rw, handle, "", digest, nil, &legacy.SigScheme{Alg: legacy.AlgECDSA, Hash: hash})
if err != nil {
return nil, fmt.Errorf("TPM ECDSA signature: %w", err)
}
if sig.ECC == nil {
return nil, fmt.Errorf("TPM returned a %v signature for an ECDSA key", sig.Alg)
}
return asn1.Marshal(struct{ R, S *big.Int }{sig.ECC.R, sig.ECC.S})
}
func signRSA(rw io.ReadWriter, handle tpmutil.Handle, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
hash, err := legacy.HashToAlgorithm(opts.HashFunc())
if err != nil {
return nil, fmt.Errorf("TPM hash algorithm: %w", err)
}
scheme := &legacy.SigScheme{Alg: legacy.AlgRSASSA, Hash: hash}
if pss, ok := opts.(*rsa.PSSOptions); ok {
// The TPM chooses the salt length itself, the digest length on most chips, so
// only a request for that length is taken. PSSSaltLengthAuto asks for the
// largest salt the key allows and is refused. Verify with PSSSaltLengthAuto.
if pss.SaltLength != rsa.PSSSaltLengthEqualsHash &&
pss.SaltLength != len(digest) {
return nil, fmt.Errorf("TPM cannot produce a PSS signature with salt length %d", pss.SaltLength)
}
scheme.Alg = legacy.AlgRSAPSS
}
sig, err := legacy.Sign(rw, handle, "", digest, nil, scheme)
if err != nil {
return nil, fmt.Errorf("TPM RSA signature: %w", err)
}
if sig.RSA == nil {
return nil, fmt.Errorf("TPM returned a %v signature for an RSA key", sig.Alg)
}
return sig.RSA.Signature, nil
}
func eccHash(curve elliptic.Curve) (legacy.Algorithm, error) {
switch curve {
case elliptic.P256():
return legacy.AlgSHA256, nil
case elliptic.P384():
return legacy.AlgSHA384, nil
case elliptic.P521():
return legacy.AlgSHA512, nil
default:
return 0, fmt.Errorf("unsupported curve %s", curve.Params().Name)
}
signer.SetSRKTemplate(tss2.ECCSRKTemplate)
return signer.Sign(rand, digest, opts)
}
+13 -4
View File
@@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/pem"
"path/filepath"
@@ -12,7 +13,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
@@ -27,9 +27,7 @@ func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) {
key := newP256Key(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, withAuth)))
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithAuth())))
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
}
@@ -48,6 +46,17 @@ func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
}
// TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose checks the salt-length gate, which
// runs before the TPM is touched: the TPM picks the salt itself, so a request for the
// maximum salt (PSSSaltLengthAuto) or any other explicit length is refused.
func TestSignRSA_RefusesSaltLengthsTheTPMDoesNotChoose(t *testing.T) {
digest := sha256.Sum256([]byte("challenge"))
for _, salt := range []int{rsa.PSSSaltLengthAuto, 20, 222} {
_, err := signRSA(nil, 0, digest[:], &rsa.PSSOptions{SaltLength: salt, Hash: crypto.SHA256})
assert.ErrorContains(t, err, "salt length", "salt length %d must be refused", salt)
}
}
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+85 -12
View File
@@ -4,14 +4,49 @@ package tpmtest
import (
"crypto/ecdsa"
"crypto/elliptic"
"encoding/asn1"
"encoding/pem"
"testing"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
)
const p256Bytes = 32
const (
p256Bytes = 32
// KeyPEMType is the PEM block type of a TPM 2.0 key file.
KeyPEMType = "TSS2 PRIVATE KEY"
)
// oidLoadableKey is the key type of draft-bottomley-tpm2-keys that a parent wraps.
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
// tss2KeyDER is the ASN.1 container, written out independently of the parser under test
// so that an encoder bug and a decoder bug cannot cancel out. Policy, secret and auth
// policy are left out entirely: they are optional, and a key that carries them is
// refused anyway.
type tss2KeyDER struct {
Type asn1.ObjectIdentifier
EmptyAuth bool `asn1:"optional,explicit,tag:0"`
Parent int64
PublicKey []byte
PrivateKey []byte
}
// Option adjusts a key before it is encoded.
type Option func(*tss2KeyDER)
// WithParent names the handle the key is wrapped by, instead of the owner hierarchy.
func WithParent(handle int64) Option {
return func(k *tss2KeyDER) { k.Parent = handle }
}
// WithAuth marks the key as guarded by an authorization value, which this client refuses
// because nothing can supply one without prompting.
func WithAuth() Option {
return func(k *tss2KeyDER) { k.EmptyAuth = false }
}
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
// scheme, the shape tpm2-openssl creates certificate keys in.
@@ -26,23 +61,61 @@ func SigningTemplate() tpm2.Public {
// KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses
// and reports pub, but no TPM can load it.
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...tss2.TPMOption) string {
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...Option) string {
t.Helper()
require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256")
area := SigningTemplate()
area.ECCParameters.Point = tpm2.ECPoint{
XRaw: pub.X.FillBytes(make([]byte, p256Bytes)),
YRaw: pub.Y.FillBytes(make([]byte, p256Bytes)),
}
encoded, err := area.Encode()
// The uncompressed encoding is 0x04 followed by the two coordinates, which is
// what the TPM wants them as.
point, err := pub.Bytes()
require.NoError(t, err)
require.Len(t, point, 1+2*p256Bytes)
area.ECCParameters.Point = tpm2.ECPoint{
XRaw: point[1 : 1+p256Bytes],
YRaw: point[1+p256Bytes:],
}
encoded, encErr := area.Encode()
require.NoError(t, encErr)
return EncodePEM(t, encoded, []byte("placeholder"), opts...)
}
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE KEY.
func EncodePEM(t *testing.T, public, private []byte, opts ...tss2.TPMOption) string {
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE
// KEY, giving each the TPM2B length prefix the format carries them with.
func EncodePEM(t *testing.T, public, private []byte, opts ...Option) string {
t.Helper()
pemBytes, err := tss2.New(public, private, opts...).EncodeToMemory()
key := tss2KeyDER{
Type: oidLoadableKey,
EmptyAuth: true,
Parent: int64(tpm2.HandleOwner),
PublicKey: prefixTPM2B(public),
PrivateKey: prefixTPM2B(private),
}
for _, opt := range opts {
opt(&key)
}
der, err := asn1.Marshal(key)
require.NoError(t, err)
return string(pemBytes)
return string(pem.EncodeToMemory(&pem.Block{Type: KeyPEMType, Bytes: der}))
}
func prefixTPM2B(b []byte) []byte {
out := make([]byte, 0, len(b)+2)
out = append(out, byte(len(b)>>8), byte(len(b)))
return append(out, b...)
}
// ECCSRKTemplate is the TCG reference ECC-P256 storage root key, the parent a key under
// a hierarchy is wrapped by. Tests that create a key in a real TPM have to use the same
// template the signer re-derives it with.
var ECCSRKTemplate = tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagStorageDefault | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{
Symmetric: &tpm2.SymScheme{Alg: tpm2.AlgAES, KeyBits: 128, Mode: tpm2.AlgCFB},
Sign: &tpm2.SigScheme{Alg: tpm2.AlgNull},
CurveID: tpm2.CurveNISTP256,
},
}
+133
View File
@@ -0,0 +1,133 @@
package tpm
import (
"encoding/asn1"
"errors"
"fmt"
"math"
legacy "github.com/google/go-tpm/legacy/tpm2"
)
// oidLoadableKey marks a key wrapped by a parent inside the TPM, which is the only kind
// enrollment tooling writes for a signing key and the only kind that can be loaded under
// an SRK. The sealed and importable variants carry different material and are refused.
var oidLoadableKey = asn1.ObjectIdentifier{2, 23, 133, 10, 1, 3}
var (
errNotLoadable = errors.New("TSS2 key is not a loadable key")
errKeyHasPolicy = errors.New("TSS2 key carries a policy, which is not supported")
errKeyHasSecret = errors.New("TSS2 key carries a secret, which is not supported")
errBadParent = errors.New("TSS2 key names a parent that is neither persistent nor a hierarchy")
errBadBlob = errors.New("TSS2 key blob is malformed")
)
// tss2KeyASN1 is the ASN.1 container of draft-bottomley-tpm2-keys, the format
// tpm2-openssl, tpm2-tss-engine and tpm2_encodeobject write. PublicKey and PrivateKey
// hold TPM2B structures, so each is its own two-byte length followed by that many bytes.
//
// EmptyAuth is decoded raw: OpenSSL-based tools write BOOLEAN TRUE as 0x01, which BER
// allows but encoding/asn1 rejects, as it accepts only the DER form 0xff. Parent is an
// int64 so a persistent handle such as 0x81000001 still fits on 32-bit platforms.
type tss2KeyASN1 struct {
Type asn1.ObjectIdentifier
EmptyAuth asn1.RawValue `asn1:"optional,explicit,tag:0"`
Policy []asn1.RawValue `asn1:"optional,explicit,tag:1"`
Secret []byte `asn1:"optional,explicit,tag:2"`
AuthPolicy []asn1.RawValue `asn1:"optional,explicit,tag:3"`
Parent int64
PublicKey []byte
PrivateKey []byte
}
// tss2Key is a decoded key file the client can ask the TPM to load.
type tss2Key struct {
EmptyAuth bool
Parent uint32
PublicKey []byte
PrivateKey []byte
}
// parseTSS2 decodes a TSS2 key file and rejects everything this client cannot honour,
// so a key that parses here is one the TPM can be asked to load.
func parseTSS2(der []byte) (*tss2Key, error) {
raw := new(tss2KeyASN1)
rest, err := asn1.Unmarshal(der, raw)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
}
if len(rest) > 0 {
return nil, fmt.Errorf("parse TSS2 key: %d trailing bytes", len(rest))
}
emptyAuth, err := berBoolean(raw.EmptyAuth)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key emptyAuth: %w", err)
}
switch {
case !raw.Type.Equal(oidLoadableKey):
return nil, fmt.Errorf("%w: %s", errNotLoadable, raw.Type)
case len(raw.Policy) > 0 || len(raw.AuthPolicy) > 0:
return nil, errKeyHasPolicy
case len(raw.Secret) > 0:
return nil, errKeyHasSecret
case raw.Parent < 0 || raw.Parent > math.MaxUint32 || !validParent(uint32(raw.Parent)):
return nil, fmt.Errorf("%w: %d", errBadParent, raw.Parent)
case !validTPM2B(raw.PublicKey) || !validTPM2B(raw.PrivateKey):
return nil, errBadBlob
}
return &tss2Key{
EmptyAuth: emptyAuth,
Parent: uint32(raw.Parent),
PublicKey: raw.PublicKey,
PrivateKey: raw.PrivateKey,
}, nil
}
// berBoolean decodes an optional BOOLEAN, absent meaning false. Any non-zero content
// byte is true, as BER (X.690 section 8.2.2) allows and OpenSSL writes.
func berBoolean(v asn1.RawValue) (bool, error) {
if len(v.FullBytes) == 0 {
return false, nil
}
// The field is explicitly tagged, so v is the [0] wrapper and the BOOLEAN is inside it.
if v.Class == asn1.ClassContextSpecific && v.IsCompound {
var inner asn1.RawValue
rest, err := asn1.Unmarshal(v.Bytes, &inner)
if err != nil || len(rest) > 0 {
return false, errors.New("malformed explicit tag")
}
v = inner
}
if v.Class != asn1.ClassUniversal || v.Tag != asn1.TagBoolean || v.IsCompound || len(v.Bytes) != 1 {
return false, errors.New("not a BOOLEAN")
}
return v.Bytes[0] != 0, nil
}
// blobs returns the public and private areas with their TPM2B length prefix removed,
// which is the form the load command takes them in.
func (k *tss2Key) blobs() (public, private []byte) {
return k.PublicKey[2:], k.PrivateKey[2:]
}
// validParent accepts a persistent handle, under which the key was wrapped directly, or
// one of the four hierarchies, under which the key is wrapped by a primary the TPM
// re-derives from the hierarchy seed.
func validParent(parent uint32) bool {
return persistentHandle(parent) ||
parent == uint32(legacy.HandleOwner) ||
parent == uint32(legacy.HandleNull) ||
parent == uint32(legacy.HandleEndorsement) ||
parent == uint32(legacy.HandlePlatform)
}
func persistentHandle(h uint32) bool {
return h>>24 == uint32(legacy.HandleTypePersistent)
}
// validTPM2B reports whether b is a TPM2B structure: a two-byte big-endian length
// followed by exactly that many bytes.
func validTPM2B(b []byte) bool {
return len(b) >= 2 && len(b)-2 == int(b[0])<<8+int(b[1])
}
+78
View File
@@ -0,0 +1,78 @@
package tpm
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/asn1"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
// derEmptyAuthTrue is the explicit [0] tag around a DER BOOLEAN TRUE, as encoding/asn1
// writes emptyAuth; OpenSSL writes the same element with 0x01 as the content byte.
var (
derEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0xff}
opensslEmptyAuthTrue = []byte{0xa0, 0x03, 0x01, 0x01, 0x01}
)
func p256(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func TestParseKey_AcceptsOpenSSLBoolean(t *testing.T) {
key := p256(t)
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey))
require.Equal(t, 1, bytes.Count(der, derEmptyAuthTrue), "fixture carries one DER emptyAuth TRUE")
// The same key as tpm2-openssl and tpm2-tss-engine write it: identical apart from
// the BOOLEAN content byte.
openssl := bytes.Replace(der, derEmptyAuthTrue, opensslEmptyAuthTrue, 1)
signer, err := ParseKey(openssl)
require.NoError(t, err, "BER allows any non-zero byte for TRUE, and OpenSSL writes 0x01")
assert.True(t, key.PublicKey.Equal(signer.Public()), "the key is the same either way")
}
func TestParseKey_AcceptsPersistentParent(t *testing.T) {
key := p256(t)
// 0x81000001 overflows a 32-bit int, the width the parent used to be decoded into.
der := decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(0x81000001)))
parsed, err := parseTSS2(der)
require.NoError(t, err)
assert.Equal(t, uint32(0x81000001), parsed.Parent, "persistent parent handle")
assert.True(t, persistentHandle(parsed.Parent), "the key is loaded under the persistent parent directly")
}
func TestParseKey_RejectsParentOutOfRange(t *testing.T) {
key := p256(t)
for _, parent := range []int64{-1, 0x1_0000_0000, 0x02000000} {
_, err := parseTSS2(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, tpmtest.WithParent(parent))))
assert.ErrorIs(t, err, errBadParent, "parent %#x is neither persistent nor a hierarchy", parent)
}
}
func TestBERBoolean(t *testing.T) {
absent, err := berBoolean(asn1.RawValue{})
require.NoError(t, err)
assert.False(t, absent, "an absent emptyAuth means the key needs authorization")
for content, want := range map[byte]bool{0x00: false, 0x01: true, 0xff: true} {
v := asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagBoolean, Bytes: []byte{content}, FullBytes: []byte{0x01, 0x01, content}}
got, err := berBoolean(v)
require.NoError(t, err)
assert.Equal(t, want, got, "content byte %#x", content)
}
_, err = berBoolean(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagInteger, Bytes: []byte{1}, FullBytes: []byte{0x02, 0x01, 0x01}})
assert.Error(t, err, "an INTEGER is not a BOOLEAN")
}
+15 -1
View File
@@ -2717,9 +2717,23 @@ func (s *Server) checkDisableAdvancedView() *bool {
return nil
}
// profileOwnerOption passes the OS account of the active profile to the connect client,
// which reads that account's certificate store for user certificate posture checks.
func (s *Server) profileOwnerOption() []internal.ConnectClientOption {
if s.profileManager == nil {
return nil
}
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
log.Warnf("failed to read the active profile owner, no user certificate store is used for certificate posture: %v", err)
return []internal.ConnectClientOption{internal.WithUnknownProfileOwner()}
}
return []internal.ConnectClientOption{internal.WithProfileOwner(activeProf.Username)}
}
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
log.Tracef("running client connection")
client := internal.NewConnectClient(ctx, config, statusRecorder)
client := internal.NewConnectClient(ctx, config, statusRecorder, s.profileOwnerOption()...)
client.SetUpdateManager(s.updateManager)
client.SetSyncResponsePersistence(s.persistSyncResponse)
+19 -1
View File
@@ -206,12 +206,27 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
// The buffered channel lets the abandoned goroutine finish and exit once its blocking call
// returns, so it does not leak beyond the duration of that call.
func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
return getInfoWithChecksTimeout(ctx, timeout, checks, nil, excludeIPs...)
}
// gatherInfoWithChecks is the gathering getInfoWithChecksTimeout bounds. Tests replace it
// to control when a gathering finishes.
var gatherInfoWithChecks = GetInfoWithChecks
// getInfoWithChecksTimeout is GetInfoWithChecksTimeout that calls done, when not nil, once
// the gathering is over: on return when it finished in time, else when the goroutine
// exits, which may be well after the timeout. done may be called twice and must be
// idempotent.
func getInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, done func(), excludeIPs ...netip.Addr) (*Info, bool) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
infoCh := make(chan *Info, 1)
go func() {
info, err := GetInfoWithChecks(ctx, checks, excludeIPs...)
if done != nil {
defer done()
}
info, err := gatherInfoWithChecks(ctx, checks, excludeIPs...)
if err != nil {
if ctx.Err() != nil {
return
@@ -225,6 +240,9 @@ func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks
select {
case info := <-infoCh:
if done != nil {
done()
}
return info, true
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+129 -10
View File
@@ -4,35 +4,154 @@ import (
"context"
"net/netip"
"slices"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
// infoLostAfter is how many timeouts an abandoned gathering is waited for before
// another may start beside it. A system call that is merely slow returns well within
// that; only one blocked for good, such as a stat on a dead network mount, is worked
// around.
infoLostAfter = 10
// maxAbandoned bounds how many gatherings may be left blocked, so a call that never
// returns cannot leak a goroutine on every refresh for the life of the daemon.
maxAbandoned = 2
)
// InfoSource gathers the system info sent to management, keeping the posture
// check results from the last Refresh for the cheap Current snapshots.
// check results from the last Refresh for the cheap Current snapshots. The zero value is
// ready to use.
type InfoSource struct {
files atomic.Pointer[[]File]
// started numbers Refresh calls in the order they began.
started atomic.Uint64
// now overrides the clock when set.
now func() time.Time
mu sync.Mutex
// abandoned holds the start of each gathering that timed out and is still blocked in
// a system call, by Refresh number.
abandoned map[uint64]time.Time
// files holds the file check results of the latest-started Refresh that succeeded,
// filesFrom its number.
files []File
filesFrom uint64
}
// Refresh gathers the info with the posture checks evaluated, bounded by timeout.
// Refresh gathers the info with the posture checks evaluated, bounded by timeout. It may
// run concurrently with other calls; the results Current reuses are those of the call
// that started last, so an older call finishing late cannot replace them. It reports
// false on a timeout, and also while a gathering that timed out earlier is still blocked
// in a system call: starting another would only add one more goroutine stuck on it.
// That one is given up on once it has run for infoLostAfter timeouts, up to maxAbandoned.
func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...)
if !ok {
started := s.clock()
if !s.mayStart(started, time.Duration(infoLostAfter)*timeout) {
return nil, false
}
files := slices.Clone(info.Files)
s.files.Store(&files)
seq := s.started.Add(1)
var mu sync.Mutex
var finished, abandoned bool
done := func() {
mu.Lock()
defer mu.Unlock()
if finished {
return
}
finished = true
if abandoned {
s.release(seq)
}
}
info, ok := getInfoWithChecksTimeout(ctx, timeout, checks, done, excludeIPs...)
if !ok {
mu.Lock()
if !finished {
abandoned = true
s.abandon(seq, started)
}
mu.Unlock()
return nil, false
}
s.publishFiles(seq, info.Files)
return info, true
}
// mayStart reports whether a gathering may start at now. Every abandoned gathering must
// have run for lost, and fewer than maxAbandoned may be left running.
func (s *InfoSource) mayStart(now time.Time, lost time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.abandoned) == 0 {
return true
}
if len(s.abandoned) >= maxAbandoned {
log.Warnf("%d system info gatherings are stuck, skipping this one", len(s.abandoned))
return false
}
for _, started := range s.abandoned {
if now.Sub(started) < lost {
log.Warnf("system info gathering that timed out earlier is still running, skipping this one")
return false
}
}
log.Warnf("system info gathering has been stuck for over %s, starting another", lost)
return true
}
// abandon records that gathering seq, started at started, timed out but is still running.
func (s *InfoSource) abandon(seq uint64, started time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if s.abandoned == nil {
s.abandoned = map[uint64]time.Time{}
}
s.abandoned[seq] = started
}
// release records that the abandoned gathering seq has returned.
func (s *InfoSource) release(seq uint64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.abandoned, seq)
}
func (s *InfoSource) abandonedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.abandoned)
}
func (s *InfoSource) clock() time.Time {
if s.now != nil {
return s.now()
}
return time.Now()
}
func (s *InfoSource) publishFiles(seq uint64, files []File) {
s.mu.Lock()
defer s.mu.Unlock()
if seq < s.filesFrom {
return
}
s.files = slices.Clone(files)
s.filesFrom = seq
}
// Current gathers the info without evaluating the checks, reusing the last Refresh results.
func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info {
info := GetInfo(ctx)
info.removeAddresses(excludeIPs...)
if files := s.files.Load(); files != nil {
info.Files = *files
}
s.mu.Lock()
info.Files = slices.Clone(s.files)
s.mu.Unlock()
return info
}
+139
View File
@@ -2,6 +2,7 @@ package system
import (
"context"
"net/netip"
"os"
"path/filepath"
"testing"
@@ -36,6 +37,106 @@ func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) {
assert.Equal(t, refreshed.Files, info.Files)
}
// TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns stands in for a gathering that
// timed out and is still blocked in a system call: no second one starts on top of it,
// and gathering works again once it exits.
func TestInfoSource_RefreshSkipsWhileEarlierGatheringRuns(t *testing.T) {
var src InfoSource
src.abandoned = map[uint64]time.Time{1: time.Now()}
_, ok := src.Refresh(context.Background(), 15*time.Second, nil)
assert.False(t, ok, "no gathering starts while an earlier one is still running")
src.abandoned = nil
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
require.True(t, ok, "gathering runs once the earlier one exited")
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
assert.True(t, ok, "a gathering that finished in time does not block the next one")
}
// TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits checks that a gathering that
// timed out releases the source once its goroutine finishes, not before.
func TestInfoSource_RefreshReleasesAfterTimedOutGatheringExits(t *testing.T) {
var src InfoSource
_, ok := src.Refresh(context.Background(), time.Nanosecond, nil)
require.False(t, ok, "gathering cannot finish within a nanosecond")
require.Eventually(t, func() bool { return src.abandonedCount() == 0 }, 10*time.Second, 10*time.Millisecond,
"the source is released when the abandoned gathering exits")
_, ok = src.Refresh(context.Background(), 15*time.Second, nil)
assert.True(t, ok, "gathering works again after the abandoned one exited")
}
// stubGathering replaces the gathering with one that reports a single file check for the
// path the checks name, and blocks a gathering for blockedPath until release is closed.
// entered receives once that gathering has started.
func stubGathering(t *testing.T, blockedPath string) (entered chan struct{}, release chan struct{}) {
t.Helper()
entered = make(chan struct{}, 1)
release = make(chan struct{})
original := gatherInfoWithChecks
gatherInfoWithChecks = func(_ context.Context, checks []*proto.Checks, _ ...netip.Addr) (*Info, error) {
path := checks[0].Files[0]
if path == blockedPath {
entered <- struct{}{}
<-release
}
return &Info{Files: []File{{Path: path, Exist: true}}}, nil
}
t.Cleanup(func() { gatherInfoWithChecks = original })
return entered, release
}
func filesCheck(path string) []*proto.Checks {
return []*proto.Checks{{Files: []string{path}}}
}
// TestInfoSource_RefreshRunsConcurrently: only a gathering that timed out holds off new
// ones; a caller gathering while another gathering is in progress is served.
func TestInfoSource_RefreshRunsConcurrently(t *testing.T) {
entered, release := stubGathering(t, "/slow")
var src InfoSource
slowDone := make(chan bool, 1)
go func() {
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/slow"))
slowDone <- ok
}()
<-entered
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/fast"))
assert.True(t, ok, "a gathering runs while another one is still in progress")
close(release)
assert.True(t, <-slowDone, "the slower gathering completes too")
}
// TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults: a Refresh for the previous
// checks that finishes after one for the current checks must not bring back the old
// results Current reports.
func TestInfoSource_LateOlderRefreshDoesNotReplaceNewerResults(t *testing.T) {
entered, release := stubGathering(t, "/old")
var src InfoSource
oldDone := make(chan struct{})
go func() {
defer close(oldDone)
_, _ = src.Refresh(context.Background(), 15*time.Second, filesCheck("/old"))
}()
<-entered
_, ok := src.Refresh(context.Background(), 15*time.Second, filesCheck("/new"))
require.True(t, ok)
close(release)
<-oldDone
files := src.Current(context.Background()).Files
require.Len(t, files, 1)
assert.Equal(t, "/new", files[0].Path, "the results of the refresh that started last are kept")
}
func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
addrs := GetInfo(context.Background()).NetworkAddresses
if len(addrs) == 0 {
@@ -57,3 +158,41 @@ func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
assert.NotEqual(t, excluded, addr.NetIP.Addr())
}
}
// TestInfoSource_LostGatheringDoesNotBlockForever: a gathering blocked for good, such as
// an os.Stat on a dead network mount, must not stop every later meta sync. Once it has
// run for lostAfter timeouts another starts beside it, and no more than maxAbandoned
// are ever left running.
func TestInfoSource_LostGatheringDoesNotBlockForever(t *testing.T) {
entered, release := stubGathering(t, "/wedged")
t.Cleanup(func() { close(release) })
now := time.Now()
src := InfoSource{now: func() time.Time { return now }}
const timeout = 10 * time.Millisecond
lost := time.Duration(infoLostAfter) * timeout
_, ok := src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
require.False(t, ok, "the wedged gathering times out")
<-entered
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
assert.False(t, ok, "a gathering that may still finish holds off the next one")
now = now.Add(lost)
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
require.False(t, ok, "the second wedged gathering times out too")
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("once the first gathering is lost another one starts")
}
now = now.Add(lost)
_, ok = src.Refresh(context.Background(), timeout, filesCheck("/wedged"))
assert.False(t, ok, "no more than maxAbandoned gatherings are left running")
select {
case <-entered:
t.Fatal("a third gathering started beside two lost ones")
case <-time.After(50 * time.Millisecond):
}
}