mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 15:39:07 +02:00
[client, management] implement certificate posture check (#7535)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
co-authored by
mlsmaycon
parent
a5834fdaab
commit
53a14551c8
@@ -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. |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(pkcs11 && linux && (amd64 || arm64))
|
||||
|
||||
package pkcs11
|
||||
|
||||
func load(string) (driver, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package tpm
|
||||
|
||||
import "io"
|
||||
|
||||
func open() (io.ReadWriteCloser, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user