[client, management] implement certificate posture check (#7535)

Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
Pascal Fischer
2026-10-09 14:57:00 +02:00
committed by GitHub
co-authored by mlsmaycon
parent a5834fdaab
commit 53a14551c8
78 changed files with 7028 additions and 1382 deletions
+277
View File
@@ -0,0 +1,277 @@
# 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. |
+114
View File
@@ -0,0 +1,114 @@
package certproof
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// Collect answers the certificate challenges in checks: for each challenge it picks a
// stored certificate that chains to the challenge's CAs and signs the nonce with its
// key. The same certificate is proven once even if several checks accept it.
func Collect(ctx context.Context, store Store, checks []*proto.Checks, peerKey []byte) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
return CollectChallenges(ctx, store, challenges, peerKey)
}
func logNoChallenges(checks []*proto.Checks) {
if len(checks) > 0 {
log.Infof("certificate posture: %d posture checks received, none carries a certificate challenge", len(checks))
}
}
// CollectChallenges answers challenges already extracted from the posture checks, so a
// caller that ships them across a process boundary reuses the same matching and signing.
func CollectChallenges(ctx context.Context, store Store, challenges []*proto.CertificateChallenge, peerKey []byte) []certposture.Proof {
log.Infof("certificate posture: answering %d certificate challenges from store %T", len(challenges), store)
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("failed loading certificates for posture checks: %v", err)
return nil
}
if len(candidates) == 0 {
log.Info("certificate posture: certificate store holds no candidates, no proof will be sent")
return nil
}
log.Infof("certificate posture: store holds %d candidate certificates", len(candidates))
now := time.Now()
proven := make(map[[sha256.Size]byte]struct{})
var proofs []certposture.Proof
for i, challenge := range challenges {
roots, err := certposture.ParseCAs(challenge.GetCaCertificates())
if err != nil {
log.Warnf("skipping certificate challenge with invalid CA certificates: %v", err)
continue
}
log.Infof("certificate posture: challenge %d accepts %d CA certificates, nonce is %d bytes", i, len(challenge.GetCaCertificates()), len(challenge.GetNonce()))
matched := false
for _, candidate := range candidates {
if len(candidate.Chain) == 0 {
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)
continue
}
matched = true
fingerprint := sha256.Sum256(leaf.Raw)
if _, done := proven[fingerprint]; done {
log.Infof("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
break
}
proof, err := prove(candidate, challenge.GetNonce(), peerKey)
if err != nil {
log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err)
continue
}
log.Infof("certificate posture: challenge %d proven by %q with %s, signature %d bytes, chain of %d", i, leaf.Subject, proof.SigAlg, len(proof.Signature), len(proof.Chain))
proven[fingerprint] = struct{}{}
proofs = append(proofs, proof)
break
}
if !matched {
log.Infof("certificate posture: challenge %d matched none of the %d candidates", i, len(candidates))
}
}
log.Infof("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs))
return proofs
}
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 {
challenges = append(challenges, challenge)
}
}
return challenges
}
func prove(candidate Candidate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(candidate.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)
}
return certposture.Proof{Nonce: nonce, Chain: chain, SigAlg: sigAlg, Signature: sig}, nil
}
@@ -0,0 +1,96 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this Mac
// can reach. The root daemon reads the System keychain itself, which is where MDM
// installs device identities, and reaches the console user's login keychain only by
// launching a helper into that user's session. A Mac sitting at the login window
// therefore yields device proofs alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
// A helper already runs inside the user's session, so it reads its own keychain
// directly and must never launch another one.
if os.Geteuid() != 0 {
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
userProofs, err := collectAsConsoleUser(ctx, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: console user keychain unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
// which is what launchctl asuser does.
func collectAsConsoleUser(ctx context.Context, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentConsoleUser()
if !ok {
return nil, nil
}
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
payload, err := json.Marshal(helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
uid := strconv.FormatUint(uint64(user.UID), 10)
cmd := exec.CommandContext(ctx, "launchctl", "asuser", uid, "sudo", "-u", user.Name, "-H", binary, "posture", "cert-proof")
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
log.Infof("certificate posture: asking the desktop session of %q (uid %s) to answer %d challenges", user.Name, uid, len(challenges))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("run helper as %s: %w: %s", user.Name, err, strings.TrimSpace(stderr.String()))
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
log.Infof("certificate posture: desktop session of %q returned %d proofs", user.Name, len(resp.Proofs))
return resp.Proofs, nil
}
// helperStore is the store the helper reads. On macOS the keychain search list of the
// user's own session already is that user's keychain, so the platform default is right.
func helperStore() Store {
return DefaultStore()
}
@@ -0,0 +1,24 @@
//go:build !darwin && !windows
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
// per-user certificates out of reach of a privileged daemon, so every other platform
// reads its store in the daemon itself.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
}
// helperStore is the store the helper reads. Nothing launches a helper on these
// platforms, so it is the platform default.
func helperStore() Store {
return DefaultStore()
}
+108
View File
@@ -0,0 +1,108 @@
package certproof
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
corpCA := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := t.TempDir()
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
otherKey := certtest.RSAKey(t)
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
writeFile(t, dir, "notes.txt", "ignored")
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
challenge := func(cas ...string) *proto.Checks {
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
}
checks := []*proto.Checks{
{Files: []string{"/usr/bin/agent"}},
challenge(corpCA.PEM),
challenge(corpCA.PEM),
challenge(otherCA.PEM),
challenge(unrelatedCA.PEM),
challenge("not a pem"),
}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 2)
var subjects []string
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, time.Now())
require.NoError(t, err)
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
}
func TestCollect_NothingToProve(t *testing.T) {
dir := t.TempDir()
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))
tests := []struct {
name string
store Store
checks []*proto.Checks
}{
{"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}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
})
}
}
func TestFileStore_ChainWithIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1)
require.Len(t, candidates[0].Chain, 2)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
@@ -0,0 +1,101 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this
// machine can reach. The service reads the local machine store itself, where AD and
// Intune enrol device certificates, and reaches the signed-in user's store by launching
// a helper with that session's token. A machine at the sign-in screen therefore proves
// device certificates alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
// The helper already runs as the signed-in user, and an ordinary process has no
// right to a session token, so only the service goes looking for one.
if !runningAsLocalSystem() {
return proofs
}
userProofs, err := collectAsDesktopUser(ctx, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: user certificate store unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
// that user's store rather than the machine store the service already read.
func helperStore() Store {
return NewUserStore()
}
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
// directly, so the session token goes straight into the child process.
func collectAsDesktopUser(ctx context.Context, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentDesktopUser()
if !ok {
return nil, nil
}
defer user.Close()
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
payload, err := json.Marshal(helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(user.Token),
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
log.Infof("certificate posture: asking the session of %q (session %d) to answer %d challenges", user.Name, user.Session, len(challenges))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("run helper as %s: %w: %s", user.Name, err, strings.TrimSpace(stderr.String()))
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
log.Infof("certificate posture: session of %q returned %d proofs", user.Name, len(resp.Proofs))
return resp.Proofs, nil
}
@@ -0,0 +1,113 @@
package certproof
import (
"bytes"
"fmt"
"sync"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
encodingUTF8 = 0x08000100
consoleNameBufSize = 256
)
var (
consoleOnce sync.Once
consoleErr error
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
)
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
type ConsoleUser struct {
Name string
UID uint32
GID uint32
}
// CurrentConsoleUser reports the user sitting at the desktop. The second return value is
// false when nobody is: at the login window macOS either reports no console user at all
// or attributes the session to root, and neither has a login keychain to offer.
func CurrentConsoleUser() (ConsoleUser, bool) {
if err := loadConsoleUser(); err != nil {
log.Infof("console user lookup unavailable: %v", err)
return ConsoleUser{}, false
}
var uid, gid uint32
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
if name == 0 {
log.Info("no console user is logged in, no login keychain is reachable")
return ConsoleUser{}, false
}
defer cfRelease(name)
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
if !user.hasDesktop() {
log.Infof("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
return ConsoleUser{}, false
}
return user, true
}
// hasDesktop reports whether the console session is a real user desktop. The login
// window runs as root and some macOS releases name it "loginwindow" instead.
func (u ConsoleUser) hasDesktop() bool {
switch u.Name {
case "", "root", "loginwindow":
return false
}
return u.UID != 0
}
func cfString(str uintptr) string {
buf := make([]byte, consoleNameBufSize)
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
return ""
}
if end := bytes.IndexByte(buf, 0); end >= 0 {
return string(buf[:end])
}
return string(buf)
}
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
// first because CFRelease is resolved there and released strings depend on it.
func loadConsoleUser() error {
if err := loadKeychain(); err != nil {
return err
}
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
return consoleErr
}
func resolveConsoleUser() error {
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
if err != nil {
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
}
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
if err != nil {
return fmt.Errorf("resolve CFStringGetCString: %w", err)
}
purego.RegisterFunc(&cfStringGetCString, symbol)
return nil
}
@@ -0,0 +1,39 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
tests := []struct {
name string
user ConsoleUser
desktop bool
}{
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
{"no console user", ConsoleUser{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
})
}
}
// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with
// a desktop open must report a non-root user; a headless runner must report none.
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
user, ok := CurrentConsoleUser()
if !ok {
t.Log("no console user, running headless")
return
}
assert.NotEmpty(t, user.Name, "a console user must have a name")
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
}
@@ -0,0 +1,126 @@
package certproof
import (
"fmt"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
noActiveSession = 0xFFFFFFFF
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE and wtsActive is WTSActive of
// WTS_CONNECTSTATE_CLASS. Neither is exported by x/sys/windows.
wtsCurrentServer = windows.Handle(0)
wtsActive = 0
)
// DesktopUser is an interactive session and the account signed into it. The user's
// certificate store is readable only from a process running as that account, because
// its private keys are protected against the user profile rather than the machine.
type DesktopUser struct {
Session uint32
Name string
Token windows.Token
}
// Close releases the session token.
func (u DesktopUser) Close() {
if err := u.Token.Close(); err != nil {
log.Debugf("failed closing desktop session token: %v", err)
}
}
// CurrentDesktopUser returns a token for the interactive user whose certificate store
// should be asked. The physical console comes first, and an active remote desktop
// session is used when nobody is at the console, which is how servers and VDI hosts are
// normally reached. The second return is false at the sign-in screen, where no
// interactive session exists and only machine certificates can be proven.
//
// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an
// ordinary process does not.
func CurrentDesktopUser() (DesktopUser, bool) {
if session := windows.WTSGetActiveConsoleSessionId(); session != noActiveSession {
if user, ok := desktopUser(session); ok {
return user, true
}
log.Infof("console session %d has nobody signed in, looking for an active remote session", session)
}
sessions, err := activeSessions()
if err != nil {
log.Infof("cannot enumerate terminal sessions: %v", err)
return DesktopUser{}, false
}
for _, session := range sessions {
if user, ok := desktopUser(session); ok {
return user, true
}
}
log.Info("no interactive session is signed in, no user certificate store is reachable")
return DesktopUser{}, false
}
func desktopUser(session uint32) (DesktopUser, bool) {
var token windows.Token
if err := windows.WTSQueryUserToken(session, &token); err != nil {
log.Debugf("no user token for session %d: %v", session, err)
return DesktopUser{}, false
}
name, err := tokenAccount(token)
if err != nil {
log.Infof("session %d token has no readable account: %v", session, err)
if closeErr := token.Close(); closeErr != nil {
log.Debugf("failed closing session token: %v", closeErr)
}
return DesktopUser{}, false
}
return DesktopUser{Session: session, Name: name, Token: token}, true
}
func tokenAccount(token windows.Token) (string, error) {
user, err := token.GetTokenUser()
if err != nil {
return "", fmt.Errorf("read token user: %w", err)
}
account, domain, _, err := user.User.Sid.LookupAccount("")
if err != nil {
return "", fmt.Errorf("look up account: %w", err)
}
if domain == "" {
return account, nil
}
return domain + `\` + account, nil
}
func activeSessions() ([]uint32, error) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
return nil, fmt.Errorf("enumerate sessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
var active []uint32
for _, session := range unsafe.Slice(info, count) {
if session.State == wtsActive {
active = append(active, session.SessionID)
}
}
return active, nil
}
// runningAsLocalSystem reports whether this process is the service. The helper runs as
// the signed-in user and must read its own store rather than launching another helper.
func runningAsLocalSystem() bool {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
log.Debugf("failed reading own token user: %v", err)
return false
}
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
}
+66
View File
@@ -0,0 +1,66 @@
package certproof
import (
"context"
"encoding/json"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// HelperRequest is the work the daemon hands to a helper running in a user session. The
// peer key binds every signature to this machine, so a proof cannot be replayed onto
// another peer.
type HelperRequest struct {
PeerKey []byte `json:"peerKey"`
Challenges []HelperChallenge `json:"challenges"`
}
// HelperChallenge is one certificate challenge in the form the helper needs.
type HelperChallenge struct {
Nonce []byte `json:"nonce"`
CACertificates []string `json:"caCertificates"`
}
// HelperResponse carries the proofs the helper produced from its own keychain.
type HelperResponse struct {
Proofs []certposture.Proof `json:"proofs"`
}
// RunHelper answers the challenges on in from the store of the user running this
// process and writes the proofs to out. It is the child half of the console user
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
// session instead. Only the signature crosses back, never the private key.
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
return runHelper(ctx, helperStore(), in, out)
}
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
var req HelperRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode helper request: %w", err)
}
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
for _, challenge := range req.Challenges {
challenges = append(challenges, &proto.CertificateChallenge{
Nonce: challenge.Nonce,
CaCertificates: challenge.CACertificates,
})
}
var proofs []certposture.Proof
if len(challenges) > 0 {
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
}
log.Infof("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
return fmt.Errorf("encode helper response: %w", err)
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
//go:build darwin || windows
package certproof
import (
"crypto/sha256"
"crypto/x509"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest {
req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))}
for _, challenge := range challenges {
req.Challenges = append(req.Challenges, HelperChallenge{
Nonce: challenge.GetNonce(),
CACertificates: challenge.GetCaCertificates(),
})
}
return req
}
// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that
// both stores hold so the same certificate is proven once.
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
if len(user) == 0 {
return device
}
seen := make(map[[sha256.Size]byte]struct{}, len(device))
for _, proof := range device {
if len(proof.Chain) > 0 {
seen[sha256.Sum256(proof.Chain[0])] = struct{}{}
}
}
merged := device
for _, proof := range user {
if len(proof.Chain) == 0 {
continue
}
fingerprint := sha256.Sum256(proof.Chain[0])
if _, done := seen[fingerprint]; done {
continue
}
seen[fingerprint] = struct{}{}
merged = append(merged, proof)
logUserProof(proof)
}
return merged
}
func logUserProof(proof certposture.Proof) {
leaf, err := x509.ParseCertificate(proof.Chain[0])
if err != nil {
log.Infof("certificate posture: user proof carries an unparsable leaf: %v", err)
return
}
log.Infof("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
}
@@ -0,0 +1,56 @@
//go:build darwin || windows
package certproof
import (
"crypto/sha256"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
shared := ca.Issue(t, certtest.ECDSAKey(t), "shared")
userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only")
device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}}
user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}}
merged := mergeProofs(device, user)
require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped")
assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place")
assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended")
}
func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}}
merged := mergeProofs(device, nil)
require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof")
assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged")
}
func TestHelperRequest_CarriesEveryChallenge(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
challenges := []*proto.CertificateChallenge{
{Nonce: []byte("first"), CaCertificates: []string{ca.PEM}},
{Nonce: []byte("second")},
}
req := helperRequest(challenges, peerKey)
require.Len(t, req.Challenges, 2, "every challenge must reach the helper")
assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine")
assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged")
assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged")
assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs")
}
+63
View File
@@ -0,0 +1,63 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := t.TempDir()
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
request, err := json.Marshal(HelperRequest{
PeerKey: peerKey,
Challenges: []HelperChallenge{{Nonce: nonce, CACertificates: []string{ca.PEM}}},
})
require.NoError(t, err, "request must encode")
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON")
require.Len(t, resp.Proofs, 1, "the matching certificate should produce one proof")
// Verify exactly as management does, so the proof is proven to survive the encode,
// the process boundary and the decode intact.
chain, err := challenger.Verify(resp.Proofs[0], peerKey, time.Now())
require.NoError(t, err, "the decoded proof must verify against the issued nonce")
assert.Equal(t, "device", chain[0].Subject.CommonName, "the proven leaf should be the device certificate")
}
func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
request, err := json.Marshal(HelperRequest{PeerKey: peerKey})
require.NoError(t, err)
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
assert.Empty(t, resp.Proofs, "no challenges should produce no proofs")
}
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
var stdout bytes.Buffer
err := runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader([]byte("not json")), &stdout)
require.Error(t, err, "a malformed request must fail rather than emit an empty proof set")
assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure")
}
@@ -0,0 +1,363 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"errors"
"fmt"
"io"
"os"
"sync"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
errSecItemNotFound = -25300
)
var (
keychainOnce sync.Once
keychainErr error
secItemCopyMatching func(query uintptr, result *uintptr) int32
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
secCertificateCopyData func(cert uintptr) uintptr
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
secKeychainCopySearchList func(searchList *uintptr) int32
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
cfArrayGetCount func(array uintptr) int
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
cfDataGetLength func(data uintptr) int
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
cfErrorGetCode func(err uintptr) int
cfRelease func(ref uintptr)
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks uintptr
)
// DefaultStore is the keychain search list of the daemon, which for the root daemon is
// the System keychain where MDM installs device identities.
func DefaultStore() Store {
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{}
func NewKeychainStore() *KeychainStore {
return &KeychainStore{}
}
func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
if err := loadKeychain(); err != nil {
return nil, err
}
var leaves []*x509.Certificate
err := eachIdentity(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)
leaves = append(leaves, cert)
return false, nil
})
if err != nil {
return nil, err
}
// The certificate query runs even without identities: it separates a keychain that is
// readable but holds no identity from one the process cannot read at all.
pool, err := keychainCertificates()
if err != nil {
return nil, err
}
if len(leaves) == 0 {
log.Infof("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
return nil, nil
}
log.Infof("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Infof("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
log.Infof("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
}
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf}})
}
return candidates, nil
}
// 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
}
func (s *keychainSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
log.Infof("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
algorithm := keychainAlgorithm(scheme)
var signature []byte
err = eachIdentity(func(identity uintptr, der []byte) (bool, error) {
if !bytes.Equal(der, s.leaf.Raw) {
return false, nil
}
signature, err = signWithIdentity(identity, algorithm, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the keychain")
}
log.Infof("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
return signature, nil
}
func keychainAlgorithm(scheme sigScheme) uintptr {
switch scheme {
case schemeECDSASHA384:
return kSecKeyAlgorithmECDSASHA384
case schemeRSAPSSSHA256:
return kSecKeyAlgorithmRSAPSSSHA256
default:
return kSecKeyAlgorithmECDSASHA256
}
}
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
var key uintptr
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
}
defer cfRelease(key)
data := cfDataCreate(0, &digest[0], len(digest))
defer cfRelease(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))
}
defer cfRelease(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) {
var cert uintptr
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
return true, fmt.Errorf("SecIdentityCopyCertificate: %d", status)
}
der := certificateDER(cert)
cfRelease(cert)
return fn(identity, der)
})
}
func keychainCertificates() ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var unparsable int
err := eachMatching(kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
certs = append(certs, cert)
return false, nil
}
unparsable++
return false, nil
})
log.Infof("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
return certs, err
}
func eachMatching(class uintptr, name string, fn func(item uintptr) (bool, error)) error {
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
defer cfRelease(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)
return nil
default:
log.Infof("keychain %s query returned OSStatus %d", name, status)
return fmt.Errorf("SecItemCopyMatching: %d", status)
}
defer cfRelease(items)
n := cfArrayGetCount(items)
log.Infof("keychain %s query returned %d items", name, n)
for i := 0; i < n; i++ {
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
return err
}
}
return nil
}
func certificateDER(cert uintptr) []byte {
data := secCertificateCopyData(cert)
defer cfRelease(data)
return dataBytes(data)
}
func dataBytes(data uintptr) []byte {
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), cfDataGetLength(data)))
}
func loadKeychain() error {
keychainOnce.Do(func() {
if keychainErr = resolveKeychain(); keychainErr != nil {
log.Infof("macOS keychain unavailable for certificate posture: %v", keychainErr)
return
}
log.Infof("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
logSearchList()
})
return keychainErr
}
// logSearchList reports the keychains the process searches. The root daemon sees the
// System keychain and System Roots, never a user's login keychain.
func logSearchList() {
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
log.Info("keychain search list diagnostics unavailable on this macOS version")
return
}
var list uintptr
if status := secKeychainCopySearchList(&list); status != 0 {
log.Infof("SecKeychainCopySearchList returned OSStatus %d", status)
return
}
defer cfRelease(list)
n := cfArrayGetCount(list)
log.Infof("keychain search list contains %d keychains", n)
for i := 0; i < n; i++ {
log.Infof("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
}
}
func keychainPath(keychain uintptr) string {
path := make([]byte, 1024)
length := uint32(len(path))
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
}
return string(path[:length])
}
func resolveKeychain() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
for _, fn := range []struct {
ptr any
lib uintptr
name string
}{
{&secItemCopyMatching, security, "SecItemCopyMatching"},
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
{&secCertificateCopyData, security, "SecCertificateCopyData"},
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
{&cfDataCreate, coreFoundation, "CFDataCreate"},
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
{&cfRelease, coreFoundation, "CFRelease"},
} {
symbol, err := purego.Dlsym(fn.lib, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
purego.RegisterFunc(fn.ptr, symbol)
}
for _, global := range []struct {
ptr *uintptr
lib uintptr
name string
deref bool
}{
{&kSecClass, security, "kSecClass", true},
{&kSecClassIdentity, security, "kSecClassIdentity", true},
{&kSecClassCertificate, security, "kSecClassCertificate", true},
{&kSecMatchLimit, security, "kSecMatchLimit", true},
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
{&kSecReturnRef, security, "kSecReturnRef", 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},
} {
addr, err := purego.Dlsym(global.lib, global.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", global.name, err)
}
if global.deref {
addr = **(**uintptr)(unsafe.Pointer(&addr))
}
*global.ptr = addr
}
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
return nil
}
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
// longer exports it so keychain lookups keep working without it.
func resolveOptional(lib uintptr, name string, ptr any) {
symbol, err := purego.Dlsym(lib, name)
if err != nil {
log.Infof("keychain diagnostics: %s unavailable: %v", name, err)
return
}
purego.RegisterFunc(ptr, symbol)
}
+77
View File
@@ -0,0 +1,77 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"slices"
)
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
type sigScheme int
const (
schemeECDSASHA256 sigScheme = iota + 1
schemeECDSASHA384
schemeRSAPSSSHA256
)
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
switch pub.(type) {
case *ecdsa.PublicKey:
switch opts.HashFunc() {
case crypto.SHA256:
return schemeECDSASHA256, nil
case crypto.SHA384:
return schemeECDSASHA384, nil
}
case *rsa.PublicKey:
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
return schemeRSAPSSSHA256, nil
}
}
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
}
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
chain := []*x509.Certificate{leaf}
current := leaf
for current.CheckSignatureFrom(current) != nil {
issuer := issuerIn(current, pool, chain)
if issuer == nil {
break
}
chain = append(chain, issuer)
current = issuer
}
return chain
}
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
for _, candidate := range pool {
if slices.ContainsFunc(seen, candidate.Equal) {
continue
}
if cert.CheckSignatureFrom(candidate) == nil {
return candidate
}
}
return nil
}
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
if len(raw) == 0 || len(raw)%2 != 0 {
return nil, errors.New("malformed raw ECDSA signature")
}
half := len(raw) / 2
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
}
@@ -0,0 +1,87 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestBuildChain_FollowsIssuersThroughThePool(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
unrelated := certtest.NewCA(t, "unrelated")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
pool := []*x509.Certificate{unrelated.Cert, root.Cert, leaf, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Equal(t, []*x509.Certificate{leaf, intermediate.Cert, root.Cert}, chain)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(chain, roots, time.Now()))
}
func TestBuildChain_StopsWhereThePoolEnds(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
assert.Equal(t, []*x509.Certificate{leaf}, buildChain(leaf, nil))
assert.Equal(t, []*x509.Certificate{leaf, intermediate.Cert}, buildChain(leaf, []*x509.Certificate{intermediate.Cert}))
}
func TestSchemeFor(t *testing.T) {
ecKey := certtest.ECDSAKey(t)
rsaKey := certtest.RSAKey(t)
pss := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
tests := []struct {
name string
pub crypto.PublicKey
opts crypto.SignerOpts
want sigScheme
}{
{"ecdsa sha256", ecKey.Public(), crypto.SHA256, schemeECDSASHA256},
{"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384},
{"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256},
{"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0},
{"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := schemeFor(tc.pub, tc.opts)
if tc.want == 0 {
assert.ErrorIs(t, err, errUnsupportedScheme)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestECDSASignatureASN1(t *testing.T) {
key := certtest.ECDSAKey(t).(*ecdsa.PrivateKey)
digest := sha256.Sum256([]byte("nonce"))
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
require.NoError(t, err)
raw := append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
der, err := ecdsaSignatureASN1(raw)
require.NoError(t, err)
assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der))
_, err = ecdsaSignatureASN1(raw[:63])
assert.Error(t, err)
}
+294
View File
@@ -0,0 +1,294 @@
package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
// 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.
type PKCS11Config struct {
URI string
PIN string
}
// 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
// directory by public key. Every signature happens on the token.
type PKCS11Store struct {
uri *pkcs11.URI
pin string
certDir string
}
// 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.
func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) {
store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir}
if cfg.URI == "" {
return store, nil
}
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
}
store.uri = parsed
return store, nil
}
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
session, err := s.open()
if err != nil {
return nil, err
}
defer session.Close()
certs, err := tokenCertificates(session)
if err != nil {
return nil, err
}
fileChains, err := s.fileChains()
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))
pool := make([]*x509.Certificate, 0, len(certs))
for _, cert := range certs {
pool = append(pool, cert.cert)
}
for _, chain := range fileChains {
pool = append(pool, chain...)
}
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)
continue
}
candidates = append(candidates, s.candidate(cert.cert, cert.id, pool))
}
if len(fileChains) == 0 {
return candidates, nil
}
keys, err := tokenPublicKeys(session)
if err != nil {
return nil, err
}
for _, chain := range fileChains {
leaf := chain[0]
id, ok := keys.idFor(leaf.PublicKey)
if !ok {
log.Debugf("%s holds no key for certificate %q from %s", s, leaf.Subject, s.certDir)
continue
}
candidates = append(candidates, s.candidate(leaf, id, pool))
}
return candidates, nil
}
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}}
}
// fileChains reads the certificate files in the PEM directory that carry no key of their
// own; the file store answers for the ones that do.
func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) {
if s.certDir == "" {
return nil, nil
}
paths, err := certFiles(s.certDir)
if err != nil {
return nil, err
}
var chains [][]*x509.Certificate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil || signer != nil {
continue
}
chains = append(chains, chain)
}
return chains, nil
}
type tokenKey struct {
id []byte
public crypto.PublicKey
}
type tokenKeys []tokenKey
func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) {
objects, err := session.FindObjects(pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPublicKey)})
if err != nil {
return nil, err
}
keys := make(tokenKeys, 0, len(objects))
for _, object := range objects {
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
public, err := session.PublicKey(object)
if err != nil {
log.Debugf("skipping public key on PKCS#11 token: %v", err)
continue
}
keys = append(keys, tokenKey{id: id, public: public})
}
return keys, nil
}
// idFor finds the token key whose public half is pub, so a certificate kept outside the
// token is still signed for by the key inside it.
func (k tokenKeys) idFor(pub crypto.PublicKey) ([]byte, bool) {
for _, key := range k {
equaler, ok := key.public.(interface{ Equal(crypto.PublicKey) bool })
if ok && len(key.id) > 0 && equaler.Equal(pub) {
return key.id, true
}
}
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
}
func tokenCertificates(session *pkcs11.Session) ([]tokenCertificate, error) {
objects, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassCertificate)},
pkcs11.Attribute{Type: pkcs11.AttrCertificateType, Value: pkcs11.ULong(pkcs11.CertificateX509)},
)
if err != nil {
return nil, err
}
certs := make([]tokenCertificate, 0, len(objects))
for _, object := range objects {
der, err := session.Attribute(object, pkcs11.AttrValue)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping unparsable certificate on PKCS#11 token: %v", err)
continue
}
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
certs = append(certs, tokenCertificate{cert: cert, id: id})
}
return certs, nil
}
var errNoPrivateKey = errors.New("no private key shares the certificate's CKA_ID")
func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) {
if len(id) == 0 {
return 0, errNoPrivateKey
}
keys, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPrivateKey)},
pkcs11.Attribute{Type: pkcs11.AttrID, Value: id},
)
if err != nil {
return 0, err
}
if len(keys) == 0 {
return 0, errNoPrivateKey
}
return keys[0], nil
}
// pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh
// session at signing time so no token handle outlives a call.
type pkcs11Signer struct {
store *PKCS11Store
leaf *x509.Certificate
id []byte
}
func (s *pkcs11Signer) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *pkcs11Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
session, err := s.store.open()
if err != nil {
return nil, err
}
defer session.Close()
key, err := privateKey(session, s.id)
if err != nil {
return nil, err
}
signature, err := session.Sign(pkcs11Mechanism(scheme), key, digest)
if err != nil {
return nil, err
}
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// pkcs11Mechanism maps a signature scheme onto the token mechanism that consumes a digest.
func pkcs11Mechanism(scheme sigScheme) pkcs11.Mechanism {
if scheme == schemeRSAPSSSHA256 {
return pkcs11.Mechanism{
Type: pkcs11.MechRSAPKCSPSS,
PSS: &pkcs11.PSSParams{Hash: pkcs11.MechSHA256, MGF: pkcs11.MGF1SHA256, SaltLen: sha256.Size},
}
}
return pkcs11.Mechanism{Type: pkcs11.MechECDSA}
}
@@ -0,0 +1,331 @@
package certproof
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"math/big"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
const testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
type failingStore struct{}
func (failingStore) Candidates(context.Context) ([]Candidate, error) {
return nil, errors.New("token unplugged")
}
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key := certtest.ECDSAKey(t)
dir := t.TempDir()
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())
require.NoError(t, err)
assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token")
}
// TestCollect_PKCS11TokenEndToEnd needs an initialised token with a user PIN, named by
// NB_TEST_PKCS11_URI. With SoftHSM:
//
// 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
//
// It imports a key and its certificate as token objects, then proves the certificate
// through the store the way the daemon would. Every run adds one more identity to the token.
func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
store, uri := pkcs11TestStore(t, "")
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-"+name)
leaf := ca.Issue(t, key, "device-"+name)
importIdentity(t, uri, key, leaf)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token-held key must prove exactly this run's certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the imported certificate")
})
}
}
// Attribute types the import needs and the store does not.
const (
attrPrivate = 0x2
attrIssuer = 0x81
attrSerialNumber = 0x82
attrSensitive = 0x103
attrSign = 0x108
attrVerify = 0x10a
attrPrivateExponent = 0x123
attrPrime1 = 0x124
attrPrime2 = 0x125
attrExponent1 = 0x126
attrExponent2 = 0x127
attrCoefficient = 0x128
)
var (
ckTrue = []byte{1}
ckFalse = []byte{0}
// The P-256 named curve OID in DER, which is what CKA_EC_PARAMS carries.
oidP256 = []byte{0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}
)
// importIdentity stores key and leaf on the token the way tpm2_ptool import and addcert
// do: private and public key objects plus the certificate, all under one CKA_ID.
func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Certificate) {
t.Helper()
id := importKey(t, uri, key, leaf.Subject.CommonName)
importCertificate(t, uri, leaf, id)
}
func importKey(t *testing.T, uri string, key crypto.Signer, label string) []byte {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
id := make([]byte, 8)
_, err := rand.Read(id)
require.NoError(t, err)
private := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPrivateKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckTrue),
attr(attrSensitive, ckTrue),
attr(attrSign, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(private, privateKeyAttributes(t, key)...)...)
require.NoError(t, err, "import private key")
public := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPublicKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(attrVerify, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(public, publicKeyAttributes(t, key)...)...)
require.NoError(t, err, "import public key")
return id
}
func importCertificate(t *testing.T, uri string, leaf *x509.Certificate, id []byte) {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
serial, err := asn1.Marshal(leaf.SerialNumber)
require.NoError(t, err)
_, err = session.CreateObject(
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassCertificate)),
attr(pkcs11.AttrCertificateType, pkcs11.ULong(pkcs11.CertificateX509)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(pkcs11.AttrLabel, []byte(leaf.Subject.CommonName)),
attr(pkcs11.AttrID, id),
attr(pkcs11.AttrSubject, leaf.RawSubject),
attr(attrIssuer, leaf.RawIssuer),
attr(attrSerialNumber, serial),
attr(pkcs11.AttrValue, leaf.Raw),
)
require.NoError(t, err, "import certificate")
}
func readWriteSession(t *testing.T, uri string) *pkcs11.Session {
t.Helper()
parsed, err := pkcs11.ParseURI(uri)
require.NoError(t, err)
module, err := pkcs11.Load(parsed.Module())
require.NoError(t, err)
pin, err := parsed.PIN()
require.NoError(t, err)
session, err := module.OpenReadWriteSession(parsed.Token, pin)
require.NoError(t, err)
return session
}
func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrValue, k.D.FillBytes(make([]byte, 32))),
}
case *rsa.PrivateKey:
k.Precompute()
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
attr(attrPrivateExponent, k.D.Bytes()),
attr(attrPrime1, k.Primes[0].Bytes()),
attr(attrPrime2, k.Primes[1].Bytes()),
attr(attrExponent1, k.Precomputed.Dp.Bytes()),
attr(attrExponent2, k.Precomputed.Dq.Bytes()),
attr(attrCoefficient, k.Precomputed.Qinv.Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
// publicKeyAttributes describes the CKO_PUBLIC_KEY object tokens keep next to a private
// key, which is what the store reads to pair a certificate file with its key.
func publicKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
point := append([]byte{4}, k.X.FillBytes(make([]byte, 32))...)
point = append(point, k.Y.FillBytes(make([]byte, 32))...)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrECPoint, wrapped),
}
case *rsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
func attr(typ uint, value []byte) pkcs11.Attribute {
return pkcs11.Attribute{Type: typ, Value: value}
}
// pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when
// no token is configured or this build lacks PKCS#11 support.
func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) {
t.Helper()
uri := os.Getenv(testPKCS11URIEnv)
if uri == "" {
t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv)
}
store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir)
require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err)
}
return store, uri
}
// TestCollect_PKCS11KeyWithFileCertificate covers the split layout: the key lives on the
// 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()
store, uri := pkcs11TestStore(t, dir)
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-file-"+name)
leaf := ca.Issue(t, key, "device-file-"+name)
importKey(t, uri, key, "device-file-"+name)
writeFile(t, dir, "device-"+name+".pem", certtest.CertPEM(leaf))
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token key must prove the certificate kept on disk")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the certificate from the directory")
})
}
}
func TestPKCS11Store_FileChains(t *testing.T) {
ca := certtest.NewCA(t, "corp")
dir := t.TempDir()
// 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")))
writeFile(t, dir, "ca.crt", ca.PEM)
keyed := certtest.ECDSAKey(t)
writeFile(t, dir, "inline.pem", certtest.CertPEM(ca.Issue(t, keyed, "inline"))+certtest.KeyPEM(t, keyed))
writeFile(t, dir, "sibling.crt", certtest.CertPEM(ca.Issue(t, keyed, "sibling")))
writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed))
writeFile(t, dir, "notes.txt", "not a certificate")
chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains()
require.NoError(t, err)
var subjects []string
for _, chain := range chains {
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token")
chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains()
require.NoError(t, err)
assert.Empty(t, chains, "no directory configured means no file certificates")
}
func TestNewPKCS11Store_PIN(t *testing.T) {
tests := []struct {
name string
cfg PKCS11Config
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},
{"no pin at all means no login", PKCS11Config{URI: "pkcs11:token=netbird"}, nil, pkcs11.DefaultModule},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store, err := NewPKCS11Store(tt.cfg, "")
require.NoError(t, err)
pin, err := store.userPIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
assert.Equal(t, tt.wantModule, store.uri.Module(), "module to load")
})
}
_, 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")
}
+215
View File
@@ -0,0 +1,215 @@
package certproof
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/tpm"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
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.
type Config struct {
Dir string
PKCS11 PKCS11Config
}
func (c Config) dir() string {
if c.Dir != "" {
return c.Dir
}
return StoreDir()
}
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
type FileStore struct {
dir string
}
func NewFileStore(dir string) *FileStore {
return &FileStore{dir: dir}
}
func StoreDir() string {
if dir := os.Getenv(StoreDirEnv); dir != "" {
return dir
}
return defaultStoreDir
}
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
paths, err := certFiles(s.dir)
if err != nil {
return nil, err
}
var candidates []Candidate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
if signer == nil {
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
continue
}
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
}
return candidates, nil
}
// certFiles lists the certificate files in dir, none when the directory does not exist.
func certFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
}
var paths []string
for _, entry := range entries {
if !entry.IsDir() && isCertFile(entry.Name()) {
paths = append(paths, filepath.Join(dir, entry.Name()))
}
}
return paths, nil
}
// 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)
if err != nil {
return nil, nil, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return nil, nil, err
}
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")
}
return chain, signer, nil
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
return chain, signer, nil
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tpm.KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
}
signer = key
}
}
}
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 "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("private key cannot sign")
}
return signer, nil
}
func isCertFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".pem", ".crt", ".cer":
return true
}
return false
}
// Stores queries several stores and carries on when one fails, so a broken token cannot
// hide the certificates a directory holds. A failure is logged instead of returned
// because Collect treats a store error as "no proofs at all".
type Stores []Store
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
var all []Candidate
for _, store := range s {
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("certificate store %T unavailable: %v", store, err)
continue
}
all = append(all, candidates...)
}
return all, nil
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !darwin && !windows
package certproof
import log "github.com/sirupsen/logrus"
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
func DefaultStore() Store {
return NewFileStore(StoreDir())
}
// 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())
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
return files
}
token, err := NewPKCS11Store(cfg.PKCS11, cfg.dir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
}
return Stores{files, token}
}
@@ -0,0 +1,36 @@
//go:build !darwin && !windows
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStoreWithToken(t *testing.T) {
dir := t.TempDir()
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")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide 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)
require.True(t, ok, "%s joins the token to the PEM directory", name)
require.Len(t, store, 2, name)
token, ok := store[1].(*PKCS11Store)
require.True(t, ok, name)
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
}
}
@@ -0,0 +1,98 @@
package certproof
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"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"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestFileStore_TPMKeyFile(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := t.TempDir()
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))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
}
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
// swtpm started with:
//
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
//
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
// hierarchy, and proves the certificate for it through the regular file store.
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
if os.Getenv(tpm.DeviceEnv) == "" {
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
}
public, private := createTPMKey(t)
keyPEM := tpmtest.EncodePEM(t, public, private)
block, _ := pem.Decode([]byte(keyPEM))
require.NotNil(t, block)
signer, err := tpm.ParseKey(block.Bytes)
require.NoError(t, err)
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
}
func createTPMKey(t *testing.T) (public, private []byte) {
t.Helper()
rwc, err := tpm.Open()
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tss2.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
require.NoError(t, err)
return public, private
}
@@ -0,0 +1,251 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"slices"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
personalStore = "MY"
intermediateStore = "CA"
cryptAcquireSilentFlag = 0x00000040
cryptAcquirePreferNCryptKeyFlag = 0x00020000
certNCryptKeySpec = 0xFFFFFFFF
bcryptPadPSS = 0x00000008
)
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
)
type bcryptPSSPaddingInfo struct {
algID *uint16
salt uint32
}
// DefaultStore is the local machine's personal certificate store, where device
// certificates enrolled through AD or Intune are kept.
func DefaultStore() Store {
return NewSystemStore()
}
// SystemStore yields the identities of a personal certificate store, completing their
// chains from the matching intermediate CA store. Keys are used through CNG and never
// exported.
//
// The location decides whose certificates these are. The local machine store is the one
// a service reads; the current user store lives in the signed-in user's registry hive
// with keys protected against their profile, so it is only readable while running as
// that user.
type SystemStore struct {
location uint32
}
// NewSystemStore reads the local machine store, which is what the daemon uses.
func NewSystemStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
}
// NewUserStore reads the calling user's personal store. It is only useful in a process
// already running as that user, which is what the posture helper is.
func NewUserStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
}
func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
leaves, err := storeCertificates(s.location, personalStore)
if err != nil {
return nil, err
}
intermediates, err := storeCertificates(s.location, intermediateStore)
if err != nil {
return nil, err
}
log.Infof("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
if len(leaves) == 0 {
return nil, nil
}
pool := slices.Concat(intermediates, leaves)
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Infof("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}})
}
return candidates, nil
}
// String names the store location the way the Windows documentation does.
func (s *SystemStore) String() string {
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
return "CurrentUser"
}
return "LocalMachine"
}
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
// at signing time so no handles outlive a call.
type systemStoreSigner struct {
leaf *x509.Certificate
location uint32
}
func (s *systemStoreSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
store, err := openStore(s.location, personalStore)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var signature []byte
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
return false, nil
}
signature, err = signWithContext(ctx, scheme, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the personal store")
}
return signature, nil
}
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
var key uintptr
var keySpec uint32
var callerFree int32
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
if ok == 0 {
return nil, fmt.Errorf("acquire private key: %w", err)
}
if keySpec != certNCryptKeySpec {
if callerFree != 0 {
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
}
return nil, errors.New("legacy CryptoAPI keys are not supported")
}
if callerFree != 0 {
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
}
var padding unsafe.Pointer
var flags uintptr
if scheme == schemeRSAPSSSHA256 {
algID, _ := windows.UTF16PtrFromString("SHA256")
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
}
size, err := ncryptSignHash(key, padding, digest, nil, flags)
if err != nil {
return nil, err
}
signature := make([]byte, size)
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
return nil, err
}
signature = signature[:size]
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
var result uint32
var signaturePtr uintptr
if len(signature) > 0 {
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
}
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
if uint32(status) != 0 {
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
}
return result, nil
}
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
store, err := openStore(location, name)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var certs []*x509.Certificate
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
if err != nil {
log.Warnf("skipping certificate in %s store: %v", name, err)
return false, nil
}
certs = append(certs, cert)
return false, nil
})
return certs, err
}
func openStore(location uint32, name string) (windows.Handle, error) {
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return 0, err
}
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
if err != nil {
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
}
return store, nil
}
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
var ctx *windows.CertContext
for {
next, err := windows.CertEnumCertificatesInStore(store, ctx)
if next == nil {
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil
}
return fmt.Errorf("enumerate certificates: %w", err)
}
ctx = next
if stop, err := fn(ctx); stop || err != nil {
_ = windows.CertFreeCertificateContext(ctx)
return err
}
}
}
func encodedCert(ctx *windows.CertContext) []byte {
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
}
+6
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -675,6 +676,11 @@ 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},
},
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
+18
View File
@@ -37,6 +37,7 @@ import (
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
@@ -170,6 +171,8 @@ type EngineConfig struct {
MTU uint16
CertStore certproof.Config
// for debug bundle generation
ProfileConfig *profilemanager.Config
@@ -1240,6 +1243,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}
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)
@@ -1271,9 +1275,17 @@ 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)
return info
}
@@ -1289,6 +1301,7 @@ func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context)
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
return info
}
}
@@ -2746,6 +2759,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
sortedFiles := slices.Clone(check.Files)
sort.Strings(sortedFiles)
normalized[i] = strings.Join(sortedFiles, "|")
if challenge := check.GetCertificateChallenge(); challenge != nil {
sortedCAs := slices.Clone(challenge.GetCaCertificates())
sort.Strings(sortedCAs)
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
}
}
sort.Strings(normalized)
+26
View File
@@ -1179,6 +1179,32 @@ func Test_CheckFilesEqual(t *testing.T) {
},
expectedBool: true,
},
{
name: "Same files with rotated certificate challenge nonce should return false",
inputChecks1: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a"}},
},
},
inputChecks2: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{2}, CaCertificates: []string{"ca-a"}},
},
},
expectedBool: false,
},
{
name: "Same certificate challenge with CA certificates in different order should return true",
inputChecks1: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a", "ca-b"}}},
},
inputChecks2: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-b", "ca-a"}}},
},
expectedBool: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
+328
View File
@@ -0,0 +1,328 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"errors"
"fmt"
"runtime"
"strings"
"unsafe"
"github.com/ebitengine/purego"
)
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
// this file builds for. The struct layouts below assume that width and natural alignment.
type ulong = uintptr
const (
unavailableInformation = ^ulong(0)
flagOSLockingOK = 0x2
flagRWSession = 0x2
flagSerialSession = 0x4
userTypeUser = 0x1
findBatch = 32
)
type version struct {
major byte
minor byte
}
type attribute struct {
typ ulong
value unsafe.Pointer
len ulong
}
type mechanism struct {
typ ulong
parameter unsafe.Pointer
len ulong
}
type pssParams struct {
hashAlg ulong
mgf ulong
saltLen ulong
}
type tokenInfo struct {
label [32]byte
manufacturerID [32]byte
model [16]byte
serialNumber [16]byte
flags ulong
counters [10]ulong
hardware version
firmware version
utcTime [16]byte
}
type initializeArgs struct {
createMutex uintptr
destroyMutex uintptr
lockMutex uintptr
unlockMutex uintptr
flags ulong
reserved unsafe.Pointer
}
// functionList mirrors CK_FUNCTION_LIST: a CK_VERSION padded to pointer alignment, then
// the PKCS#11 v2.40 entry points in specification order.
type functionList struct {
version version
_ [6]byte
fn [68]uintptr
}
const (
fnInitialize = 0
fnGetSlotList = 4
fnGetTokenInfo = 6
fnOpenSession = 12
fnCloseSession = 13
fnLogin = 18
fnLogout = 19
fnCreateObject = 20
fnGetAttributeValue = 24
fnFindObjectsInit = 26
fnFindObjects = 27
fnFindObjectsFinal = 28
fnSignInit = 42
fnSign = 43
)
// module holds the entry points of one loaded library, bound straight from its
// CK_FUNCTION_LIST.
type module struct {
cInitialize func(args *initializeArgs) ulong
cGetSlotList func(tokenPresent byte, slots *ulong, count *ulong) ulong
cGetTokenInfo func(slot ulong, info *tokenInfo) ulong
cOpenSession func(slot ulong, flags ulong, application unsafe.Pointer, notify uintptr, session *ulong) ulong
cCloseSession func(session ulong) ulong
cLogin func(session ulong, userType ulong, pin *byte, pinLen ulong) ulong
cLogout func(session ulong) ulong
cCreateObject func(session ulong, template *attribute, count ulong, object *ulong) ulong
cGetAttributeValue func(session ulong, object ulong, template *attribute, count ulong) ulong
cFindObjectsInit func(session ulong, template *attribute, count ulong) ulong
cFindObjects func(session ulong, objects *ulong, max ulong, count *ulong) ulong
cFindObjectsFinal func(session ulong) ulong
cSignInit func(session ulong, mech *mechanism, key ulong) ulong
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
}
func load(path string) (driver, 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)
}
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
if err != nil {
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
}
var getFunctionList func(list **functionList) ulong
purego.RegisterFunc(&getFunctionList, symbol)
var list *functionList
if rv := getFunctionList(&list); rv != rvOK || list == nil {
return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)}
}
m := &module{}
for _, entry := range []struct {
fn any
index int
}{
{&m.cInitialize, fnInitialize},
{&m.cGetSlotList, fnGetSlotList},
{&m.cGetTokenInfo, fnGetTokenInfo},
{&m.cOpenSession, fnOpenSession},
{&m.cCloseSession, fnCloseSession},
{&m.cLogin, fnLogin},
{&m.cLogout, fnLogout},
{&m.cCreateObject, fnCreateObject},
{&m.cGetAttributeValue, fnGetAttributeValue},
{&m.cFindObjectsInit, fnFindObjectsInit},
{&m.cFindObjects, fnFindObjects},
{&m.cFindObjectsFinal, fnFindObjectsFinal},
{&m.cSignInit, fnSignInit},
{&m.cSign, fnSign},
} {
if list.fn[entry.index] == 0 {
return nil, fmt.Errorf("%s lacks PKCS#11 entry point %d", path, entry.index)
}
purego.RegisterFunc(entry.fn, list.fn[entry.index])
}
args := &initializeArgs{flags: flagOSLockingOK}
if rv := m.cInitialize(args); rv != rvOK && rv != rvAlreadyInitialized {
return nil, Error{Op: "C_Initialize", Code: uint(rv)}
}
return m, nil
}
func (m *module) tokens() ([]Token, error) {
var count ulong
if rv := m.cGetSlotList(1, nil, &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
if count == 0 {
return nil, nil
}
slots := make([]ulong, count)
if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
tokens := make([]Token, 0, count)
for _, slot := range slots[:count] {
var info tokenInfo
if rv := m.cGetTokenInfo(slot, &info); rv != rvOK {
continue
}
tokens = append(tokens, Token{Slot: uint(slot), Label: strings.TrimRight(string(info.label[:]), " \x00")})
}
return tokens, nil
}
func (m *module) openSession(slot uint, readWrite bool) (uint, error) {
flags := ulong(flagSerialSession)
if readWrite {
flags |= flagRWSession
}
var session ulong
if rv := m.cOpenSession(ulong(slot), flags, nil, 0, &session); rv != rvOK {
return 0, Error{Op: "C_OpenSession", Code: uint(rv)}
}
return uint(session), nil
}
func (m *module) closeSession(session uint) {
m.cCloseSession(ulong(session))
}
func (m *module) login(session uint, pin []byte) error {
var pinPtr *byte
if len(pin) > 0 {
pinPtr = &pin[0]
}
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
runtime.KeepAlive(pin)
if rv != rvOK && rv != rvUserAlreadyLoggedIn {
return Error{Op: "C_Login", Code: uint(rv)}
}
return nil
}
func (m *module) logout(session uint) {
m.cLogout(ulong(session))
}
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
attrs := toAttributes(template)
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
runtime.KeepAlive(template)
if rv != rvOK {
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
}
defer m.cFindObjectsFinal(ulong(session))
var objects []Object
for {
var batch [findBatch]ulong
var count ulong
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
}
for _, handle := range batch[:count] {
objects = append(objects, Object(handle))
}
if count < findBatch {
return objects, nil
}
}
}
func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
attr := attribute{typ: ulong(typ)}
if rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1); rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len == unavailableInformation {
return nil, fmt.Errorf("attribute 0x%x is unavailable", typ)
}
if attr.len == 0 {
return nil, nil
}
value := make([]byte, attr.len)
attr.value = unsafe.Pointer(&value[0])
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
runtime.KeepAlive(value)
if rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
return value[:attr.len], nil
}
func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("nothing to sign")
}
native := mechanism{typ: ulong(mech.Type)}
var params *pssParams
if mech.PSS != nil {
params = &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
native.parameter = unsafe.Pointer(params)
native.len = ulong(unsafe.Sizeof(*params))
}
rv := m.cSignInit(ulong(session), &native, ulong(key))
runtime.KeepAlive(params)
if rv != rvOK {
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
}
var size ulong
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
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)}
}
return signature[:size], nil
}
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
attrs := toAttributes(template)
var object ulong
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
runtime.KeepAlive(template)
if rv != rvOK {
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
}
return Object(object), nil
}
func toAttributes(template []Attribute) []attribute {
attrs := make([]attribute, len(template))
for i, a := range template {
attrs[i].typ = ulong(a.Type)
if len(a.Value) > 0 {
attrs[i].value = unsafe.Pointer(&a.Value[0])
attrs[i].len = ulong(len(a.Value))
}
}
return attrs
}
func first(attrs []attribute) *attribute {
if len(attrs) == 0 {
return nil
}
return &attrs[0]
}
@@ -0,0 +1,72 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"crypto/x509"
"os"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStructLayoutsMatchTheCABI(t *testing.T) {
assert.Equal(t, uintptr(24), unsafe.Sizeof(attribute{}), "CK_ATTRIBUTE")
assert.Equal(t, uintptr(24), unsafe.Sizeof(mechanism{}), "CK_MECHANISM")
assert.Equal(t, uintptr(24), unsafe.Sizeof(pssParams{}), "CK_RSA_PKCS_PSS_PARAMS")
assert.Equal(t, uintptr(208), unsafe.Sizeof(tokenInfo{}), "CK_TOKEN_INFO")
assert.Equal(t, uintptr(48), unsafe.Sizeof(initializeArgs{}), "CK_C_INITIALIZE_ARGS")
assert.Equal(t, uintptr(8), unsafe.Offsetof(functionList{}.fn), "entry points follow the padded CK_VERSION")
assert.Equal(t, uintptr(8+68*8), unsafe.Sizeof(functionList{}), "CK_FUNCTION_LIST v2.40")
}
// TestTrustModule_ListsSystemCertificates drives a real module through the binding:
// p11-kit's trust module exposes the system CA store as certificate objects with no login.
func TestTrustModule_ListsSystemCertificates(t *testing.T) {
module := loadFirst(t,
"/usr/lib/pkcs11/p11-kit-trust.so",
"/usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib/aarch64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib64/pkcs11/p11-kit-trust.so",
)
tokens, err := module.Tokens()
require.NoError(t, err)
require.NotEmpty(t, tokens, "the trust module must present at least one token")
parsed := 0
for _, token := range tokens {
session, err := module.OpenSession(token.Label, nil)
require.NoError(t, err, token.Label)
objects, err := session.FindObjects(
Attribute{Type: AttrClass, Value: ULong(ClassCertificate)},
Attribute{Type: AttrCertificateType, Value: ULong(CertificateX509)},
)
require.NoError(t, err, token.Label)
for _, object := range objects {
der, err := session.Attribute(object, AttrValue)
require.NoError(t, err)
_, err = x509.ParseCertificate(der)
require.NoError(t, err, "CKA_VALUE must be a DER certificate")
parsed++
}
session.Close()
}
assert.Positive(t, parsed, "system trust anchors must be readable through the binding")
}
func loadFirst(t *testing.T, paths ...string) *Module {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
module, err := Load(path)
require.NoError(t, err, path)
return module
}
t.Skip("p11-kit trust module not installed")
return nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !(pkcs11 && linux && (amd64 || arm64))
package pkcs11
func load(string) (driver, error) {
return nil, ErrUnsupported
}
+259
View File
@@ -0,0 +1,259 @@
// Package pkcs11 is a minimal PKCS#11 client. It loads a module at runtime without cgo,
// opens a token session, lists objects and signs with keys the token holds. It exists so
// certificates whose keys live in a TPM behind tpm2-pkcs11 can be proven; whatever a
// certificate store does not need is left out.
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"sync"
)
// Object classes, attribute types, mechanisms and generators from PKCS#11 v2.40.
const (
ClassCertificate = 0x1
ClassPublicKey = 0x2
ClassPrivateKey = 0x3
CertificateX509 = 0x0
AttrClass = 0x0
AttrToken = 0x1
AttrLabel = 0x3
AttrValue = 0x11
AttrCertificateType = 0x80
AttrKeyType = 0x100
AttrSubject = 0x101
AttrID = 0x102
AttrModulus = 0x120
AttrPublicExponent = 0x122
AttrECParams = 0x180
AttrECPoint = 0x181
KeyRSA = 0x0
KeyEC = 0x3
MechRSAPKCSPSS = 0xd
MechSHA256 = 0x250
MechSHA384 = 0x260
MechECDSA = 0x1041
MGF1SHA256 = 0x2
MGF1SHA384 = 0x3
rvOK = 0x0
rvUserAlreadyLoggedIn = 0x100
rvAlreadyInitialized = 0x191
)
var ErrUnsupported = errors.New("PKCS#11 modules need a build with the pkcs11 tag on linux/amd64 or linux/arm64")
// Error is a PKCS#11 return value other than CKR_OK.
type Error struct {
Op string
Code uint
}
func (e Error) Error() string {
if name, ok := returnValueNames[e.Code]; ok {
return fmt.Sprintf("%s: %s", e.Op, name)
}
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
}
var returnValueNames = map[uint]string{
0x2: "CKR_HOST_MEMORY",
0x3: "CKR_SLOT_ID_INVALID",
0x5: "CKR_GENERAL_ERROR",
0x7: "CKR_ARGUMENTS_BAD",
0x12: "CKR_ATTRIBUTE_TYPE_INVALID",
0x13: "CKR_ATTRIBUTE_VALUE_INVALID",
0x30: "CKR_DEVICE_ERROR",
0x54: "CKR_FUNCTION_NOT_SUPPORTED",
0x68: "CKR_KEY_FUNCTION_NOT_PERMITTED",
0x70: "CKR_MECHANISM_INVALID",
0x71: "CKR_MECHANISM_PARAM_INVALID",
0x82: "CKR_OBJECT_HANDLE_INVALID",
0xa0: "CKR_PIN_INCORRECT",
0xa4: "CKR_PIN_LOCKED",
0xb3: "CKR_SESSION_HANDLE_INVALID",
0xd0: "CKR_TEMPLATE_INCOMPLETE",
0xd1: "CKR_TEMPLATE_INCONSISTENT",
0xe0: "CKR_TOKEN_NOT_PRESENT",
0x101: "CKR_USER_NOT_LOGGED_IN",
0x150: "CKR_BUFFER_TOO_SMALL",
0x190: "CKR_CRYPTOKI_NOT_INITIALIZED",
}
// Attribute is one entry of a PKCS#11 template. Integer-valued attributes such as the
// object class are encoded with ULong.
type Attribute struct {
Type uint
Value []byte
}
// Mechanism selects a signing algorithm. PSS carries the parameters CKM_RSA_PKCS_PSS needs.
type Mechanism struct {
Type uint
PSS *PSSParams
}
type PSSParams struct {
Hash uint
MGF uint
SaltLen uint
}
// Object is a handle the token issued for one of its objects.
type Object uint
// Token is a slot with a token present.
type Token struct {
Slot uint
Label string
}
// Module is a loaded and initialised PKCS#11 library. A module is loaded once per path
// and never finalised: tokens such as tpm2-pkcs11 do real work in C_Initialize, and the
// process exit releases everything anyway.
type Module struct {
d driver
}
var (
modulesMu sync.Mutex
modules = map[string]*Module{}
)
// Load opens the shared library at path and initialises it, or returns the module already
// loaded from that path.
func Load(path string) (*Module, error) {
modulesMu.Lock()
defer modulesMu.Unlock()
if m, ok := modules[path]; ok {
return m, nil
}
d, err := load(path)
if err != nil {
return nil, err
}
m := &Module{d: d}
modules[path] = m
return m, nil
}
func (m *Module) Tokens() ([]Token, error) {
return m.d.tokens()
}
// OpenSession opens a read-only session with the token carrying label, or with the first
// token when label is empty, and logs in as the user when pin is not nil. An empty,
// non-nil pin still logs in.
func (m *Module) OpenSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, false)
}
// OpenReadWriteSession is OpenSession for callers that create objects on the token.
func (m *Module) OpenReadWriteSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, true)
}
func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session, error) {
token, err := m.token(label)
if err != nil {
return nil, err
}
handle, err := m.d.openSession(token.Slot, readWrite)
if err != nil {
return nil, err
}
s := &Session{d: m.d, handle: handle}
if pin == nil {
return s, nil
}
if err := m.d.login(handle, pin); err != nil {
s.Close()
return nil, err
}
s.loggedIn = true
return s, nil
}
func (m *Module) token(label string) (Token, error) {
tokens, err := m.Tokens()
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if label == "" || token.Label == label {
return token, nil
}
}
if label == "" {
return Token{}, errors.New("no token present")
}
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.
type Session struct {
d driver
handle uint
loggedIn bool
}
func (s *Session) Close() {
if s.loggedIn {
s.d.logout(s.handle)
}
s.d.closeSession(s.handle)
}
// FindObjects returns the handles of every object matching all attributes of template.
func (s *Session) FindObjects(template ...Attribute) ([]Object, error) {
return s.d.findObjects(s.handle, template)
}
// Attribute reads one attribute of an object.
func (s *Session) Attribute(obj Object, typ uint) ([]byte, error) {
return s.d.attribute(s.handle, obj, typ)
}
// Sign signs data, normally a digest, with the token-held key in a single operation.
func (s *Session) Sign(mech Mechanism, key Object, data []byte) ([]byte, error) {
return s.d.sign(s.handle, mech, key, data)
}
// CreateObject stores a new object described by template on the token.
func (s *Session) CreateObject(template ...Attribute) (Object, error) {
return s.d.createObject(s.handle, template)
}
type driver interface {
tokens() ([]Token, error)
openSession(slot uint, readWrite bool) (uint, error)
closeSession(session uint)
login(session uint, pin []byte) error
logout(session uint)
findObjects(session uint, template []Attribute) ([]Object, error)
attribute(session uint, obj Object, typ uint) ([]byte, error)
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
createObject(session uint, template []Attribute) (Object, error)
}
// ulongSize is the width of CK_ULONG on the 64-bit platforms the driver builds for.
const ulongSize = 8
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
func ULong(v uint) []byte {
return binary.NativeEndian.AppendUint64(nil, uint64(v))
}
func ulongValue(b []byte) (uint, error) {
if len(b) != ulongSize {
return 0, fmt.Errorf("CK_ULONG value has %d bytes", len(b))
}
return uint(binary.NativeEndian.Uint64(b)), nil
}
+95
View File
@@ -0,0 +1,95 @@
package pkcs11
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"math"
"math/big"
)
var curvesByOID = map[string]elliptic.Curve{
"1.2.840.10045.3.1.7": elliptic.P256(),
"1.3.132.0.34": elliptic.P384(),
"1.3.132.0.35": elliptic.P521(),
}
// PublicKey reads a CKO_PUBLIC_KEY object as a Go public key. RSA and EC keys are
// supported, the two kinds a certificate posture proof can be signed with.
func (s *Session) PublicKey(obj Object) (crypto.PublicKey, error) {
raw, err := s.Attribute(obj, AttrKeyType)
if err != nil {
return nil, err
}
keyType, err := ulongValue(raw)
if err != nil {
return nil, fmt.Errorf("CKA_KEY_TYPE: %w", err)
}
switch keyType {
case KeyRSA:
modulus, exponent, err := s.attributes(obj, AttrModulus, AttrPublicExponent)
if err != nil {
return nil, err
}
return rsaPublicKey(modulus, exponent)
case KeyEC:
params, point, err := s.attributes(obj, AttrECParams, AttrECPoint)
if err != nil {
return nil, err
}
return ecPublicKey(params, point)
}
return nil, fmt.Errorf("unsupported key type 0x%x", keyType)
}
func (s *Session) attributes(obj Object, first, second uint) ([]byte, []byte, error) {
a, err := s.Attribute(obj, first)
if err != nil {
return nil, nil, err
}
b, err := s.Attribute(obj, second)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) {
e := new(big.Int).SetBytes(exponent)
if e.Sign() <= 0 || e.Cmp(big.NewInt(math.MaxInt32)) > 0 {
return nil, errors.New("CKA_PUBLIC_EXPONENT is out of range")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: int(e.Int64())}, nil
}
// ecPublicKey decodes CKA_EC_PARAMS, the named curve OID, and CKA_EC_POINT, the
// uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare.
func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
var oid asn1.ObjectIdentifier
if _, err := asn1.Unmarshal(params, &oid); err != nil {
return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err)
}
curve, ok := curvesByOID[oid.String()]
if !ok {
return nil, fmt.Errorf("unsupported curve %s", oid)
}
size := (curve.Params().BitSize + 7) / 8
raw := point
if len(raw) != 1+2*size {
if _, err := asn1.Unmarshal(point, &raw); err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
}
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
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/asn1"
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestECPublicKey(t *testing.T) {
curves := []struct {
name string
curve elliptic.Curve
oid asn1.ObjectIdentifier
}{
{"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}},
{"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}},
}
for _, tt := range curves {
t.Run(tt.name, func(t *testing.T) {
key, err := ecdsa.GenerateKey(tt.curve, rand.Reader)
require.NoError(t, err)
params, err := asn1.Marshal(tt.oid)
require.NoError(t, err)
point := uncompressedPoint(key)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
// PKCS#11 wraps the point in an OCTET STRING, but some modules return it bare.
for form, encoded := range map[string][]byte{"DER octet string": wrapped, "bare point": point} {
pub, err := ecPublicKey(params, encoded)
require.NoError(t, err, form)
assert.True(t, key.PublicKey.Equal(pub), "%s must decode to the generated key", form)
}
})
}
}
func TestECPublicKey_Rejections(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
p256, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7})
require.NoError(t, err)
brainpool, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 36, 3, 3, 2, 8, 1, 1, 7})
require.NoError(t, err)
point := uncompressedPoint(key)
_, err = ecPublicKey(brainpool, point)
assert.Error(t, err, "curves the proof cannot use must be rejected")
_, err = ecPublicKey(p256, point[:len(point)-1])
assert.Error(t, err, "a truncated point must be rejected")
_, err = ecPublicKey([]byte("junk"), point)
assert.Error(t, err, "malformed parameters must be rejected")
}
func TestRSAPublicKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pub, err := rsaPublicKey(key.N.Bytes(), big.NewInt(int64(key.E)).Bytes())
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(pub), "modulus and exponent must decode to the generated key")
_, err = rsaPublicKey(key.N.Bytes(), nil)
assert.Error(t, err, "a missing exponent must be rejected")
}
func TestULongRoundTrip(t *testing.T) {
v, err := ulongValue(ULong(ClassPrivateKey))
require.NoError(t, err)
assert.Equal(t, uint(ClassPrivateKey), v)
_, err = ulongValue([]byte{1, 2, 3})
assert.Error(t, err, "a value of the wrong width must be rejected")
}
func uncompressedPoint(key *ecdsa.PrivateKey) []byte {
size := (key.Curve.Params().BitSize + 7) / 8
point := append([]byte{4}, key.X.FillBytes(make([]byte, size))...)
return append(point, key.Y.FillBytes(make([]byte, size))...)
}
+98
View File
@@ -0,0 +1,98 @@
package pkcs11
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
)
// DefaultModule is p11-kit's proxy, which exposes every module the system has registered,
// tpm2-pkcs11 included, so a URI without module-path works on a stock p11-kit setup.
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.
type URI struct {
Token string
ModulePath string
pinValue *string
pinSource string
}
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:")
}
path, query, _ := strings.Cut(rest, "?")
u := &URI{}
if err := eachAttribute(path, ";", func(name, value string) {
if name == "token" {
u.Token = value
}
}); 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 {
return nil, err
}
return u, nil
}
func eachAttribute(list, sep string, fn func(name, value string)) error {
if list == "" {
return nil
}
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)
}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
}
fn(name, value)
}
return nil
}
// Module is the library to load, DefaultModule when the URI names none.
func (u *URI) Module() string {
if u.ModulePath == "" {
return DefaultModule
}
return u.ModulePath
}
// 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) {
if u.pinValue != nil {
return []byte(*u.pinValue), nil
}
if u.pinSource == "" {
return nil, nil
}
path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:")
pin, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read PIN: %w", err)
}
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseURI(t *testing.T) {
tests := []struct {
name string
raw string
wantToken string
wantModule string
wantPIN []byte
}{
{
name: "token with module path and pin value",
raw: "pkcs11:token=netbird?module-path=/usr/lib/libtpm2_pkcs11.so&pin-value=1234",
wantToken: "netbird",
wantModule: "/usr/lib/libtpm2_pkcs11.so",
wantPIN: []byte("1234"),
},
{
name: "module name becomes a library file",
raw: "pkcs11:token=netbird?module-name=tpm2_pkcs11",
wantToken: "netbird",
wantModule: "libtpm2_pkcs11.so",
},
{
name: "percent encoding and unknown attributes",
raw: "pkcs11:model=SoftHSM%20v2;token=my%20token;serial=1?max-sessions=1",
wantToken: "my token",
wantModule: DefaultModule,
},
{
name: "bare scheme uses the p11-kit proxy and no login",
raw: "pkcs11:",
wantModule: DefaultModule,
},
{
name: "empty pin value still logs in",
raw: "pkcs11:?pin-value=",
wantModule: DefaultModule,
wantPIN: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uri, err := ParseURI(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.wantToken, uri.Token, "token label")
assert.Equal(t, tt.wantModule, uri.Module(), "module to load")
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
})
}
}
func TestParseURI_Rejections(t *testing.T) {
for _, raw := range []string{"pkcs11", "https://example.com", "pkcs11:token", "pkcs11:token=%zz"} {
_, err := ParseURI(raw)
assert.Error(t, err, raw)
}
}
func TestURI_PINFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "pin")
require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600))
for _, source := range []string{path, "file:" + path, "file://" + path} {
uri, err := ParseURI("pkcs11:token=netbird?pin-source=" + source)
require.NoError(t, err)
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, []byte("secret"), pin, "PIN from %s must drop the trailing newline", source)
}
uri, err := ParseURI("pkcs11:?pin-source=" + filepath.Join(t.TempDir(), "missing"))
require.NoError(t, err)
_, err = uri.PIN()
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
}
+14
View File
@@ -185,6 +185,20 @@ 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
// 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
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
// Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"`
+29
View File
@@ -0,0 +1,29 @@
package tpm
import (
"errors"
"fmt"
"io"
"os"
"github.com/google/go-tpm/tpmutil"
)
// The kernel resource manager multiplexes clients and flushes what they leave behind,
// so it is tried before the raw device.
var devicePaths = []string{"/dev/tpmrm0", "/dev/tpm0"}
func open() (io.ReadWriteCloser, error) {
if path := os.Getenv(DeviceEnv); path != "" {
return tpmutil.OpenTPM(path)
}
var errs error
for _, path := range devicePaths {
rwc, err := tpmutil.OpenTPM(path)
if err == nil {
return rwc, nil
}
errs = errors.Join(errs, err)
}
return nil, fmt.Errorf("open TPM: %w", errs)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package tpm
import "io"
func open() (io.ReadWriteCloser, error) {
return nil, ErrUnsupported
}
+61
View File
@@ -0,0 +1,61 @@
package tpm
import (
"crypto"
"errors"
"fmt"
"io"
"go.step.sm/crypto/tpm/tss2"
)
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
// draft-bottomley-tpm2-keys and written by tpm2-openssl and tpm2-tss-engine.
const KeyPEMType = "TSS2 PRIVATE KEY"
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
// 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.
func ParseKey(der []byte) (crypto.Signer, error) {
key, err := tss2.ParsePrivateKey(der)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
}
if !key.EmptyAuth {
return nil, ErrKeyNeedsAuth
}
public, err := key.Public()
if err != nil {
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
}
return &keySigner{key: key, public: public}, nil
}
type keySigner struct {
key *tss2.TPMKey
public crypto.PublicKey
}
func (s *keySigner) Public() crypto.PublicKey {
return s.public
}
func (s *keySigner) Sign(rand 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)
if err != nil {
return nil, fmt.Errorf("load TSS2 key: %w", err)
}
signer.SetSRKTemplate(tss2.ECCSRKTemplate)
return signer.Sign(rand, digest, opts)
}
+64
View File
@@ -0,0 +1,64 @@
package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/pem"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
key := newP256Key(t)
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey)))
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(signer.Public()), "signer must expose the key the TPM holds")
}
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)))
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
}
func TestParseKey_RejectsMalformedKey(t *testing.T) {
_, err := ParseKey([]byte("not a TSS2 key"))
assert.Error(t, err)
}
func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
t.Setenv(DeviceEnv, filepath.Join(t.TempDir(), "missing"))
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &newP256Key(t).PublicKey)))
require.NoError(t, err)
digest := sha256.Sum256([]byte("challenge"))
_, err = signer.Sign(rand.Reader, digest[:], crypto.SHA256)
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
}
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func decodePEM(t *testing.T, pemData string) []byte {
t.Helper()
block, _ := pem.Decode([]byte(pemData))
require.NotNil(t, block)
require.Equal(t, KeyPEMType, block.Type)
return block.Bytes
}
+19
View File
@@ -0,0 +1,19 @@
// Package tpm is the client's one door to the platform TPM 2.0. It opens the device
// and turns TPM-held key files into signers; every operation opens the TPM, runs and
// closes it, so no handle outlives a call.
package tpm
import (
"errors"
"io"
)
// DeviceEnv overrides the TPM device path, which also lets tests point at a swtpm socket.
const DeviceEnv = "NB_TPM_DEVICE"
var ErrUnsupported = errors.New("TPM is not supported on this platform")
// Open connects to the platform TPM 2.0. The caller closes it after one operation.
func Open() (io.ReadWriteCloser, error) {
return open()
}
+48
View File
@@ -0,0 +1,48 @@
// Package tpmtest builds TSS2 key files for tests, with or without a TPM behind them.
package tpmtest
import (
"crypto/ecdsa"
"crypto/elliptic"
"testing"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
)
const p256Bytes = 32
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
// scheme, the shape tpm2-openssl creates certificate keys in.
func SigningTemplate() tpm2.Public {
return tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagSign | tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{CurveID: tpm2.CurveNISTP256},
}
}
// 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 {
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()
require.NoError(t, err)
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 {
t.Helper()
pemBytes, err := tss2.New(public, private, opts...).EncodeToMemory()
require.NoError(t, err)
return string(pemBytes)
}