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

Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
Pascal Fischer
2026-10-09 14:57:00 +02:00
committed by GitHub
co-authored by mlsmaycon
parent a5834fdaab
commit 53a14551c8
78 changed files with 7028 additions and 1382 deletions
+3 -1
View File
@@ -363,7 +363,9 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
path: |
dist/netbird_linux**
dist/netbird-pkcs11_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
+24 -2
View File
@@ -40,7 +40,28 @@ builds:
tags:
- load_wgnt_from_rsrc
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch.
# deb and rpm packages target glibc distributions, so they carry the PKCS#11 store, which
# links libc. Tarballs and the Alpine-based images keep the static build above.
- id: netbird-pkcs11
dir: client
binary: netbird
env: [CGO_ENABLED=0]
goos:
- linux
goarch:
- arm
- amd64
- arm64
- 386
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- load_wgnt_from_rsrc
- pkcs11
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. They
# carry the PKCS#11 store like the deb build above.
- &netbird_rpm_build
id: netbird-rpm-amd64
dir: client
@@ -53,6 +74,7 @@ builds:
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- load_wgnt_from_rsrc
- pkcs11
- <<: *netbird_rpm_build
id: netbird-rpm-arm64
@@ -268,7 +290,7 @@ nfpms:
id: netbird_deb
bindir: /usr/bin
builds:
- netbird
- netbird-pkcs11
formats:
- deb
scripts:
+30
View File
@@ -0,0 +1,30 @@
package cmd
import (
"github.com/spf13/cobra"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/certproof"
)
var postureCmd = &cobra.Command{
Use: "posture",
Short: "Posture helpers invoked by the NetBird daemon",
Hidden: true,
}
var postureCertProofCmd = &cobra.Command{
Use: "cert-proof",
Short: "Answer certificate posture challenges from the calling user's keychain",
Long: "Reads a certificate challenge set as JSON on stdin and writes the proofs as JSON on stdout.\n" +
"The daemon launches this in the console user's desktop session, because a login keychain\n" +
"cannot be reached from a root daemon. Not intended to be run by hand.",
Hidden: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
// Proofs travel on stdout, so every log line has to go elsewhere.
log.SetOutput(cmd.ErrOrStderr())
return certproof.RunHelper(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout())
},
}
+3
View File
@@ -180,6 +180,9 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(postureCmd)
postureCmd.AddCommand(postureCertProofCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
+277
View File
@@ -0,0 +1,277 @@
# Certificate posture proofs
A peer answers a management certificate challenge by signing the challenge nonce with a
private key it holds, and sending back the certificate chain. Management verifies the
chain against the CAs configured on the check and verifies the signature, which proves
the peer holds the key rather than merely a copy of the certificate.
The signature covers `netbird-posture-cert-v1 || nonce || peerKey`, so a proof is bound
to one WireGuard peer key and cannot be replayed by another peer.
## Where certificates come from
| Platform | Store | Read by |
| --- | --- | --- |
| macOS | System keychain | the daemon, directly |
| macOS | console user's login keychain | a helper in that user's desktop session |
| Windows | `LocalMachine\MY` | the service, directly |
| Windows | signed-in user's `CurrentUser\MY` | a helper launched with that session's token |
| Linux and others | PEM directory: `CertStoreDir` in the profile config, else `NB_CERT_STORE_DIR`, else `/etc/netbird/certs` | the daemon, directly |
| Linux | a `TSS2 PRIVATE KEY` file in that directory, signed by the TPM | the daemon, through `/dev/tpmrm0` |
| Linux | a PKCS#11 token, tpm2-pkcs11 for one, enabled by `CertPKCS11PIN` in the profile config | the daemon, through the token's module, in builds with the `pkcs11` tag |
macOS and Windows both keep per-user certificates out of reach of a privileged daemon,
and both are handled the same way: the daemon reads the machine store itself and
launches `netbird posture cert-proof` as the signed-in user for the rest. Only the
signature and the chain come back. The helper, the request and response types and the
subcommand are shared; only the way the child is launched differs.
## macOS: why the daemon cannot read a login keychain
The daemon runs as root from a LaunchDaemon. Its keychain search list is the System
keychain, which is where MDM installs device identities, and nothing else. A user's
login keychain is out of reach for reasons that are not about privilege:
- `login.keychain-db` is unlocked by `securityd` **in the user's session**. The daemon
lives in a different Mach bootstrap namespace, so from where it stands the keychain is
locked no matter which uid it runs as.
- Every private key carries an ACL naming the applications allowed to use it. A process
that is not listed causes a consent prompt *in the user's session*. A daemon has no
session to show one in, so it receives `errSecInteractionNotAllowed (-25308)` instead.
Dropping to the user's uid with `SysProcAttr.Credential` does **not** fix this: uid is
not what selects the securityd instance, the bootstrap namespace is. The process has to
enter the user's session, which is what `launchctl asuser` does.
## macOS: the console user helper
When a certificate challenge arrives and the daemon is root, it:
1. Reads the System keychain itself, so MDM device identities are answered with no user
session involved.
2. Resolves the console user with `SCDynamicStoreCopyConsoleUser`.
3. Launches itself as that user with
`launchctl asuser <uid> sudo -u <user> -H netbird posture cert-proof`, writing the
challenges to the child's stdin as JSON and reading proofs from its stdout.
4. Merges both sets of proofs, dropping a leaf that both keychains hold.
The child runs `RunHelper`, which uses the ordinary `KeychainStore` — inside the user's
session it simply works. **The private key never crosses the boundary; only the
signature and the certificate chain come back.**
`-H` matters: it sets `HOME`, which is how the login keychain path is resolved.
`netbird posture cert-proof` is hidden and not meant to be run by hand. It writes proofs
to stdout and every log line to stderr, so stdout stays parseable.
## Windows: the service and the signed-in user
`LocalMachine\MY` is what the service reads, and it is where AD and Intune enrol device
certificates. `CurrentUser\MY` lives in the signed-in user's registry hive with private
keys protected by DPAPI against their profile, so it is only readable while running as
that user.
The failure mode differs from macOS in an important way: a service that opens
`CURRENT_USER` does **not** get an error. "Current user" resolves to the service
account's own hive, `HKU\S-1-5-18`, so it silently reads an empty and irrelevant store.
There is nothing to log. That is why the service only ever opens `LocalMachine` and asks
a helper for the rest.
Windows does let a privileged service assume a user identity, which macOS does not for
keychains, so no external tooling is involved:
```go
windows.WTSQueryUserToken(session, &token)
cmd.SysProcAttr = &syscall.SysProcAttr{Token: syscall.Token(token), CreationFlags: windows.CREATE_NO_WINDOW}
```
`CREATE_NO_WINDOW` matters: without it a console window flashes on the user's desktop on
every sync.
Session selection prefers the physical console, then falls back to any active session,
so remote desktop and VDI hosts work. `WTSQueryUserToken` needs `SE_TCB_NAME`, which
LocalSystem holds and an ordinary process does not, so a user-run `netbird up` skips the
helper and reads the machine store alone.
In-process impersonation would also work, but it is per-OS-thread while goroutines
migrate freely, so it would need `runtime.LockOSThread` around every key operation. The
child process avoids that class of bug entirely.
Unlike macOS, the Windows store acquires keys with `CRYPT_ACQUIRE_SILENT_FLAG`, so a key
that would need a prompt fails immediately instead of blocking. That also means a
smartcard PIN can never be satisfied this way.
## Linux: keys held by the TPM
Enrollment tooling on Linux keeps a TPM-resident key as a `TSS2 PRIVATE KEY` PEM file,
the format of draft-bottomley-tpm2-keys that tpm2-openssl, tpm2-tss-engine and
`tpm2_encodeobject` write. The file holds the key wrapped by its parent; the TPM is the
only thing that can use it. Drop it next to the certificate as usual:
```
openssl genpkey -provider tpm2 -algorithm EC -pkeyopt group:P-256 -out /etc/netbird/certs/device.key
openssl req -provider tpm2 -provider default -new -key /etc/netbird/certs/device.key -subj /CN=device -out device.csr
```
Sign the CSR with the organisation CA and store the result as `device.pem`. The store
parses the key file without touching the TPM, so the certificate is listed as a
candidate like any other, and every signature opens `/dev/tpmrm0`, loads the key under
its parent, signs, flushes and closes again. `NB_TPM_DEVICE` overrides the device path.
What the key file may look like:
- **Parent.** A persistent handle such as `0x81000001` is used as is. The owner
hierarchy, which both tpm2-openssl and tpm2-tss-engine default to, means the key was
created under a transient primary from the TCG default ECC P-256 template, and that
same primary is derived again before loading.
- **No authorization value.** A key created with a password needs someone to type it,
which the daemon cannot arrange, so the certificate is skipped with a log line rather
than blocking on a TPM auth failure.
- **RSA-2048 or P-256, sometimes P-384.** Those are what the PC Client profile requires
of a TPM; P-384 depends on the chip. The TPM chooses the RSA-PSS salt itself, which is
why management verifies PSS proofs with `rsa.PSSSaltLengthAuto`.
Windows needs none of this: a certificate enrolled into the TPM sits behind the Microsoft
Platform Crypto Provider and the CNG path above signs with it unchanged. macOS has no
TPM; its Secure Enclave keys are reachable only through the keychain path.
To exercise the path without hardware, run a software TPM and point the end-to-end test
at it:
```
swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
NB_TPM_DEVICE=/tmp/swtpm.sock go test ./client/internal/certproof/ -run TestCollect_TPMKeyEndToEnd -v
```
## Linux: keys behind a PKCS#11 token
Distributions that follow Red Hat's guidance reach the TPM through tpm2-pkcs11, a PKCS#11
module whose token holds both the key and, after `tpm2_ptool addcert`, the certificate.
The store reads that token when the profile config, `/etc/netbird/config.json` by default,
carries the token's user PIN:
```json
"CertPKCS11PIN": "1234"
```
That alone opens the first token the p11-kit proxy exposes, which is tpm2-pkcs11 on a
stock setup that has registered it. `CertPKCS11URI`, an RFC 7512 URI, narrows that down
on a host with several tokens or without p11-kit:
```json
"CertPKCS11URI": "pkcs11:token=netbird?module-path=/usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so"
```
`token` selects the token by label, or the first token present when absent. `module-path`
names the library to load; `module-name=tpm2_pkcs11` resolves to `libtpm2_pkcs11.so` on
the loader's search path, and with neither the p11-kit proxy is loaded, which exposes every
module the system has registered. The URI may carry the PIN itself, as `pin-value` inline
or `pin-source` naming a file, and `CertPKCS11PIN` takes precedence over both. Without any
PIN no login happens, and tpm2-pkcs11 then shows no private keys at all. Every other
attribute is ignored.
The certificate may live on the token or in the PEM directory: `CertStoreDir` in the
profile config, else `NB_CERT_STORE_DIR`, else `/etc/netbird/certs`. On the token,
certificates and private keys are paired by `CKA_ID`,
which is what `tpm2_ptool addcert` and `pkcs11-tool` set. In the directory, a certificate
file without a key of its own is paired with the token key whose public key it carries, so
`device.pem` alone next to a key that only the TPM holds is enough; the token's public key
object, which `tpm2_ptool addkey` and `import` create alongside the private one, is what
the store compares against. Chains are completed from the certificates on the token and in
the directory together, so intermediates may sit in either place.
Each operation opens a session, logs in, works, logs out and closes, so no token handle
outlives a call, and the PEM directory keeps working when the token does not: the two are
queried together and a failing token is logged rather than hiding file certificates.
Two consequences of the PIN are worth knowing. It is a secret on disk, which the profile
config already is: it holds the WireGuard private key and is written readable by root
alone, and the debug bundle's config dump leaves `CertPKCS11PIN` out. And a wrong PIN
counts against the TPM's dictionary-attack lockout, which is shared with everything else
on the machine that uses the TPM.
The module is loaded at runtime without cgo, through `purego`, which means the binary is
dynamically linked against libc. The store is therefore compiled in only with `-tags pkcs11`
on linux/amd64 and linux/arm64: the deb and rpm packages are built that way, since they
target glibc distributions, while the release tarballs and the Alpine-based container
images keep the fully static build. Without the tag, setting `CertPKCS11PIN` logs that
the build lacks the support.
To exercise the path without hardware, initialise a SoftHSM token and run the end-to-end
test, which imports a key and certificate itself:
```
softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
```
## Only the signed-in user can be validated
This is the central limitation of the design, and it is deliberate.
A proof from a user store can only ever be produced for **the user whose session is
currently open**. Consequences worth designing around:
- **At the sign-in screen there is no user proof.** macOS reports no console user or
attributes the console to root, and `CurrentConsoleUser` returns false for both.
Windows reports no active session with a token. Only machine proofs are sent, so a
posture check that demands a user certificate fails on a machine nobody has signed
into yet.
- **Signing out changes the answer.** Posture can flip between compliant and
non-compliant across a sign-out, so management should treat "no proof" as its own
state rather than as a failed check, or users get disconnected at the sign-in screen.
- **One session is asked, not all of them.** macOS asks the console user, so other
fast-user-switched accounts are skipped even though their keychains are unlocked.
Windows prefers the console and otherwise takes the first active session. If you ever
need every signed-in user, both platforms would have to enumerate sessions and ask
each one.
- **A locked keychain still blocks signing.** A user can be logged in with their
keychain locked (locked on sleep, or manually). The helper then needs an unlock prompt
and may block, which is why the spawn has a 30s timeout and a failure is reported as
"no proof" rather than an error.
- **The first signature prompts.** The user sees "netbird wants to use your confidential
information stored in ...". Choosing *Always Allow* records the helper's designated
requirement in the key's ACL, so it persists across restarts and updates while the
signing identity is stable. Unsigned or ad-hoc development builds re-prompt every run.
## What a user proof does and does not attest
It attests: *some process in that user's session had ACL permission to use a private key
whose certificate chains to CA X, and signed a nonce bound to this peer key*.
It does not attest that the daemon controls the key, that the key is hardware-bound, or
that a particular binary produced the signature. Any code running in that user's session
with an existing ACL grant can produce the same signature by calling
`SecKeyCreateSignature` directly — the proof format is not a secret. The helper does not
create that capability, it only packages it.
If you need a stronger guarantee, use a device identity that never involves a user
session (MDM into the System keychain, which the daemon reads directly), or a key that
requires user presence for each signature (Secure Enclave or a PIV token).
## Reading the logs
Everything in this path logs at info. A healthy macOS run shows, in order:
```
certificate posture: answering N certificate challenges from store *certproof.KeychainStore
macOS Security framework loaded for certificate posture, running as uid=0 euid=0
keychain search list contains 2 keychains
keychain search list[0]: /Library/Keychains/System.keychain
keychain identity query returned N items
certificate posture: asking the desktop session of "user" (uid 501) to answer N challenges
certificate posture: desktop session of "user" returned N proofs
peer meta carries N certificate posture proofs
```
Common outcomes and what they mean:
| Log line | Meaning |
| --- | --- |
| `keychain identity query returned errSecItemNotFound (-25300)` | The keychain is readable and holds no identity of that class. Any other OSStatus is a real access failure. |
| `holds no identities usable for certificate posture, but N readable certificates` | Reading works; the certificate is present without its private key, or is not there at all. |
| `no console user is logged in` | Login window. Device proofs only. |
| `has no issuer in the keychain` | The chain ships leaf-only and verifies only if the challenge supplies that exact root. |
| `challenge N rejected "..." : x509: unhandled critical extension` | The chain is fine but Go refuses an extension in it, which is common for Apple-issued certificates. |
| `challenge N matched none of the M candidates` | Every candidate was rejected; the preceding lines give the reason for each. |
+114
View File
@@ -0,0 +1,114 @@
package certproof
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// Collect answers the certificate challenges in checks: for each challenge it picks a
// stored certificate that chains to the challenge's CAs and signs the nonce with its
// key. The same certificate is proven once even if several checks accept it.
func Collect(ctx context.Context, store Store, checks []*proto.Checks, peerKey []byte) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
return CollectChallenges(ctx, store, challenges, peerKey)
}
func logNoChallenges(checks []*proto.Checks) {
if len(checks) > 0 {
log.Infof("certificate posture: %d posture checks received, none carries a certificate challenge", len(checks))
}
}
// CollectChallenges answers challenges already extracted from the posture checks, so a
// caller that ships them across a process boundary reuses the same matching and signing.
func CollectChallenges(ctx context.Context, store Store, challenges []*proto.CertificateChallenge, peerKey []byte) []certposture.Proof {
log.Infof("certificate posture: answering %d certificate challenges from store %T", len(challenges), store)
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("failed loading certificates for posture checks: %v", err)
return nil
}
if len(candidates) == 0 {
log.Info("certificate posture: certificate store holds no candidates, no proof will be sent")
return nil
}
log.Infof("certificate posture: store holds %d candidate certificates", len(candidates))
now := time.Now()
proven := make(map[[sha256.Size]byte]struct{})
var proofs []certposture.Proof
for i, challenge := range challenges {
roots, err := certposture.ParseCAs(challenge.GetCaCertificates())
if err != nil {
log.Warnf("skipping certificate challenge with invalid CA certificates: %v", err)
continue
}
log.Infof("certificate posture: challenge %d accepts %d CA certificates, nonce is %d bytes", i, len(challenge.GetCaCertificates()), len(challenge.GetNonce()))
matched := false
for _, candidate := range candidates {
if len(candidate.Chain) == 0 {
continue
}
leaf := candidate.Chain[0]
if err := certposture.VerifyChain(candidate.Chain, roots, now); err != nil {
log.Infof("certificate posture: challenge %d rejected %q issued by %q, chain of %d: %v", i, leaf.Subject, leaf.Issuer, len(candidate.Chain), err)
continue
}
matched = true
fingerprint := sha256.Sum256(leaf.Raw)
if _, done := proven[fingerprint]; done {
log.Infof("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
break
}
proof, err := prove(candidate, challenge.GetNonce(), peerKey)
if err != nil {
log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err)
continue
}
log.Infof("certificate posture: challenge %d proven by %q with %s, signature %d bytes, chain of %d", i, leaf.Subject, proof.SigAlg, len(proof.Signature), len(proof.Chain))
proven[fingerprint] = struct{}{}
proofs = append(proofs, proof)
break
}
if !matched {
log.Infof("certificate posture: challenge %d matched none of the %d candidates", i, len(candidates))
}
}
log.Infof("certificate posture: %d challenges produced %d proofs", len(challenges), len(proofs))
return proofs
}
func certificateChallenges(checks []*proto.Checks) []*proto.CertificateChallenge {
var challenges []*proto.CertificateChallenge
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil && len(challenge.GetNonce()) > 0 {
challenges = append(challenges, challenge)
}
}
return challenges
}
func prove(candidate Candidate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(candidate.Signer, nonce, peerKey)
if err != nil {
return certposture.Proof{}, err
}
chain := make([][]byte, 0, len(candidate.Chain))
for _, cert := range candidate.Chain {
chain = append(chain, cert.Raw)
}
return certposture.Proof{Nonce: nonce, Chain: chain, SigAlg: sigAlg, Signature: sig}, nil
}
@@ -0,0 +1,96 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this Mac
// can reach. The root daemon reads the System keychain itself, which is where MDM
// installs device identities, and reaches the console user's login keychain only by
// launching a helper into that user's session. A Mac sitting at the login window
// therefore yields device proofs alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
// A helper already runs inside the user's session, so it reads its own keychain
// directly and must never launch another one.
if os.Geteuid() != 0 {
return CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
userProofs, err := collectAsConsoleUser(ctx, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: console user keychain unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// collectAsConsoleUser runs the helper inside the desktop session of the logged-in
// user. Dropping to their uid is not enough: keychain access is an XPC call to a
// per-session securityd, so the helper has to enter their Mach bootstrap namespace,
// which is what launchctl asuser does.
func collectAsConsoleUser(ctx context.Context, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentConsoleUser()
if !ok {
return nil, nil
}
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
payload, err := json.Marshal(helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
uid := strconv.FormatUint(uint64(user.UID), 10)
cmd := exec.CommandContext(ctx, "launchctl", "asuser", uid, "sudo", "-u", user.Name, "-H", binary, "posture", "cert-proof")
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
log.Infof("certificate posture: asking the desktop session of %q (uid %s) to answer %d challenges", user.Name, uid, len(challenges))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("run helper as %s: %w: %s", user.Name, err, strings.TrimSpace(stderr.String()))
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
log.Infof("certificate posture: desktop session of %q returned %d proofs", user.Name, len(resp.Proofs))
return resp.Proofs, nil
}
// helperStore is the store the helper reads. On macOS the keychain search list of the
// user's own session already is that user's keychain, so the platform default is right.
func helperStore() Store {
return DefaultStore()
}
@@ -0,0 +1,24 @@
//go:build !darwin && !windows
package certproof
import (
"context"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// CollectProofs answers the certificate challenges in checks from the PEM directory cfg
// names, joined by its PKCS#11 token when it names one. Only macOS and Windows keep
// per-user certificates out of reach of a privileged daemon, so every other platform
// reads its store in the daemon itself.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, cfg Config) []certposture.Proof {
return Collect(ctx, storeWithToken(cfg), checks, peerKey)
}
// helperStore is the store the helper reads. Nothing launches a helper on these
// platforms, so it is the platform default.
func helperStore() Store {
return DefaultStore()
}
+108
View File
@@ -0,0 +1,108 @@
package certproof
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
corpCA := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := t.TempDir()
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
otherKey := certtest.RSAKey(t)
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
writeFile(t, dir, "notes.txt", "ignored")
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
challenge := func(cas ...string) *proto.Checks {
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
}
checks := []*proto.Checks{
{Files: []string{"/usr/bin/agent"}},
challenge(corpCA.PEM),
challenge(corpCA.PEM),
challenge(otherCA.PEM),
challenge(unrelatedCA.PEM),
challenge("not a pem"),
}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 2)
var subjects []string
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, time.Now())
require.NoError(t, err)
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
}
func TestCollect_NothingToProve(t *testing.T) {
dir := t.TempDir()
key := certtest.ECDSAKey(t)
ca := certtest.NewCA(t, "root")
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
tests := []struct {
name string
store Store
checks []*proto.Checks
}{
{"no checks", NewFileStore(dir), nil},
{"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
{"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{ca.PEM}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
})
}
}
func TestFileStore_ChainWithIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1)
require.Len(t, candidates[0].Chain, 2)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
@@ -0,0 +1,101 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const helperTimeout = 30 * time.Second
// CollectProofs answers the certificate challenges in checks from every store this
// machine can reach. The service reads the local machine store itself, where AD and
// Intune enrol device certificates, and reaches the signed-in user's store by launching
// a helper with that session's token. A machine at the sign-in screen therefore proves
// device certificates alone.
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
return nil
}
proofs := CollectChallenges(ctx, DefaultStore(), challenges, peerKey)
// The helper already runs as the signed-in user, and an ordinary process has no
// right to a session token, so only the service goes looking for one.
if !runningAsLocalSystem() {
return proofs
}
userProofs, err := collectAsDesktopUser(ctx, challenges, peerKey)
if err != nil {
log.Infof("certificate posture: user certificate store unavailable: %v", err)
}
return mergeProofs(proofs, userProofs)
}
// helperStore is the store the helper reads. It runs as the signed-in user, so it wants
// that user's store rather than the machine store the service already read.
func helperStore() Store {
return NewUserStore()
}
// collectAsDesktopUser runs the helper inside the interactive session of the signed-in
// user. Unlike a keychain on macOS, a Windows service can assume a user identity
// directly, so the session token goes straight into the child process.
func collectAsDesktopUser(ctx context.Context, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) {
user, ok := CurrentDesktopUser()
if !ok {
return nil, nil
}
defer user.Close()
binary, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve own binary: %w", err)
}
payload, err := json.Marshal(helperRequest(challenges, peerKey))
if err != nil {
return nil, fmt.Errorf("encode helper request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "posture", "cert-proof")
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(user.Token),
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
log.Infof("certificate posture: asking the session of %q (session %d) to answer %d challenges", user.Name, user.Session, len(challenges))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("run helper as %s: %w: %s", user.Name, err, strings.TrimSpace(stderr.String()))
}
var resp HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("decode helper response: %w", err)
}
log.Infof("certificate posture: session of %q returned %d proofs", user.Name, len(resp.Proofs))
return resp.Proofs, nil
}
@@ -0,0 +1,113 @@
package certproof
import (
"bytes"
"fmt"
"sync"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
systemConfigurationFramework = "/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration"
encodingUTF8 = 0x08000100
consoleNameBufSize = 256
)
var (
consoleOnce sync.Once
consoleErr error
scDynamicStoreCopyConsoleUser func(store uintptr, uid, gid *uint32) uintptr
cfStringGetCString func(str uintptr, buffer *byte, size int, encoding uint32) bool
)
// ConsoleUser is the account whose desktop session owns the display. Its login keychain
// is the only user keychain a NetBird daemon can reach, and only while it is logged in.
type ConsoleUser struct {
Name string
UID uint32
GID uint32
}
// CurrentConsoleUser reports the user sitting at the desktop. The second return value is
// false when nobody is: at the login window macOS either reports no console user at all
// or attributes the session to root, and neither has a login keychain to offer.
func CurrentConsoleUser() (ConsoleUser, bool) {
if err := loadConsoleUser(); err != nil {
log.Infof("console user lookup unavailable: %v", err)
return ConsoleUser{}, false
}
var uid, gid uint32
name := scDynamicStoreCopyConsoleUser(0, &uid, &gid)
if name == 0 {
log.Info("no console user is logged in, no login keychain is reachable")
return ConsoleUser{}, false
}
defer cfRelease(name)
user := ConsoleUser{Name: cfString(name), UID: uid, GID: gid}
if !user.hasDesktop() {
log.Infof("console session belongs to %q uid=%d, which is not a desktop login, no login keychain is reachable", user.Name, user.UID)
return ConsoleUser{}, false
}
return user, true
}
// hasDesktop reports whether the console session is a real user desktop. The login
// window runs as root and some macOS releases name it "loginwindow" instead.
func (u ConsoleUser) hasDesktop() bool {
switch u.Name {
case "", "root", "loginwindow":
return false
}
return u.UID != 0
}
func cfString(str uintptr) string {
buf := make([]byte, consoleNameBufSize)
if !cfStringGetCString(str, &buf[0], len(buf), encodingUTF8) {
return ""
}
if end := bytes.IndexByte(buf, 0); end >= 0 {
return string(buf[:end])
}
return string(buf)
}
// loadConsoleUser resolves the console user symbols. It loads the keychain bindings
// first because CFRelease is resolved there and released strings depend on it.
func loadConsoleUser() error {
if err := loadKeychain(); err != nil {
return err
}
consoleOnce.Do(func() { consoleErr = resolveConsoleUser() })
return consoleErr
}
func resolveConsoleUser() error {
systemConfiguration, err := purego.Dlopen(systemConfigurationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", systemConfigurationFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
symbol, err := purego.Dlsym(systemConfiguration, "SCDynamicStoreCopyConsoleUser")
if err != nil {
return fmt.Errorf("resolve SCDynamicStoreCopyConsoleUser: %w", err)
}
purego.RegisterFunc(&scDynamicStoreCopyConsoleUser, symbol)
symbol, err = purego.Dlsym(coreFoundation, "CFStringGetCString")
if err != nil {
return fmt.Errorf("resolve CFStringGetCString: %w", err)
}
purego.RegisterFunc(&cfStringGetCString, symbol)
return nil
}
@@ -0,0 +1,39 @@
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) {
tests := []struct {
name string
user ConsoleUser
desktop bool
}{
{"logged in user", ConsoleUser{Name: "maycon", UID: 501, GID: 20}, true},
{"login window as root", ConsoleUser{Name: "root", UID: 0}, false},
{"login window by name", ConsoleUser{Name: "loginwindow", UID: 0}, false},
{"named user still at uid 0", ConsoleUser{Name: "admin", UID: 0}, false},
{"no console user", ConsoleUser{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.desktop, tt.user.hasDesktop(), "only a real desktop session offers a login keychain")
})
}
}
// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with
// a desktop open must report a non-root user; a headless runner must report none.
func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) {
user, ok := CurrentConsoleUser()
if !ok {
t.Log("no console user, running headless")
return
}
assert.NotEmpty(t, user.Name, "a console user must have a name")
assert.NotZero(t, user.UID, "a desktop session never belongs to uid 0")
assert.True(t, user.hasDesktop(), "a reported console user must be a desktop session")
}
@@ -0,0 +1,126 @@
package certproof
import (
"fmt"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
noActiveSession = 0xFFFFFFFF
// wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE and wtsActive is WTSActive of
// WTS_CONNECTSTATE_CLASS. Neither is exported by x/sys/windows.
wtsCurrentServer = windows.Handle(0)
wtsActive = 0
)
// DesktopUser is an interactive session and the account signed into it. The user's
// certificate store is readable only from a process running as that account, because
// its private keys are protected against the user profile rather than the machine.
type DesktopUser struct {
Session uint32
Name string
Token windows.Token
}
// Close releases the session token.
func (u DesktopUser) Close() {
if err := u.Token.Close(); err != nil {
log.Debugf("failed closing desktop session token: %v", err)
}
}
// CurrentDesktopUser returns a token for the interactive user whose certificate store
// should be asked. The physical console comes first, and an active remote desktop
// session is used when nobody is at the console, which is how servers and VDI hosts are
// normally reached. The second return is false at the sign-in screen, where no
// interactive session exists and only machine certificates can be proven.
//
// Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an
// ordinary process does not.
func CurrentDesktopUser() (DesktopUser, bool) {
if session := windows.WTSGetActiveConsoleSessionId(); session != noActiveSession {
if user, ok := desktopUser(session); ok {
return user, true
}
log.Infof("console session %d has nobody signed in, looking for an active remote session", session)
}
sessions, err := activeSessions()
if err != nil {
log.Infof("cannot enumerate terminal sessions: %v", err)
return DesktopUser{}, false
}
for _, session := range sessions {
if user, ok := desktopUser(session); ok {
return user, true
}
}
log.Info("no interactive session is signed in, no user certificate store is reachable")
return DesktopUser{}, false
}
func desktopUser(session uint32) (DesktopUser, bool) {
var token windows.Token
if err := windows.WTSQueryUserToken(session, &token); err != nil {
log.Debugf("no user token for session %d: %v", session, err)
return DesktopUser{}, false
}
name, err := tokenAccount(token)
if err != nil {
log.Infof("session %d token has no readable account: %v", session, err)
if closeErr := token.Close(); closeErr != nil {
log.Debugf("failed closing session token: %v", closeErr)
}
return DesktopUser{}, false
}
return DesktopUser{Session: session, Name: name, Token: token}, true
}
func tokenAccount(token windows.Token) (string, error) {
user, err := token.GetTokenUser()
if err != nil {
return "", fmt.Errorf("read token user: %w", err)
}
account, domain, _, err := user.User.Sid.LookupAccount("")
if err != nil {
return "", fmt.Errorf("look up account: %w", err)
}
if domain == "" {
return account, nil
}
return domain + `\` + account, nil
}
func activeSessions() ([]uint32, error) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(wtsCurrentServer, 0, 1, &info, &count); err != nil {
return nil, fmt.Errorf("enumerate sessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
var active []uint32
for _, session := range unsafe.Slice(info, count) {
if session.State == wtsActive {
active = append(active, session.SessionID)
}
}
return active, nil
}
// runningAsLocalSystem reports whether this process is the service. The helper runs as
// the signed-in user and must read its own store rather than launching another helper.
func runningAsLocalSystem() bool {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
log.Debugf("failed reading own token user: %v", err)
return false
}
return user.User.Sid.IsWellKnown(windows.WinLocalSystemSid)
}
+66
View File
@@ -0,0 +1,66 @@
package certproof
import (
"context"
"encoding/json"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// HelperRequest is the work the daemon hands to a helper running in a user session. The
// peer key binds every signature to this machine, so a proof cannot be replayed onto
// another peer.
type HelperRequest struct {
PeerKey []byte `json:"peerKey"`
Challenges []HelperChallenge `json:"challenges"`
}
// HelperChallenge is one certificate challenge in the form the helper needs.
type HelperChallenge struct {
Nonce []byte `json:"nonce"`
CACertificates []string `json:"caCertificates"`
}
// HelperResponse carries the proofs the helper produced from its own keychain.
type HelperResponse struct {
Proofs []certposture.Proof `json:"proofs"`
}
// RunHelper answers the challenges on in from the store of the user running this
// process and writes the proofs to out. It is the child half of the console user
// lookup: the daemon cannot read a login keychain, so it launches this in the user's
// session instead. Only the signature crosses back, never the private key.
func RunHelper(ctx context.Context, in io.Reader, out io.Writer) error {
return runHelper(ctx, helperStore(), in, out)
}
func runHelper(ctx context.Context, store Store, in io.Reader, out io.Writer) error {
var req HelperRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode helper request: %w", err)
}
challenges := make([]*proto.CertificateChallenge, 0, len(req.Challenges))
for _, challenge := range req.Challenges {
challenges = append(challenges, &proto.CertificateChallenge{
Nonce: challenge.Nonce,
CaCertificates: challenge.CACertificates,
})
}
var proofs []certposture.Proof
if len(challenges) > 0 {
proofs = CollectChallenges(ctx, store, challenges, req.PeerKey)
}
log.Infof("certificate posture helper: answering %d challenges with %d proofs", len(challenges), len(proofs))
if err := json.NewEncoder(out).Encode(HelperResponse{Proofs: proofs}); err != nil {
return fmt.Errorf("encode helper response: %w", err)
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
//go:build darwin || windows
package certproof
import (
"crypto/sha256"
"crypto/x509"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) HelperRequest {
req := HelperRequest{PeerKey: peerKey, Challenges: make([]HelperChallenge, 0, len(challenges))}
for _, challenge := range challenges {
req.Challenges = append(req.Challenges, HelperChallenge{
Nonce: challenge.GetNonce(),
CACertificates: challenge.GetCaCertificates(),
})
}
return req
}
// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that
// both stores hold so the same certificate is proven once.
func mergeProofs(device, user []certposture.Proof) []certposture.Proof {
if len(user) == 0 {
return device
}
seen := make(map[[sha256.Size]byte]struct{}, len(device))
for _, proof := range device {
if len(proof.Chain) > 0 {
seen[sha256.Sum256(proof.Chain[0])] = struct{}{}
}
}
merged := device
for _, proof := range user {
if len(proof.Chain) == 0 {
continue
}
fingerprint := sha256.Sum256(proof.Chain[0])
if _, done := seen[fingerprint]; done {
continue
}
seen[fingerprint] = struct{}{}
merged = append(merged, proof)
logUserProof(proof)
}
return merged
}
func logUserProof(proof certposture.Proof) {
leaf, err := x509.ParseCertificate(proof.Chain[0])
if err != nil {
log.Infof("certificate posture: user proof carries an unparsable leaf: %v", err)
return
}
log.Infof("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer)
}
@@ -0,0 +1,56 @@
//go:build darwin || windows
package certproof
import (
"crypto/sha256"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
shared := ca.Issue(t, certtest.ECDSAKey(t), "shared")
userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only")
device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}}
user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}}
merged := mergeProofs(device, user)
require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped")
assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place")
assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended")
}
func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}}
merged := mergeProofs(device, nil)
require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof")
assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged")
}
func TestHelperRequest_CarriesEveryChallenge(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
challenges := []*proto.CertificateChallenge{
{Nonce: []byte("first"), CaCertificates: []string{ca.PEM}},
{Nonce: []byte("second")},
}
req := helperRequest(challenges, peerKey)
require.Len(t, req.Challenges, 2, "every challenge must reach the helper")
assert.Equal(t, peerKey, req.PeerKey, "the peer key binds the signature to this machine")
assert.Equal(t, []byte("first"), req.Challenges[0].Nonce, "the nonce must survive unchanged")
assert.Equal(t, []string{ca.PEM}, req.Challenges[0].CACertificates, "the accepted CAs must survive unchanged")
assert.Empty(t, req.Challenges[1].CACertificates, "a challenge without CAs stays without CAs")
}
+63
View File
@@ -0,0 +1,63 @@
package certproof
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
dir := t.TempDir()
key := certtest.ECDSAKey(t)
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
request, err := json.Marshal(HelperRequest{
PeerKey: peerKey,
Challenges: []HelperChallenge{{Nonce: nonce, CACertificates: []string{ca.PEM}}},
})
require.NoError(t, err, "request must encode")
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON")
require.Len(t, resp.Proofs, 1, "the matching certificate should produce one proof")
// Verify exactly as management does, so the proof is proven to survive the encode,
// the process boundary and the decode intact.
chain, err := challenger.Verify(resp.Proofs[0], peerKey, time.Now())
require.NoError(t, err, "the decoded proof must verify against the issued nonce")
assert.Equal(t, "device", chain[0].Subject.CommonName, "the proven leaf should be the device certificate")
}
func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) {
request, err := json.Marshal(HelperRequest{PeerKey: peerKey})
require.NoError(t, err)
var stdout bytes.Buffer
require.NoError(t, runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader(request), &stdout))
var resp HelperResponse
require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON")
assert.Empty(t, resp.Proofs, "no challenges should produce no proofs")
}
func TestRunHelper_RejectsMalformedRequest(t *testing.T) {
var stdout bytes.Buffer
err := runHelper(context.Background(), NewFileStore(t.TempDir()), bytes.NewReader([]byte("not json")), &stdout)
require.Error(t, err, "a malformed request must fail rather than emit an empty proof set")
assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure")
}
@@ -0,0 +1,363 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"errors"
"fmt"
"io"
"os"
"sync"
"unsafe"
"github.com/ebitengine/purego"
log "github.com/sirupsen/logrus"
)
const (
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
errSecItemNotFound = -25300
)
var (
keychainOnce sync.Once
keychainErr error
secItemCopyMatching func(query uintptr, result *uintptr) int32
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
secCertificateCopyData func(cert uintptr) uintptr
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
secKeychainCopySearchList func(searchList *uintptr) int32
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
cfArrayGetCount func(array uintptr) int
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
cfDataGetLength func(data uintptr) int
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
cfErrorGetCode func(err uintptr) int
cfRelease func(ref uintptr)
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks uintptr
)
// DefaultStore is the keychain search list of the daemon, which for the root daemon is
// the System keychain where MDM installs device identities.
func DefaultStore() Store {
return NewKeychainStore()
}
// KeychainStore yields the identities of the process's keychain search list, reached
// through purego so the client keeps building with CGO_ENABLED=0.
type KeychainStore struct{}
func NewKeychainStore() *KeychainStore {
return &KeychainStore{}
}
func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
if err := loadKeychain(); err != nil {
return nil, err
}
var leaves []*x509.Certificate
err := eachIdentity(func(_ uintptr, der []byte) (bool, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping keychain identity: %v", err)
return false, nil
}
log.Infof("keychain identity: subject=%q issuer=%q serial=%s expires=%s", cert.Subject, cert.Issuer, cert.SerialNumber, cert.NotAfter)
leaves = append(leaves, cert)
return false, nil
})
if err != nil {
return nil, err
}
// The certificate query runs even without identities: it separates a keychain that is
// readable but holds no identity from one the process cannot read at all.
pool, err := keychainCertificates()
if err != nil {
return nil, err
}
if len(leaves) == 0 {
log.Infof("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
return nil, nil
}
log.Infof("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Infof("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
log.Infof("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
}
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf}})
}
return candidates, nil
}
// keychainSigner holds only the certificate; the identity is looked up again at signing
// time so no keychain references outlive a call.
type keychainSigner struct {
leaf *x509.Certificate
}
func (s *keychainSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
log.Infof("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
algorithm := keychainAlgorithm(scheme)
var signature []byte
err = eachIdentity(func(identity uintptr, der []byte) (bool, error) {
if !bytes.Equal(der, s.leaf.Raw) {
return false, nil
}
signature, err = signWithIdentity(identity, algorithm, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the keychain")
}
log.Infof("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
return signature, nil
}
func keychainAlgorithm(scheme sigScheme) uintptr {
switch scheme {
case schemeECDSASHA384:
return kSecKeyAlgorithmECDSASHA384
case schemeRSAPSSSHA256:
return kSecKeyAlgorithmRSAPSSSHA256
default:
return kSecKeyAlgorithmECDSASHA256
}
}
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
var key uintptr
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
}
defer cfRelease(key)
data := cfDataCreate(0, &digest[0], len(digest))
defer cfRelease(data)
var cfErr uintptr
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
if signature == 0 {
defer cfRelease(cfErr)
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", cfErrorGetCode(cfErr))
}
defer cfRelease(signature)
return dataBytes(signature), nil
}
func eachIdentity(fn func(identity uintptr, der []byte) (bool, error)) error {
return eachMatching(kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
var cert uintptr
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
return true, fmt.Errorf("SecIdentityCopyCertificate: %d", status)
}
der := certificateDER(cert)
cfRelease(cert)
return fn(identity, der)
})
}
func keychainCertificates() ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var unparsable int
err := eachMatching(kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
certs = append(certs, cert)
return false, nil
}
unparsable++
return false, nil
})
log.Infof("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
return certs, err
}
func eachMatching(class uintptr, name string, fn func(item uintptr) (bool, error)) error {
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
defer cfRelease(query)
var items uintptr
switch status := secItemCopyMatching(query, &items); status {
case 0:
case errSecItemNotFound:
log.Infof("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
return nil
default:
log.Infof("keychain %s query returned OSStatus %d", name, status)
return fmt.Errorf("SecItemCopyMatching: %d", status)
}
defer cfRelease(items)
n := cfArrayGetCount(items)
log.Infof("keychain %s query returned %d items", name, n)
for i := 0; i < n; i++ {
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
return err
}
}
return nil
}
func certificateDER(cert uintptr) []byte {
data := secCertificateCopyData(cert)
defer cfRelease(data)
return dataBytes(data)
}
func dataBytes(data uintptr) []byte {
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), cfDataGetLength(data)))
}
func loadKeychain() error {
keychainOnce.Do(func() {
if keychainErr = resolveKeychain(); keychainErr != nil {
log.Infof("macOS keychain unavailable for certificate posture: %v", keychainErr)
return
}
log.Infof("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
logSearchList()
})
return keychainErr
}
// logSearchList reports the keychains the process searches. The root daemon sees the
// System keychain and System Roots, never a user's login keychain.
func logSearchList() {
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
log.Info("keychain search list diagnostics unavailable on this macOS version")
return
}
var list uintptr
if status := secKeychainCopySearchList(&list); status != 0 {
log.Infof("SecKeychainCopySearchList returned OSStatus %d", status)
return
}
defer cfRelease(list)
n := cfArrayGetCount(list)
log.Infof("keychain search list contains %d keychains", n)
for i := 0; i < n; i++ {
log.Infof("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
}
}
func keychainPath(keychain uintptr) string {
path := make([]byte, 1024)
length := uint32(len(path))
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
}
return string(path[:length])
}
func resolveKeychain() error {
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", securityFramework, err)
}
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
}
for _, fn := range []struct {
ptr any
lib uintptr
name string
}{
{&secItemCopyMatching, security, "SecItemCopyMatching"},
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
{&secCertificateCopyData, security, "SecCertificateCopyData"},
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
{&cfDataCreate, coreFoundation, "CFDataCreate"},
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
{&cfRelease, coreFoundation, "CFRelease"},
} {
symbol, err := purego.Dlsym(fn.lib, fn.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", fn.name, err)
}
purego.RegisterFunc(fn.ptr, symbol)
}
for _, global := range []struct {
ptr *uintptr
lib uintptr
name string
deref bool
}{
{&kSecClass, security, "kSecClass", true},
{&kSecClassIdentity, security, "kSecClassIdentity", true},
{&kSecClassCertificate, security, "kSecClassCertificate", true},
{&kSecMatchLimit, security, "kSecMatchLimit", true},
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
{&kSecReturnRef, security, "kSecReturnRef", true},
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
} {
addr, err := purego.Dlsym(global.lib, global.name)
if err != nil {
return fmt.Errorf("resolve %s: %w", global.name, err)
}
if global.deref {
addr = **(**uintptr)(unsafe.Pointer(&addr))
}
*global.ptr = addr
}
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
return nil
}
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
// longer exports it so keychain lookups keep working without it.
func resolveOptional(lib uintptr, name string, ptr any) {
symbol, err := purego.Dlsym(lib, name)
if err != nil {
log.Infof("keychain diagnostics: %s unavailable: %v", name, err)
return
}
purego.RegisterFunc(ptr, symbol)
}
+77
View File
@@ -0,0 +1,77 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"slices"
)
var errUnsupportedScheme = errors.New("unsupported signature scheme for OS keystore")
type sigScheme int
const (
schemeECDSASHA256 sigScheme = iota + 1
schemeECDSASHA384
schemeRSAPSSSHA256
)
// schemeFor maps a crypto.Signer request onto the schemes the OS keystores perform.
func schemeFor(pub crypto.PublicKey, opts crypto.SignerOpts) (sigScheme, error) {
switch pub.(type) {
case *ecdsa.PublicKey:
switch opts.HashFunc() {
case crypto.SHA256:
return schemeECDSASHA256, nil
case crypto.SHA384:
return schemeECDSASHA384, nil
}
case *rsa.PublicKey:
if pss, ok := opts.(*rsa.PSSOptions); ok && pss.Hash == crypto.SHA256 {
return schemeRSAPSSSHA256, nil
}
}
return 0, fmt.Errorf("%w: %T with %v", errUnsupportedScheme, pub, opts.HashFunc())
}
// buildChain extends leaf with the issuers found in pool up to a self-signed certificate.
func buildChain(leaf *x509.Certificate, pool []*x509.Certificate) []*x509.Certificate {
chain := []*x509.Certificate{leaf}
current := leaf
for current.CheckSignatureFrom(current) != nil {
issuer := issuerIn(current, pool, chain)
if issuer == nil {
break
}
chain = append(chain, issuer)
current = issuer
}
return chain
}
func issuerIn(cert *x509.Certificate, pool, seen []*x509.Certificate) *x509.Certificate {
for _, candidate := range pool {
if slices.ContainsFunc(seen, candidate.Equal) {
continue
}
if cert.CheckSignatureFrom(candidate) == nil {
return candidate
}
}
return nil
}
// ecdsaSignatureASN1 converts the fixed-width r||s form emitted by CNG into the DER form Go verifies.
func ecdsaSignatureASN1(raw []byte) ([]byte, error) {
if len(raw) == 0 || len(raw)%2 != 0 {
return nil, errors.New("malformed raw ECDSA signature")
}
half := len(raw) / 2
return asn1.Marshal(struct{ R, S *big.Int }{new(big.Int).SetBytes(raw[:half]), new(big.Int).SetBytes(raw[half:])})
}
@@ -0,0 +1,87 @@
package certproof
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestBuildChain_FollowsIssuersThroughThePool(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
unrelated := certtest.NewCA(t, "unrelated")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
pool := []*x509.Certificate{unrelated.Cert, root.Cert, leaf, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Equal(t, []*x509.Certificate{leaf, intermediate.Cert, root.Cert}, chain)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(chain, roots, time.Now()))
}
func TestBuildChain_StopsWhereThePoolEnds(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
leaf := intermediate.Issue(t, certtest.ECDSAKey(t), "device")
assert.Equal(t, []*x509.Certificate{leaf}, buildChain(leaf, nil))
assert.Equal(t, []*x509.Certificate{leaf, intermediate.Cert}, buildChain(leaf, []*x509.Certificate{intermediate.Cert}))
}
func TestSchemeFor(t *testing.T) {
ecKey := certtest.ECDSAKey(t)
rsaKey := certtest.RSAKey(t)
pss := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
tests := []struct {
name string
pub crypto.PublicKey
opts crypto.SignerOpts
want sigScheme
}{
{"ecdsa sha256", ecKey.Public(), crypto.SHA256, schemeECDSASHA256},
{"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384},
{"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256},
{"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0},
{"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := schemeFor(tc.pub, tc.opts)
if tc.want == 0 {
assert.ErrorIs(t, err, errUnsupportedScheme)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestECDSASignatureASN1(t *testing.T) {
key := certtest.ECDSAKey(t).(*ecdsa.PrivateKey)
digest := sha256.Sum256([]byte("nonce"))
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
require.NoError(t, err)
raw := append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
der, err := ecdsaSignatureASN1(raw)
require.NoError(t, err)
assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der))
_, err = ecdsaSignatureASN1(raw[:63])
assert.Error(t, err)
}
+294
View File
@@ -0,0 +1,294 @@
package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
// PKCS11Config names the token whose certificates the store yields. URI is an RFC 7512
// PKCS#11 URI, or empty for the first token the p11-kit proxy exposes. PIN is the user
// PIN, and takes precedence over a pin-value or pin-source the URI carries.
type PKCS11Config struct {
URI string
PIN string
}
// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes
// TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the
// convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM
// directory by public key. Every signature happens on the token.
type PKCS11Store struct {
uri *pkcs11.URI
pin string
certDir string
}
// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in
// certDir without a key of their own are paired with the token's keys by public key.
func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) {
store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir}
if cfg.URI == "" {
return store, nil
}
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
}
store.uri = parsed
return store, nil
}
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
session, err := s.open()
if err != nil {
return nil, err
}
defer session.Close()
certs, err := tokenCertificates(session)
if err != nil {
return nil, err
}
fileChains, err := s.fileChains()
if err != nil {
return nil, err
}
log.Infof("%s holds %d certificates, %d certificate files without a key wait for its keys", s, len(certs), len(fileChains))
pool := make([]*x509.Certificate, 0, len(certs))
for _, cert := range certs {
pool = append(pool, cert.cert)
}
for _, chain := range fileChains {
pool = append(pool, chain...)
}
var candidates []Candidate
for _, cert := range certs {
if _, err := privateKey(session, cert.id); err != nil {
log.Infof("%s certificate %q has no usable private key: %v", s, cert.cert.Subject, err)
continue
}
candidates = append(candidates, s.candidate(cert.cert, cert.id, pool))
}
if len(fileChains) == 0 {
return candidates, nil
}
keys, err := tokenPublicKeys(session)
if err != nil {
return nil, err
}
for _, chain := range fileChains {
leaf := chain[0]
id, ok := keys.idFor(leaf.PublicKey)
if !ok {
log.Debugf("%s holds no key for certificate %q from %s", s, leaf.Subject, s.certDir)
continue
}
candidates = append(candidates, s.candidate(leaf, id, pool))
}
return candidates, nil
}
func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate {
chain := buildChain(leaf, pool)
log.Infof("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}}
}
// fileChains reads the certificate files in the PEM directory that carry no key of their
// own; the file store answers for the ones that do.
func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) {
if s.certDir == "" {
return nil, nil
}
paths, err := certFiles(s.certDir)
if err != nil {
return nil, err
}
var chains [][]*x509.Certificate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil || signer != nil {
continue
}
chains = append(chains, chain)
}
return chains, nil
}
type tokenKey struct {
id []byte
public crypto.PublicKey
}
type tokenKeys []tokenKey
func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) {
objects, err := session.FindObjects(pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPublicKey)})
if err != nil {
return nil, err
}
keys := make(tokenKeys, 0, len(objects))
for _, object := range objects {
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
public, err := session.PublicKey(object)
if err != nil {
log.Debugf("skipping public key on PKCS#11 token: %v", err)
continue
}
keys = append(keys, tokenKey{id: id, public: public})
}
return keys, nil
}
// idFor finds the token key whose public half is pub, so a certificate kept outside the
// token is still signed for by the key inside it.
func (k tokenKeys) idFor(pub crypto.PublicKey) ([]byte, bool) {
for _, key := range k {
equaler, ok := key.public.(interface{ Equal(crypto.PublicKey) bool })
if ok && len(key.id) > 0 && equaler.Equal(pub) {
return key.id, true
}
}
return nil, false
}
func (s *PKCS11Store) String() string {
if s.uri.Token == "" {
return "PKCS#11 token"
}
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
}
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
module, err := pkcs11.Load(s.uri.Module())
if err != nil {
return nil, err
}
pin, err := s.userPIN()
if err != nil {
return nil, err
}
return module.OpenSession(s.uri.Token, pin)
}
func (s *PKCS11Store) userPIN() ([]byte, error) {
if s.pin != "" {
return []byte(s.pin), nil
}
return s.uri.PIN()
}
type tokenCertificate struct {
cert *x509.Certificate
id []byte
}
func tokenCertificates(session *pkcs11.Session) ([]tokenCertificate, error) {
objects, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassCertificate)},
pkcs11.Attribute{Type: pkcs11.AttrCertificateType, Value: pkcs11.ULong(pkcs11.CertificateX509)},
)
if err != nil {
return nil, err
}
certs := make([]tokenCertificate, 0, len(objects))
for _, object := range objects {
der, err := session.Attribute(object, pkcs11.AttrValue)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping unparsable certificate on PKCS#11 token: %v", err)
continue
}
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
certs = append(certs, tokenCertificate{cert: cert, id: id})
}
return certs, nil
}
var errNoPrivateKey = errors.New("no private key shares the certificate's CKA_ID")
func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) {
if len(id) == 0 {
return 0, errNoPrivateKey
}
keys, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPrivateKey)},
pkcs11.Attribute{Type: pkcs11.AttrID, Value: id},
)
if err != nil {
return 0, err
}
if len(keys) == 0 {
return 0, errNoPrivateKey
}
return keys[0], nil
}
// pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh
// session at signing time so no token handle outlives a call.
type pkcs11Signer struct {
store *PKCS11Store
leaf *x509.Certificate
id []byte
}
func (s *pkcs11Signer) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *pkcs11Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
session, err := s.store.open()
if err != nil {
return nil, err
}
defer session.Close()
key, err := privateKey(session, s.id)
if err != nil {
return nil, err
}
signature, err := session.Sign(pkcs11Mechanism(scheme), key, digest)
if err != nil {
return nil, err
}
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// pkcs11Mechanism maps a signature scheme onto the token mechanism that consumes a digest.
func pkcs11Mechanism(scheme sigScheme) pkcs11.Mechanism {
if scheme == schemeRSAPSSSHA256 {
return pkcs11.Mechanism{
Type: pkcs11.MechRSAPKCSPSS,
PSS: &pkcs11.PSSParams{Hash: pkcs11.MechSHA256, MGF: pkcs11.MGF1SHA256, SaltLen: sha256.Size},
}
}
return pkcs11.Mechanism{Type: pkcs11.MechECDSA}
}
@@ -0,0 +1,331 @@
package certproof
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"math/big"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
const testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
type failingStore struct{}
func (failingStore) Candidates(context.Context) ([]Candidate, error) {
return nil, errors.New("token unplugged")
}
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key := certtest.ECDSAKey(t)
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background())
require.NoError(t, err)
assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token")
}
// TestCollect_PKCS11TokenEndToEnd needs an initialised token with a user PIN, named by
// NB_TEST_PKCS11_URI. With SoftHSM:
//
// softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
// NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
// go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
//
// It imports a key and its certificate as token objects, then proves the certificate
// through the store the way the daemon would. Every run adds one more identity to the token.
func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
store, uri := pkcs11TestStore(t, "")
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-"+name)
leaf := ca.Issue(t, key, "device-"+name)
importIdentity(t, uri, key, leaf)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token-held key must prove exactly this run's certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the imported certificate")
})
}
}
// Attribute types the import needs and the store does not.
const (
attrPrivate = 0x2
attrIssuer = 0x81
attrSerialNumber = 0x82
attrSensitive = 0x103
attrSign = 0x108
attrVerify = 0x10a
attrPrivateExponent = 0x123
attrPrime1 = 0x124
attrPrime2 = 0x125
attrExponent1 = 0x126
attrExponent2 = 0x127
attrCoefficient = 0x128
)
var (
ckTrue = []byte{1}
ckFalse = []byte{0}
// The P-256 named curve OID in DER, which is what CKA_EC_PARAMS carries.
oidP256 = []byte{0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}
)
// importIdentity stores key and leaf on the token the way tpm2_ptool import and addcert
// do: private and public key objects plus the certificate, all under one CKA_ID.
func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Certificate) {
t.Helper()
id := importKey(t, uri, key, leaf.Subject.CommonName)
importCertificate(t, uri, leaf, id)
}
func importKey(t *testing.T, uri string, key crypto.Signer, label string) []byte {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
id := make([]byte, 8)
_, err := rand.Read(id)
require.NoError(t, err)
private := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPrivateKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckTrue),
attr(attrSensitive, ckTrue),
attr(attrSign, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(private, privateKeyAttributes(t, key)...)...)
require.NoError(t, err, "import private key")
public := []pkcs11.Attribute{
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassPublicKey)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(attrVerify, ckTrue),
attr(pkcs11.AttrLabel, []byte(label)),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(public, publicKeyAttributes(t, key)...)...)
require.NoError(t, err, "import public key")
return id
}
func importCertificate(t *testing.T, uri string, leaf *x509.Certificate, id []byte) {
t.Helper()
session := readWriteSession(t, uri)
defer session.Close()
serial, err := asn1.Marshal(leaf.SerialNumber)
require.NoError(t, err)
_, err = session.CreateObject(
attr(pkcs11.AttrClass, pkcs11.ULong(pkcs11.ClassCertificate)),
attr(pkcs11.AttrCertificateType, pkcs11.ULong(pkcs11.CertificateX509)),
attr(pkcs11.AttrToken, ckTrue),
attr(attrPrivate, ckFalse),
attr(pkcs11.AttrLabel, []byte(leaf.Subject.CommonName)),
attr(pkcs11.AttrID, id),
attr(pkcs11.AttrSubject, leaf.RawSubject),
attr(attrIssuer, leaf.RawIssuer),
attr(attrSerialNumber, serial),
attr(pkcs11.AttrValue, leaf.Raw),
)
require.NoError(t, err, "import certificate")
}
func readWriteSession(t *testing.T, uri string) *pkcs11.Session {
t.Helper()
parsed, err := pkcs11.ParseURI(uri)
require.NoError(t, err)
module, err := pkcs11.Load(parsed.Module())
require.NoError(t, err)
pin, err := parsed.PIN()
require.NoError(t, err)
session, err := module.OpenReadWriteSession(parsed.Token, pin)
require.NoError(t, err)
return session
}
func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrValue, k.D.FillBytes(make([]byte, 32))),
}
case *rsa.PrivateKey:
k.Precompute()
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
attr(attrPrivateExponent, k.D.Bytes()),
attr(attrPrime1, k.Primes[0].Bytes()),
attr(attrPrime2, k.Primes[1].Bytes()),
attr(attrExponent1, k.Precomputed.Dp.Bytes()),
attr(attrExponent2, k.Precomputed.Dq.Bytes()),
attr(attrCoefficient, k.Precomputed.Qinv.Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
// publicKeyAttributes describes the CKO_PUBLIC_KEY object tokens keep next to a private
// key, which is what the store reads to pair a certificate file with its key.
func publicKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
point := append([]byte{4}, k.X.FillBytes(make([]byte, 32))...)
point = append(point, k.Y.FillBytes(make([]byte, 32))...)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyEC)),
attr(pkcs11.AttrECParams, oidP256),
attr(pkcs11.AttrECPoint, wrapped),
}
case *rsa.PrivateKey:
return []pkcs11.Attribute{
attr(pkcs11.AttrKeyType, pkcs11.ULong(pkcs11.KeyRSA)),
attr(pkcs11.AttrModulus, k.N.Bytes()),
attr(pkcs11.AttrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
func attr(typ uint, value []byte) pkcs11.Attribute {
return pkcs11.Attribute{Type: typ, Value: value}
}
// pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when
// no token is configured or this build lacks PKCS#11 support.
func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) {
t.Helper()
uri := os.Getenv(testPKCS11URIEnv)
if uri == "" {
t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv)
}
store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir)
require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err)
}
return store, uri
}
// TestCollect_PKCS11KeyWithFileCertificate covers the split layout: the key lives on the
// token, the certificate is a PEM file in the directory, and the two are paired by public
// key because nothing on the token carries the certificate's CKA_ID.
func TestCollect_PKCS11KeyWithFileCertificate(t *testing.T) {
dir := t.TempDir()
store, uri := pkcs11TestStore(t, dir)
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
t.Run(name, func(t *testing.T) {
ca := certtest.NewCA(t, "corp-file-"+name)
leaf := ca.Issue(t, key, "device-file-"+name)
importKey(t, uri, key, "device-file-"+name)
writeFile(t, dir, "device-"+name+".pem", certtest.CertPEM(leaf))
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), store, checks, peerKey)
require.Len(t, proofs, 1, "the token key must prove the certificate kept on disk")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the certificate from the directory")
})
}
}
func TestPKCS11Store_FileChains(t *testing.T) {
ca := certtest.NewCA(t, "corp")
dir := t.TempDir()
// Only certificate files without a key of their own belong to the token; the file
// store answers for the others, and non-certificate files are ignored.
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, certtest.ECDSAKey(t), "device")))
writeFile(t, dir, "ca.crt", ca.PEM)
keyed := certtest.ECDSAKey(t)
writeFile(t, dir, "inline.pem", certtest.CertPEM(ca.Issue(t, keyed, "inline"))+certtest.KeyPEM(t, keyed))
writeFile(t, dir, "sibling.crt", certtest.CertPEM(ca.Issue(t, keyed, "sibling")))
writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed))
writeFile(t, dir, "notes.txt", "not a certificate")
chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains()
require.NoError(t, err)
var subjects []string
for _, chain := range chains {
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token")
chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains()
require.NoError(t, err)
assert.Empty(t, chains, "no directory configured means no file certificates")
}
func TestNewPKCS11Store_PIN(t *testing.T) {
tests := []struct {
name string
cfg PKCS11Config
wantPIN []byte
wantModule string
}{
{"pin alone opens the first p11-kit token", PKCS11Config{PIN: "1234"}, []byte("1234"), pkcs11.DefaultModule},
{"pin field wins over pin-value", PKCS11Config{URI: "pkcs11:?module-path=/lib/x.so&pin-value=0000", PIN: "1234"}, []byte("1234"), "/lib/x.so"},
{"uri pin-value stands in for a missing field", PKCS11Config{URI: "pkcs11:?pin-value=0000"}, []byte("0000"), pkcs11.DefaultModule},
{"no pin at all means no login", PKCS11Config{URI: "pkcs11:token=netbird"}, nil, pkcs11.DefaultModule},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store, err := NewPKCS11Store(tt.cfg, "")
require.NoError(t, err)
pin, err := store.userPIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
assert.Equal(t, tt.wantModule, store.uri.Module(), "module to load")
})
}
_, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "")
assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults")
}
+215
View File
@@ -0,0 +1,215 @@
package certproof
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/tpm"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
type Store interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// Config selects where the Linux daemon looks for certificates: Dir is the PEM directory,
// empty for NB_CERT_STORE_DIR or /etc/netbird/certs, and PKCS11 names a token whose keys
// sign for certificates on the token or in that directory.
type Config struct {
Dir string
PKCS11 PKCS11Config
}
func (c Config) dir() string {
if c.Dir != "" {
return c.Dir
}
return StoreDir()
}
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
// either its private key or a sibling "<name>.key" file holds it. The key is a plain
// PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with.
type FileStore struct {
dir string
}
func NewFileStore(dir string) *FileStore {
return &FileStore{dir: dir}
}
func StoreDir() string {
if dir := os.Getenv(StoreDirEnv); dir != "" {
return dir
}
return defaultStoreDir
}
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
paths, err := certFiles(s.dir)
if err != nil {
return nil, err
}
var candidates []Candidate
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
if signer == nil {
log.Debugf("certificate %s has no key file, only a token can sign for it", path)
continue
}
candidates = append(candidates, Candidate{Chain: chain, Signer: signer})
}
return candidates, nil
}
// certFiles lists the certificate files in dir, none when the directory does not exist.
func certFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", dir, err)
}
var paths []string
for _, entry := range entries {
if !entry.IsDir() && isCertFile(entry.Name()) {
paths = append(paths, filepath.Join(dir, entry.Name()))
}
}
return paths, nil
}
// loadPEM reads a certificate file and its private key, held in the file itself or in
// the sibling "<name>.key" file. The signer is nil when neither holds a key.
func loadPEM(path string) ([]*x509.Certificate, crypto.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return nil, nil, err
}
if len(chain) == 0 {
return nil, nil, errors.New("no certificate")
}
if signer != nil {
return chain, signer, nil
}
keyData, err := os.ReadFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if errors.Is(err, os.ErrNotExist) {
return chain, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("read key file: %w", err)
}
if _, signer, err = parsePEM(keyData); err != nil {
return nil, nil, err
}
if signer == nil {
return nil, nil, errors.New("no private key in key file")
}
return chain, signer, nil
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
return chain, signer, nil
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tpm.KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
}
signer = key
}
}
}
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case tpm.KeyPEMType:
return tpm.ParseKey(block.Bytes)
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("private key cannot sign")
}
return signer, nil
}
func isCertFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".pem", ".crt", ".cer":
return true
}
return false
}
// Stores queries several stores and carries on when one fails, so a broken token cannot
// hide the certificates a directory holds. A failure is logged instead of returned
// because Collect treats a store error as "no proofs at all".
type Stores []Store
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
var all []Candidate
for _, store := range s {
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("certificate store %T unavailable: %v", store, err)
continue
}
all = append(all, candidates...)
}
return all, nil
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !darwin && !windows
package certproof
import log "github.com/sirupsen/logrus"
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
func DefaultStore() Store {
return NewFileStore(StoreDir())
}
// storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg
// names one. The token pairs the directory's key-less certificates with its own keys.
func storeWithToken(cfg Config) Store {
files := NewFileStore(cfg.dir())
if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" {
return files
}
token, err := NewPKCS11Store(cfg.PKCS11, cfg.dir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
}
return Stores{files, token}
}
@@ -0,0 +1,36 @@
//go:build !darwin && !windows
package certproof
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStoreWithToken(t *testing.T) {
dir := t.TempDir()
files, ok := storeWithToken(Config{Dir: dir}).(*FileStore)
require.True(t, ok, "a directory alone reads that directory alone")
assert.Equal(t, dir, files.dir, "the configured directory replaces the default")
files, ok = storeWithToken(Config{}).(*FileStore)
require.True(t, ok, "nothing configured reads the PEM directory alone")
assert.Equal(t, StoreDir(), files.dir, "no directory configured falls back to the environment or the default")
assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory")
for name, cfg := range map[string]PKCS11Config{
"pin alone": {PIN: "1234"},
"uri alone": {URI: "pkcs11:token=netbird?pin-value=1234"},
} {
store, ok := storeWithToken(Config{Dir: dir, PKCS11: cfg}).(Stores)
require.True(t, ok, "%s joins the token to the PEM directory", name)
require.Len(t, store, 2, name)
token, ok := store[1].(*PKCS11Store)
require.True(t, ok, name)
assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name)
}
}
@@ -0,0 +1,98 @@
package certproof
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestFileStore_TPMKeyFile(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
// A key that needs a password can never be used silently, so its certificate is skipped.
locked := certtest.ECDSAKey(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), withAuth))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
}
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
// swtpm started with:
//
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
//
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
// hierarchy, and proves the certificate for it through the regular file store.
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
if os.Getenv(tpm.DeviceEnv) == "" {
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
}
public, private := createTPMKey(t)
keyPEM := tpmtest.EncodePEM(t, public, private)
block, _ := pem.Decode([]byte(keyPEM))
require.NotNil(t, block)
signer, err := tpm.ParseKey(block.Bytes)
require.NoError(t, err)
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
}
func createTPMKey(t *testing.T) (public, private []byte) {
t.Helper()
rwc, err := tpm.Open()
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tss2.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
require.NoError(t, err)
return public, private
}
@@ -0,0 +1,251 @@
package certproof
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
"slices"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
personalStore = "MY"
intermediateStore = "CA"
cryptAcquireSilentFlag = 0x00000040
cryptAcquirePreferNCryptKeyFlag = 0x00020000
certNCryptKeySpec = 0xFFFFFFFF
bcryptPadPSS = 0x00000008
)
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
)
type bcryptPSSPaddingInfo struct {
algID *uint16
salt uint32
}
// DefaultStore is the local machine's personal certificate store, where device
// certificates enrolled through AD or Intune are kept.
func DefaultStore() Store {
return NewSystemStore()
}
// SystemStore yields the identities of a personal certificate store, completing their
// chains from the matching intermediate CA store. Keys are used through CNG and never
// exported.
//
// The location decides whose certificates these are. The local machine store is the one
// a service reads; the current user store lives in the signed-in user's registry hive
// with keys protected against their profile, so it is only readable while running as
// that user.
type SystemStore struct {
location uint32
}
// NewSystemStore reads the local machine store, which is what the daemon uses.
func NewSystemStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE}
}
// NewUserStore reads the calling user's personal store. It is only useful in a process
// already running as that user, which is what the posture helper is.
func NewUserStore() *SystemStore {
return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER}
}
func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
leaves, err := storeCertificates(s.location, personalStore)
if err != nil {
return nil, err
}
intermediates, err := storeCertificates(s.location, intermediateStore)
if err != nil {
return nil, err
}
log.Infof("certificate store %s holds %d personal certificates and %d intermediates", s, len(leaves), len(intermediates))
if len(leaves) == 0 {
return nil, nil
}
pool := slices.Concat(intermediates, leaves)
candidates := make([]Candidate, 0, len(leaves))
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Infof("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}})
}
return candidates, nil
}
// String names the store location the way the Windows documentation does.
func (s *SystemStore) String() string {
if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER {
return "CurrentUser"
}
return "LocalMachine"
}
// systemStoreSigner holds only the certificate; the store entry and its key are acquired
// at signing time so no handles outlive a call.
type systemStoreSigner struct {
leaf *x509.Certificate
location uint32
}
func (s *systemStoreSigner) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *systemStoreSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
store, err := openStore(s.location, personalStore)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var signature []byte
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
if !bytes.Equal(encodedCert(ctx), s.leaf.Raw) {
return false, nil
}
signature, err = signWithContext(ctx, scheme, digest)
return true, err
})
if err != nil {
return nil, err
}
if signature == nil {
return nil, errors.New("certificate is no longer in the personal store")
}
return signature, nil
}
func signWithContext(ctx *windows.CertContext, scheme sigScheme, digest []byte) ([]byte, error) {
var key uintptr
var keySpec uint32
var callerFree int32
ok, _, err := procCryptAcquireCertificatePrivateKey.Call(uintptr(unsafe.Pointer(ctx)), cryptAcquireSilentFlag|cryptAcquirePreferNCryptKeyFlag, 0,
uintptr(unsafe.Pointer(&key)), uintptr(unsafe.Pointer(&keySpec)), uintptr(unsafe.Pointer(&callerFree)))
if ok == 0 {
return nil, fmt.Errorf("acquire private key: %w", err)
}
if keySpec != certNCryptKeySpec {
if callerFree != 0 {
_ = windows.CryptReleaseContext(windows.Handle(key), 0)
}
return nil, errors.New("legacy CryptoAPI keys are not supported")
}
if callerFree != 0 {
defer func() { _, _, _ = procNCryptFreeObject.Call(key) }()
}
var padding unsafe.Pointer
var flags uintptr
if scheme == schemeRSAPSSSHA256 {
algID, _ := windows.UTF16PtrFromString("SHA256")
info := bcryptPSSPaddingInfo{algID: algID, salt: sha256.Size}
padding, flags = unsafe.Pointer(&info), bcryptPadPSS
}
size, err := ncryptSignHash(key, padding, digest, nil, flags)
if err != nil {
return nil, err
}
signature := make([]byte, size)
if size, err = ncryptSignHash(key, padding, digest, signature, flags); err != nil {
return nil, err
}
signature = signature[:size]
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
func ncryptSignHash(key uintptr, padding unsafe.Pointer, digest, signature []byte, flags uintptr) (uint32, error) {
var result uint32
var signaturePtr uintptr
if len(signature) > 0 {
signaturePtr = uintptr(unsafe.Pointer(&signature[0]))
}
status, _, _ := procNCryptSignHash.Call(key, uintptr(padding), uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
signaturePtr, uintptr(len(signature)), uintptr(unsafe.Pointer(&result)), flags)
if uint32(status) != 0 {
return 0, fmt.Errorf("NCryptSignHash: 0x%08x", uint32(status))
}
return result, nil
}
func storeCertificates(location uint32, name string) ([]*x509.Certificate, error) {
store, err := openStore(location, name)
if err != nil {
return nil, err
}
defer func() { _ = windows.CertCloseStore(store, 0) }()
var certs []*x509.Certificate
err = eachCertificate(store, func(ctx *windows.CertContext) (bool, error) {
cert, err := x509.ParseCertificate(bytes.Clone(encodedCert(ctx)))
if err != nil {
log.Warnf("skipping certificate in %s store: %v", name, err)
return false, nil
}
certs = append(certs, cert)
return false, nil
})
return certs, err
}
func openStore(location uint32, name string) (windows.Handle, error) {
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return 0, err
}
flags := location | uint32(windows.CERT_STORE_READONLY_FLAG|windows.CERT_STORE_OPEN_EXISTING_FLAG)
store, err := windows.CertOpenStore(windows.CERT_STORE_PROV_SYSTEM, 0, 0, flags, uintptr(unsafe.Pointer(namePtr)))
if err != nil {
return 0, fmt.Errorf("open %s certificate store: %w", name, err)
}
return store, nil
}
func eachCertificate(store windows.Handle, fn func(*windows.CertContext) (bool, error)) error {
var ctx *windows.CertContext
for {
next, err := windows.CertEnumCertificatesInStore(store, ctx)
if next == nil {
if errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) || errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil
}
return fmt.Errorf("enumerate certificates: %w", err)
}
ctx = next
if stop, err := fn(ctx); stop || err != nil {
_ = windows.CertFreeCertificateContext(ctx)
return err
}
}
}
func encodedCert(ctx *windows.CertContext) []byte {
return unsafe.Slice(ctx.EncodedCert, ctx.Length)
}
+6
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -675,6 +676,11 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
LazyConnection: lazyconn.ParseState(config.LazyConnection),
CertStore: certproof.Config{
Dir: config.CertStoreDir,
PKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN},
},
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
+18
View File
@@ -37,6 +37,7 @@ import (
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
@@ -170,6 +171,8 @@ type EngineConfig struct {
MTU uint16
CertStore certproof.Config
// for debug bundle generation
ProfileConfig *profilemanager.Config
@@ -1240,6 +1243,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}
e.applyInfoFlags(info)
e.attachCertificateProofs(info, checks)
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
@@ -1271,9 +1275,17 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
)
}
// attachCertificateProofs answers the certificate challenges in checks with the
// certificates reachable on this device, signing each challenge nonce for our peer key.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
peerKey := e.config.WgPrivateKey.PublicKey()
info.CertificateProofs = certproof.CollectProofs(e.ctx, checks, peerKey[:], e.config.CertStore)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
return info
}
@@ -1289,6 +1301,7 @@ func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context)
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
return info
}
}
@@ -2746,6 +2759,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
sortedFiles := slices.Clone(check.Files)
sort.Strings(sortedFiles)
normalized[i] = strings.Join(sortedFiles, "|")
if challenge := check.GetCertificateChallenge(); challenge != nil {
sortedCAs := slices.Clone(challenge.GetCaCertificates())
sort.Strings(sortedCAs)
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
}
}
sort.Strings(normalized)
+26
View File
@@ -1179,6 +1179,32 @@ func Test_CheckFilesEqual(t *testing.T) {
},
expectedBool: true,
},
{
name: "Same files with rotated certificate challenge nonce should return false",
inputChecks1: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a"}},
},
},
inputChecks2: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{2}, CaCertificates: []string{"ca-a"}},
},
},
expectedBool: false,
},
{
name: "Same certificate challenge with CA certificates in different order should return true",
inputChecks1: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a", "ca-b"}}},
},
inputChecks2: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-b", "ca-a"}}},
},
expectedBool: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
+328
View File
@@ -0,0 +1,328 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"errors"
"fmt"
"runtime"
"strings"
"unsafe"
"github.com/ebitengine/purego"
)
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
// this file builds for. The struct layouts below assume that width and natural alignment.
type ulong = uintptr
const (
unavailableInformation = ^ulong(0)
flagOSLockingOK = 0x2
flagRWSession = 0x2
flagSerialSession = 0x4
userTypeUser = 0x1
findBatch = 32
)
type version struct {
major byte
minor byte
}
type attribute struct {
typ ulong
value unsafe.Pointer
len ulong
}
type mechanism struct {
typ ulong
parameter unsafe.Pointer
len ulong
}
type pssParams struct {
hashAlg ulong
mgf ulong
saltLen ulong
}
type tokenInfo struct {
label [32]byte
manufacturerID [32]byte
model [16]byte
serialNumber [16]byte
flags ulong
counters [10]ulong
hardware version
firmware version
utcTime [16]byte
}
type initializeArgs struct {
createMutex uintptr
destroyMutex uintptr
lockMutex uintptr
unlockMutex uintptr
flags ulong
reserved unsafe.Pointer
}
// functionList mirrors CK_FUNCTION_LIST: a CK_VERSION padded to pointer alignment, then
// the PKCS#11 v2.40 entry points in specification order.
type functionList struct {
version version
_ [6]byte
fn [68]uintptr
}
const (
fnInitialize = 0
fnGetSlotList = 4
fnGetTokenInfo = 6
fnOpenSession = 12
fnCloseSession = 13
fnLogin = 18
fnLogout = 19
fnCreateObject = 20
fnGetAttributeValue = 24
fnFindObjectsInit = 26
fnFindObjects = 27
fnFindObjectsFinal = 28
fnSignInit = 42
fnSign = 43
)
// module holds the entry points of one loaded library, bound straight from its
// CK_FUNCTION_LIST.
type module struct {
cInitialize func(args *initializeArgs) ulong
cGetSlotList func(tokenPresent byte, slots *ulong, count *ulong) ulong
cGetTokenInfo func(slot ulong, info *tokenInfo) ulong
cOpenSession func(slot ulong, flags ulong, application unsafe.Pointer, notify uintptr, session *ulong) ulong
cCloseSession func(session ulong) ulong
cLogin func(session ulong, userType ulong, pin *byte, pinLen ulong) ulong
cLogout func(session ulong) ulong
cCreateObject func(session ulong, template *attribute, count ulong, object *ulong) ulong
cGetAttributeValue func(session ulong, object ulong, template *attribute, count ulong) ulong
cFindObjectsInit func(session ulong, template *attribute, count ulong) ulong
cFindObjects func(session ulong, objects *ulong, max ulong, count *ulong) ulong
cFindObjectsFinal func(session ulong) ulong
cSignInit func(session ulong, mech *mechanism, key ulong) ulong
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
}
func load(path string) (driver, error) {
lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
if err != nil {
return nil, fmt.Errorf("open PKCS#11 module %s: %w", path, err)
}
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
if err != nil {
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
}
var getFunctionList func(list **functionList) ulong
purego.RegisterFunc(&getFunctionList, symbol)
var list *functionList
if rv := getFunctionList(&list); rv != rvOK || list == nil {
return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)}
}
m := &module{}
for _, entry := range []struct {
fn any
index int
}{
{&m.cInitialize, fnInitialize},
{&m.cGetSlotList, fnGetSlotList},
{&m.cGetTokenInfo, fnGetTokenInfo},
{&m.cOpenSession, fnOpenSession},
{&m.cCloseSession, fnCloseSession},
{&m.cLogin, fnLogin},
{&m.cLogout, fnLogout},
{&m.cCreateObject, fnCreateObject},
{&m.cGetAttributeValue, fnGetAttributeValue},
{&m.cFindObjectsInit, fnFindObjectsInit},
{&m.cFindObjects, fnFindObjects},
{&m.cFindObjectsFinal, fnFindObjectsFinal},
{&m.cSignInit, fnSignInit},
{&m.cSign, fnSign},
} {
if list.fn[entry.index] == 0 {
return nil, fmt.Errorf("%s lacks PKCS#11 entry point %d", path, entry.index)
}
purego.RegisterFunc(entry.fn, list.fn[entry.index])
}
args := &initializeArgs{flags: flagOSLockingOK}
if rv := m.cInitialize(args); rv != rvOK && rv != rvAlreadyInitialized {
return nil, Error{Op: "C_Initialize", Code: uint(rv)}
}
return m, nil
}
func (m *module) tokens() ([]Token, error) {
var count ulong
if rv := m.cGetSlotList(1, nil, &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
if count == 0 {
return nil, nil
}
slots := make([]ulong, count)
if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
tokens := make([]Token, 0, count)
for _, slot := range slots[:count] {
var info tokenInfo
if rv := m.cGetTokenInfo(slot, &info); rv != rvOK {
continue
}
tokens = append(tokens, Token{Slot: uint(slot), Label: strings.TrimRight(string(info.label[:]), " \x00")})
}
return tokens, nil
}
func (m *module) openSession(slot uint, readWrite bool) (uint, error) {
flags := ulong(flagSerialSession)
if readWrite {
flags |= flagRWSession
}
var session ulong
if rv := m.cOpenSession(ulong(slot), flags, nil, 0, &session); rv != rvOK {
return 0, Error{Op: "C_OpenSession", Code: uint(rv)}
}
return uint(session), nil
}
func (m *module) closeSession(session uint) {
m.cCloseSession(ulong(session))
}
func (m *module) login(session uint, pin []byte) error {
var pinPtr *byte
if len(pin) > 0 {
pinPtr = &pin[0]
}
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
runtime.KeepAlive(pin)
if rv != rvOK && rv != rvUserAlreadyLoggedIn {
return Error{Op: "C_Login", Code: uint(rv)}
}
return nil
}
func (m *module) logout(session uint) {
m.cLogout(ulong(session))
}
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
attrs := toAttributes(template)
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
runtime.KeepAlive(template)
if rv != rvOK {
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
}
defer m.cFindObjectsFinal(ulong(session))
var objects []Object
for {
var batch [findBatch]ulong
var count ulong
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
}
for _, handle := range batch[:count] {
objects = append(objects, Object(handle))
}
if count < findBatch {
return objects, nil
}
}
}
func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
attr := attribute{typ: ulong(typ)}
if rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1); rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len == unavailableInformation {
return nil, fmt.Errorf("attribute 0x%x is unavailable", typ)
}
if attr.len == 0 {
return nil, nil
}
value := make([]byte, attr.len)
attr.value = unsafe.Pointer(&value[0])
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
runtime.KeepAlive(value)
if rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
return value[:attr.len], nil
}
func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("nothing to sign")
}
native := mechanism{typ: ulong(mech.Type)}
var params *pssParams
if mech.PSS != nil {
params = &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
native.parameter = unsafe.Pointer(params)
native.len = ulong(unsafe.Sizeof(*params))
}
rv := m.cSignInit(ulong(session), &native, ulong(key))
runtime.KeepAlive(params)
if rv != rvOK {
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
}
var size ulong
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
signature := make([]byte, size)
rv = m.cSign(ulong(session), &data[0], ulong(len(data)), &signature[0], &size)
runtime.KeepAlive(data)
if rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
return signature[:size], nil
}
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
attrs := toAttributes(template)
var object ulong
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
runtime.KeepAlive(template)
if rv != rvOK {
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
}
return Object(object), nil
}
func toAttributes(template []Attribute) []attribute {
attrs := make([]attribute, len(template))
for i, a := range template {
attrs[i].typ = ulong(a.Type)
if len(a.Value) > 0 {
attrs[i].value = unsafe.Pointer(&a.Value[0])
attrs[i].len = ulong(len(a.Value))
}
}
return attrs
}
func first(attrs []attribute) *attribute {
if len(attrs) == 0 {
return nil
}
return &attrs[0]
}
@@ -0,0 +1,72 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"crypto/x509"
"os"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStructLayoutsMatchTheCABI(t *testing.T) {
assert.Equal(t, uintptr(24), unsafe.Sizeof(attribute{}), "CK_ATTRIBUTE")
assert.Equal(t, uintptr(24), unsafe.Sizeof(mechanism{}), "CK_MECHANISM")
assert.Equal(t, uintptr(24), unsafe.Sizeof(pssParams{}), "CK_RSA_PKCS_PSS_PARAMS")
assert.Equal(t, uintptr(208), unsafe.Sizeof(tokenInfo{}), "CK_TOKEN_INFO")
assert.Equal(t, uintptr(48), unsafe.Sizeof(initializeArgs{}), "CK_C_INITIALIZE_ARGS")
assert.Equal(t, uintptr(8), unsafe.Offsetof(functionList{}.fn), "entry points follow the padded CK_VERSION")
assert.Equal(t, uintptr(8+68*8), unsafe.Sizeof(functionList{}), "CK_FUNCTION_LIST v2.40")
}
// TestTrustModule_ListsSystemCertificates drives a real module through the binding:
// p11-kit's trust module exposes the system CA store as certificate objects with no login.
func TestTrustModule_ListsSystemCertificates(t *testing.T) {
module := loadFirst(t,
"/usr/lib/pkcs11/p11-kit-trust.so",
"/usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib/aarch64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib64/pkcs11/p11-kit-trust.so",
)
tokens, err := module.Tokens()
require.NoError(t, err)
require.NotEmpty(t, tokens, "the trust module must present at least one token")
parsed := 0
for _, token := range tokens {
session, err := module.OpenSession(token.Label, nil)
require.NoError(t, err, token.Label)
objects, err := session.FindObjects(
Attribute{Type: AttrClass, Value: ULong(ClassCertificate)},
Attribute{Type: AttrCertificateType, Value: ULong(CertificateX509)},
)
require.NoError(t, err, token.Label)
for _, object := range objects {
der, err := session.Attribute(object, AttrValue)
require.NoError(t, err)
_, err = x509.ParseCertificate(der)
require.NoError(t, err, "CKA_VALUE must be a DER certificate")
parsed++
}
session.Close()
}
assert.Positive(t, parsed, "system trust anchors must be readable through the binding")
}
func loadFirst(t *testing.T, paths ...string) *Module {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
module, err := Load(path)
require.NoError(t, err, path)
return module
}
t.Skip("p11-kit trust module not installed")
return nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !(pkcs11 && linux && (amd64 || arm64))
package pkcs11
func load(string) (driver, error) {
return nil, ErrUnsupported
}
+259
View File
@@ -0,0 +1,259 @@
// Package pkcs11 is a minimal PKCS#11 client. It loads a module at runtime without cgo,
// opens a token session, lists objects and signs with keys the token holds. It exists so
// certificates whose keys live in a TPM behind tpm2-pkcs11 can be proven; whatever a
// certificate store does not need is left out.
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"sync"
)
// Object classes, attribute types, mechanisms and generators from PKCS#11 v2.40.
const (
ClassCertificate = 0x1
ClassPublicKey = 0x2
ClassPrivateKey = 0x3
CertificateX509 = 0x0
AttrClass = 0x0
AttrToken = 0x1
AttrLabel = 0x3
AttrValue = 0x11
AttrCertificateType = 0x80
AttrKeyType = 0x100
AttrSubject = 0x101
AttrID = 0x102
AttrModulus = 0x120
AttrPublicExponent = 0x122
AttrECParams = 0x180
AttrECPoint = 0x181
KeyRSA = 0x0
KeyEC = 0x3
MechRSAPKCSPSS = 0xd
MechSHA256 = 0x250
MechSHA384 = 0x260
MechECDSA = 0x1041
MGF1SHA256 = 0x2
MGF1SHA384 = 0x3
rvOK = 0x0
rvUserAlreadyLoggedIn = 0x100
rvAlreadyInitialized = 0x191
)
var ErrUnsupported = errors.New("PKCS#11 modules need a build with the pkcs11 tag on linux/amd64 or linux/arm64")
// Error is a PKCS#11 return value other than CKR_OK.
type Error struct {
Op string
Code uint
}
func (e Error) Error() string {
if name, ok := returnValueNames[e.Code]; ok {
return fmt.Sprintf("%s: %s", e.Op, name)
}
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
}
var returnValueNames = map[uint]string{
0x2: "CKR_HOST_MEMORY",
0x3: "CKR_SLOT_ID_INVALID",
0x5: "CKR_GENERAL_ERROR",
0x7: "CKR_ARGUMENTS_BAD",
0x12: "CKR_ATTRIBUTE_TYPE_INVALID",
0x13: "CKR_ATTRIBUTE_VALUE_INVALID",
0x30: "CKR_DEVICE_ERROR",
0x54: "CKR_FUNCTION_NOT_SUPPORTED",
0x68: "CKR_KEY_FUNCTION_NOT_PERMITTED",
0x70: "CKR_MECHANISM_INVALID",
0x71: "CKR_MECHANISM_PARAM_INVALID",
0x82: "CKR_OBJECT_HANDLE_INVALID",
0xa0: "CKR_PIN_INCORRECT",
0xa4: "CKR_PIN_LOCKED",
0xb3: "CKR_SESSION_HANDLE_INVALID",
0xd0: "CKR_TEMPLATE_INCOMPLETE",
0xd1: "CKR_TEMPLATE_INCONSISTENT",
0xe0: "CKR_TOKEN_NOT_PRESENT",
0x101: "CKR_USER_NOT_LOGGED_IN",
0x150: "CKR_BUFFER_TOO_SMALL",
0x190: "CKR_CRYPTOKI_NOT_INITIALIZED",
}
// Attribute is one entry of a PKCS#11 template. Integer-valued attributes such as the
// object class are encoded with ULong.
type Attribute struct {
Type uint
Value []byte
}
// Mechanism selects a signing algorithm. PSS carries the parameters CKM_RSA_PKCS_PSS needs.
type Mechanism struct {
Type uint
PSS *PSSParams
}
type PSSParams struct {
Hash uint
MGF uint
SaltLen uint
}
// Object is a handle the token issued for one of its objects.
type Object uint
// Token is a slot with a token present.
type Token struct {
Slot uint
Label string
}
// Module is a loaded and initialised PKCS#11 library. A module is loaded once per path
// and never finalised: tokens such as tpm2-pkcs11 do real work in C_Initialize, and the
// process exit releases everything anyway.
type Module struct {
d driver
}
var (
modulesMu sync.Mutex
modules = map[string]*Module{}
)
// Load opens the shared library at path and initialises it, or returns the module already
// loaded from that path.
func Load(path string) (*Module, error) {
modulesMu.Lock()
defer modulesMu.Unlock()
if m, ok := modules[path]; ok {
return m, nil
}
d, err := load(path)
if err != nil {
return nil, err
}
m := &Module{d: d}
modules[path] = m
return m, nil
}
func (m *Module) Tokens() ([]Token, error) {
return m.d.tokens()
}
// OpenSession opens a read-only session with the token carrying label, or with the first
// token when label is empty, and logs in as the user when pin is not nil. An empty,
// non-nil pin still logs in.
func (m *Module) OpenSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, false)
}
// OpenReadWriteSession is OpenSession for callers that create objects on the token.
func (m *Module) OpenReadWriteSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, true)
}
func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session, error) {
token, err := m.token(label)
if err != nil {
return nil, err
}
handle, err := m.d.openSession(token.Slot, readWrite)
if err != nil {
return nil, err
}
s := &Session{d: m.d, handle: handle}
if pin == nil {
return s, nil
}
if err := m.d.login(handle, pin); err != nil {
s.Close()
return nil, err
}
s.loggedIn = true
return s, nil
}
func (m *Module) token(label string) (Token, error) {
tokens, err := m.Tokens()
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if label == "" || token.Label == label {
return token, nil
}
}
if label == "" {
return Token{}, errors.New("no token present")
}
return Token{}, fmt.Errorf("no token labelled %q among %d tokens", label, len(tokens))
}
// Session is an open session with one token. Close logs out again if the session logged in.
type Session struct {
d driver
handle uint
loggedIn bool
}
func (s *Session) Close() {
if s.loggedIn {
s.d.logout(s.handle)
}
s.d.closeSession(s.handle)
}
// FindObjects returns the handles of every object matching all attributes of template.
func (s *Session) FindObjects(template ...Attribute) ([]Object, error) {
return s.d.findObjects(s.handle, template)
}
// Attribute reads one attribute of an object.
func (s *Session) Attribute(obj Object, typ uint) ([]byte, error) {
return s.d.attribute(s.handle, obj, typ)
}
// Sign signs data, normally a digest, with the token-held key in a single operation.
func (s *Session) Sign(mech Mechanism, key Object, data []byte) ([]byte, error) {
return s.d.sign(s.handle, mech, key, data)
}
// CreateObject stores a new object described by template on the token.
func (s *Session) CreateObject(template ...Attribute) (Object, error) {
return s.d.createObject(s.handle, template)
}
type driver interface {
tokens() ([]Token, error)
openSession(slot uint, readWrite bool) (uint, error)
closeSession(session uint)
login(session uint, pin []byte) error
logout(session uint)
findObjects(session uint, template []Attribute) ([]Object, error)
attribute(session uint, obj Object, typ uint) ([]byte, error)
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
createObject(session uint, template []Attribute) (Object, error)
}
// ulongSize is the width of CK_ULONG on the 64-bit platforms the driver builds for.
const ulongSize = 8
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
func ULong(v uint) []byte {
return binary.NativeEndian.AppendUint64(nil, uint64(v))
}
func ulongValue(b []byte) (uint, error) {
if len(b) != ulongSize {
return 0, fmt.Errorf("CK_ULONG value has %d bytes", len(b))
}
return uint(binary.NativeEndian.Uint64(b)), nil
}
+95
View File
@@ -0,0 +1,95 @@
package pkcs11
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"math"
"math/big"
)
var curvesByOID = map[string]elliptic.Curve{
"1.2.840.10045.3.1.7": elliptic.P256(),
"1.3.132.0.34": elliptic.P384(),
"1.3.132.0.35": elliptic.P521(),
}
// PublicKey reads a CKO_PUBLIC_KEY object as a Go public key. RSA and EC keys are
// supported, the two kinds a certificate posture proof can be signed with.
func (s *Session) PublicKey(obj Object) (crypto.PublicKey, error) {
raw, err := s.Attribute(obj, AttrKeyType)
if err != nil {
return nil, err
}
keyType, err := ulongValue(raw)
if err != nil {
return nil, fmt.Errorf("CKA_KEY_TYPE: %w", err)
}
switch keyType {
case KeyRSA:
modulus, exponent, err := s.attributes(obj, AttrModulus, AttrPublicExponent)
if err != nil {
return nil, err
}
return rsaPublicKey(modulus, exponent)
case KeyEC:
params, point, err := s.attributes(obj, AttrECParams, AttrECPoint)
if err != nil {
return nil, err
}
return ecPublicKey(params, point)
}
return nil, fmt.Errorf("unsupported key type 0x%x", keyType)
}
func (s *Session) attributes(obj Object, first, second uint) ([]byte, []byte, error) {
a, err := s.Attribute(obj, first)
if err != nil {
return nil, nil, err
}
b, err := s.Attribute(obj, second)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) {
e := new(big.Int).SetBytes(exponent)
if e.Sign() <= 0 || e.Cmp(big.NewInt(math.MaxInt32)) > 0 {
return nil, errors.New("CKA_PUBLIC_EXPONENT is out of range")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: int(e.Int64())}, nil
}
// ecPublicKey decodes CKA_EC_PARAMS, the named curve OID, and CKA_EC_POINT, the
// uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare.
func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
var oid asn1.ObjectIdentifier
if _, err := asn1.Unmarshal(params, &oid); err != nil {
return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err)
}
curve, ok := curvesByOID[oid.String()]
if !ok {
return nil, fmt.Errorf("unsupported curve %s", oid)
}
size := (curve.Params().BitSize + 7) / 8
raw := point
if len(raw) != 1+2*size {
if _, err := asn1.Unmarshal(point, &raw); err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
}
if len(raw) != 1+2*size || raw[0] != 4 {
return nil, errors.New("CKA_EC_POINT is not an uncompressed point")
}
return &ecdsa.PublicKey{
Curve: curve,
X: new(big.Int).SetBytes(raw[1 : 1+size]),
Y: new(big.Int).SetBytes(raw[1+size:]),
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/asn1"
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestECPublicKey(t *testing.T) {
curves := []struct {
name string
curve elliptic.Curve
oid asn1.ObjectIdentifier
}{
{"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}},
{"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}},
}
for _, tt := range curves {
t.Run(tt.name, func(t *testing.T) {
key, err := ecdsa.GenerateKey(tt.curve, rand.Reader)
require.NoError(t, err)
params, err := asn1.Marshal(tt.oid)
require.NoError(t, err)
point := uncompressedPoint(key)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
// PKCS#11 wraps the point in an OCTET STRING, but some modules return it bare.
for form, encoded := range map[string][]byte{"DER octet string": wrapped, "bare point": point} {
pub, err := ecPublicKey(params, encoded)
require.NoError(t, err, form)
assert.True(t, key.PublicKey.Equal(pub), "%s must decode to the generated key", form)
}
})
}
}
func TestECPublicKey_Rejections(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
p256, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7})
require.NoError(t, err)
brainpool, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 36, 3, 3, 2, 8, 1, 1, 7})
require.NoError(t, err)
point := uncompressedPoint(key)
_, err = ecPublicKey(brainpool, point)
assert.Error(t, err, "curves the proof cannot use must be rejected")
_, err = ecPublicKey(p256, point[:len(point)-1])
assert.Error(t, err, "a truncated point must be rejected")
_, err = ecPublicKey([]byte("junk"), point)
assert.Error(t, err, "malformed parameters must be rejected")
}
func TestRSAPublicKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pub, err := rsaPublicKey(key.N.Bytes(), big.NewInt(int64(key.E)).Bytes())
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(pub), "modulus and exponent must decode to the generated key")
_, err = rsaPublicKey(key.N.Bytes(), nil)
assert.Error(t, err, "a missing exponent must be rejected")
}
func TestULongRoundTrip(t *testing.T) {
v, err := ulongValue(ULong(ClassPrivateKey))
require.NoError(t, err)
assert.Equal(t, uint(ClassPrivateKey), v)
_, err = ulongValue([]byte{1, 2, 3})
assert.Error(t, err, "a value of the wrong width must be rejected")
}
func uncompressedPoint(key *ecdsa.PrivateKey) []byte {
size := (key.Curve.Params().BitSize + 7) / 8
point := append([]byte{4}, key.X.FillBytes(make([]byte, size))...)
return append(point, key.Y.FillBytes(make([]byte, size))...)
}
+98
View File
@@ -0,0 +1,98 @@
package pkcs11
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
)
// DefaultModule is p11-kit's proxy, which exposes every module the system has registered,
// tpm2-pkcs11 included, so a URI without module-path works on a stock p11-kit setup.
const DefaultModule = "p11-kit-proxy.so"
// URI is the subset of an RFC 7512 PKCS#11 URI this client understands: the token label,
// the module to load and where the user PIN comes from. Unknown attributes are ignored.
type URI struct {
Token string
ModulePath string
pinValue *string
pinSource string
}
func ParseURI(raw string) (*URI, error) {
rest, ok := strings.CutPrefix(raw, "pkcs11:")
if !ok {
return nil, errors.New("PKCS#11 URI must start with pkcs11:")
}
path, query, _ := strings.Cut(rest, "?")
u := &URI{}
if err := eachAttribute(path, ";", func(name, value string) {
if name == "token" {
u.Token = value
}
}); err != nil {
return nil, err
}
err := eachAttribute(query, "&", func(name, value string) {
switch name {
case "module-path":
u.ModulePath = value
case "module-name":
u.ModulePath = "lib" + value + ".so"
case "pin-value":
u.pinValue = &value
case "pin-source":
u.pinSource = value
}
})
if err != nil {
return nil, err
}
return u, nil
}
func eachAttribute(list, sep string, fn func(name, value string)) error {
if list == "" {
return nil
}
for _, pair := range strings.Split(list, sep) {
name, value, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
}
fn(name, value)
}
return nil
}
// Module is the library to load, DefaultModule when the URI names none.
func (u *URI) Module() string {
if u.ModulePath == "" {
return DefaultModule
}
return u.ModulePath
}
// PIN returns the user PIN, or nil when the URI carries none and no login should happen.
// A pin-source names a file whose single line is the PIN.
func (u *URI) PIN() ([]byte, error) {
if u.pinValue != nil {
return []byte(*u.pinValue), nil
}
if u.pinSource == "" {
return nil, nil
}
path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:")
pin, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read PIN: %w", err)
}
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseURI(t *testing.T) {
tests := []struct {
name string
raw string
wantToken string
wantModule string
wantPIN []byte
}{
{
name: "token with module path and pin value",
raw: "pkcs11:token=netbird?module-path=/usr/lib/libtpm2_pkcs11.so&pin-value=1234",
wantToken: "netbird",
wantModule: "/usr/lib/libtpm2_pkcs11.so",
wantPIN: []byte("1234"),
},
{
name: "module name becomes a library file",
raw: "pkcs11:token=netbird?module-name=tpm2_pkcs11",
wantToken: "netbird",
wantModule: "libtpm2_pkcs11.so",
},
{
name: "percent encoding and unknown attributes",
raw: "pkcs11:model=SoftHSM%20v2;token=my%20token;serial=1?max-sessions=1",
wantToken: "my token",
wantModule: DefaultModule,
},
{
name: "bare scheme uses the p11-kit proxy and no login",
raw: "pkcs11:",
wantModule: DefaultModule,
},
{
name: "empty pin value still logs in",
raw: "pkcs11:?pin-value=",
wantModule: DefaultModule,
wantPIN: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uri, err := ParseURI(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.wantToken, uri.Token, "token label")
assert.Equal(t, tt.wantModule, uri.Module(), "module to load")
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
})
}
}
func TestParseURI_Rejections(t *testing.T) {
for _, raw := range []string{"pkcs11", "https://example.com", "pkcs11:token", "pkcs11:token=%zz"} {
_, err := ParseURI(raw)
assert.Error(t, err, raw)
}
}
func TestURI_PINFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "pin")
require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600))
for _, source := range []string{path, "file:" + path, "file://" + path} {
uri, err := ParseURI("pkcs11:token=netbird?pin-source=" + source)
require.NoError(t, err)
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, []byte("secret"), pin, "PIN from %s must drop the trailing newline", source)
}
uri, err := ParseURI("pkcs11:?pin-source=" + filepath.Join(t.TempDir(), "missing"))
require.NoError(t, err)
_, err = uri.PIN()
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
}
+14
View File
@@ -185,6 +185,20 @@ type Config struct {
ClientCertKeyPair *tls.Certificate `json:"-"`
// CertStoreDir is the directory of PEM certificates, with their keys or with keys a
// PKCS#11 token holds, that answer certificate posture checks on Linux. Empty means
// NB_CERT_STORE_DIR or /etc/netbird/certs; see client/internal/certproof/README.md.
CertStoreDir string
// CertPKCS11PIN is the user PIN of the PKCS#11 token, tpm2-pkcs11 for one, whose
// certificates answer certificate posture checks on Linux. Setting it enables the
// token store; see client/internal/certproof/README.md.
CertPKCS11PIN string
// CertPKCS11URI is the RFC 7512 URI selecting that token and its module. Empty means
// the first token the p11-kit proxy exposes.
CertPKCS11URI string
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
// Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"`
+29
View File
@@ -0,0 +1,29 @@
package tpm
import (
"errors"
"fmt"
"io"
"os"
"github.com/google/go-tpm/tpmutil"
)
// The kernel resource manager multiplexes clients and flushes what they leave behind,
// so it is tried before the raw device.
var devicePaths = []string{"/dev/tpmrm0", "/dev/tpm0"}
func open() (io.ReadWriteCloser, error) {
if path := os.Getenv(DeviceEnv); path != "" {
return tpmutil.OpenTPM(path)
}
var errs error
for _, path := range devicePaths {
rwc, err := tpmutil.OpenTPM(path)
if err == nil {
return rwc, nil
}
errs = errors.Join(errs, err)
}
return nil, fmt.Errorf("open TPM: %w", errs)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package tpm
import "io"
func open() (io.ReadWriteCloser, error) {
return nil, ErrUnsupported
}
+61
View File
@@ -0,0 +1,61 @@
package tpm
import (
"crypto"
"errors"
"fmt"
"io"
"go.step.sm/crypto/tpm/tss2"
)
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
// draft-bottomley-tpm2-keys and written by tpm2-openssl and tpm2-tss-engine.
const KeyPEMType = "TSS2 PRIVATE KEY"
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
// ParseKey reads a TSS2 key file and returns a signer that produces every signature
// inside the TPM; only the digest goes in and only the signature comes out. A key with
// a persistent parent is loaded under it, a key whose parent is a hierarchy under the
// TCG default ECC primary that tpm2-openssl and tpm2-tss-engine derive as well. Keys
// guarded by an authorization value are rejected, since nothing can supply it without
// prompting.
func ParseKey(der []byte) (crypto.Signer, error) {
key, err := tss2.ParsePrivateKey(der)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
}
if !key.EmptyAuth {
return nil, ErrKeyNeedsAuth
}
public, err := key.Public()
if err != nil {
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
}
return &keySigner{key: key, public: public}, nil
}
type keySigner struct {
key *tss2.TPMKey
public crypto.PublicKey
}
func (s *keySigner) Public() crypto.PublicKey {
return s.public
}
func (s *keySigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
rwc, err := Open()
if err != nil {
return nil, err
}
defer func() { _ = rwc.Close() }()
signer, err := tss2.CreateSigner(rwc, s.key)
if err != nil {
return nil, fmt.Errorf("load TSS2 key: %w", err)
}
signer.SetSRKTemplate(tss2.ECCSRKTemplate)
return signer.Sign(rand, digest, opts)
}
+64
View File
@@ -0,0 +1,64 @@
package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/pem"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
key := newP256Key(t)
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey)))
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(signer.Public()), "signer must expose the key the TPM holds")
}
func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) {
key := newP256Key(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, withAuth)))
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
}
func TestParseKey_RejectsMalformedKey(t *testing.T) {
_, err := ParseKey([]byte("not a TSS2 key"))
assert.Error(t, err)
}
func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
t.Setenv(DeviceEnv, filepath.Join(t.TempDir(), "missing"))
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &newP256Key(t).PublicKey)))
require.NoError(t, err)
digest := sha256.Sum256([]byte("challenge"))
_, err = signer.Sign(rand.Reader, digest[:], crypto.SHA256)
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
}
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func decodePEM(t *testing.T, pemData string) []byte {
t.Helper()
block, _ := pem.Decode([]byte(pemData))
require.NotNil(t, block)
require.Equal(t, KeyPEMType, block.Type)
return block.Bytes
}
+19
View File
@@ -0,0 +1,19 @@
// Package tpm is the client's one door to the platform TPM 2.0. It opens the device
// and turns TPM-held key files into signers; every operation opens the TPM, runs and
// closes it, so no handle outlives a call.
package tpm
import (
"errors"
"io"
)
// DeviceEnv overrides the TPM device path, which also lets tests point at a swtpm socket.
const DeviceEnv = "NB_TPM_DEVICE"
var ErrUnsupported = errors.New("TPM is not supported on this platform")
// Open connects to the platform TPM 2.0. The caller closes it after one operation.
func Open() (io.ReadWriteCloser, error) {
return open()
}
+48
View File
@@ -0,0 +1,48 @@
// Package tpmtest builds TSS2 key files for tests, with or without a TPM behind them.
package tpmtest
import (
"crypto/ecdsa"
"crypto/elliptic"
"testing"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
)
const p256Bytes = 32
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
// scheme, the shape tpm2-openssl creates certificate keys in.
func SigningTemplate() tpm2.Public {
return tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagSign | tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{CurveID: tpm2.CurveNISTP256},
}
}
// KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses
// and reports pub, but no TPM can load it.
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...tss2.TPMOption) string {
t.Helper()
require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256")
area := SigningTemplate()
area.ECCParameters.Point = tpm2.ECPoint{
XRaw: pub.X.FillBytes(make([]byte, p256Bytes)),
YRaw: pub.Y.FillBytes(make([]byte, p256Bytes)),
}
encoded, err := area.Encode()
require.NoError(t, err)
return EncodePEM(t, encoded, []byte("placeholder"), opts...)
}
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE KEY.
func EncodePEM(t *testing.T, public, private []byte, opts ...tss2.TPMOption) string {
t.Helper()
pemBytes, err := tss2.New(public, private, opts...).EncodeToMemory()
require.NoError(t, err)
return string(pemBytes)
}
+2
View File
@@ -11,6 +11,7 @@ import (
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -61,6 +62,7 @@ type Info struct {
SystemManufacturer string
Environment Environment
Files []File // for posture checks
CertificateProofs []certposture.Proof
RosenpassEnabled bool
RosenpassPermissive bool
+48 -47
View File
@@ -17,27 +17,27 @@ require (
github.com/onsi/ginkgo v1.16.5
github.com/onsi/gomega v1.34.1
github.com/rs/cors v1.8.0
github.com/sirupsen/logrus v1.9.4
github.com/sirupsen/logrus v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)
require (
github.com/DeRuina/timberjack v1.4.2
github.com/Microsoft/go-winio v0.6.2
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1
github.com/aws/aws-sdk-go-v2/config v1.31.6
github.com/aws/aws-sdk-go-v2/credentials v1.18.10
github.com/aws/aws-sdk-go-v2/config v1.33.4
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
github.com/c-robinson/iplib v1.0.3
github.com/caarlos0/env/v11 v11.4.1
@@ -65,6 +65,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/go-cmp v0.7.0
github.com/google/go-tpm v0.9.8
github.com/google/gopacket v1.1.19
github.com/google/nftables v0.3.0
github.com/gopacket/gopacket v1.4.0
@@ -73,7 +74,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/go-version v1.9.0
github.com/jackc/pgx/v5 v5.10.0
github.com/libdns/route53 v1.5.0
github.com/libp2p/go-netroute v0.4.0
@@ -92,8 +93,6 @@ require (
github.com/ory/dockertest/v4 v4.0.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pb33f/libopenapi v0.41.2
github.com/pb33f/libopenapi-validator v0.15.0
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203
github.com/pion/ice/v4 v4.0.0-00010101000000-000000000000
github.com/pion/logging v0.2.4
@@ -125,23 +124,24 @@ require (
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.step.sm/crypto v0.91.0
go.uber.org/mock v0.6.0
go.uber.org/zap v1.27.0
goauthentik.io/api/v3 v3.2023051.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733
golang.org/x/mod v0.39.0
golang.org/x/net v0.58.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.23.0
golang.org/x/term v0.45.0
golang.org/x/term v0.46.0
golang.org/x/time v0.15.0
google.golang.org/api v0.276.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
google.golang.org/api v0.297.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.7
@@ -152,18 +152,18 @@ require (
)
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
@@ -171,20 +171,20 @@ require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/awnumar/memcall v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect
github.com/beevik/etree v1.6.0 // indirect
@@ -207,7 +207,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -238,10 +238,9 @@ require (
github.com/golang/mock v1.6.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
github.com/googleapis/gax-go/v2 v2.24.1 // indirect
github.com/goreleaser/chglog v0.7.4 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
@@ -279,8 +278,8 @@ require (
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
@@ -305,8 +304,10 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pb33f/go-yaml v0.1.1 // indirect
github.com/pb33f/jsonpath v0.8.4 // indirect
github.com/pb33f/libopenapi v0.41.2
github.com/pb33f/libopenapi-validator v0.15.0
github.com/pb33f/ordered-map/v2 v2.3.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pion/dtls/v2 v2.2.10 // indirect
github.com/pion/dtls/v3 v3.0.9 // indirect
@@ -323,7 +324,7 @@ require (
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
@@ -345,16 +346,16 @@ require (
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.50.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
+100 -96
View File
@@ -1,5 +1,5 @@
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
@@ -11,8 +11,8 @@ cunicu.li/go-rosenpass v0.5.42 h1:fRDsGwCxd7DhDgZI1Pxeo8GtNyq8BESZJ7w2/BGGJtU=
cunicu.li/go-rosenpass v0.5.42/go.mod h1:YRBeyKOe/gWpSX2kpDUec5p9t0XOLsshTguId5gTGVg=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
@@ -29,8 +29,8 @@ github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJP
github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -55,44 +55,44 @@ github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M=
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0=
github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 h1:MmLCRqP4U4Cw9gJ4bNrCG0mWqEtBlmAVleyelcHARMU=
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3/go.mod h1:AMPjK2YnRh0YgOID3PqhJA1BRNfXDfGOnSsKHtAe8yA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8=
@@ -183,10 +183,10 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -316,8 +316,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI=
github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
@@ -329,10 +329,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk=
github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0=
github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0=
github.com/gopacket/gopacket v1.4.0 h1:cr1OlFpzksCkZHNO0eLjaSSOrMQnpPXg0j6qHIY3y2U=
github.com/gopacket/gopacket v1.4.0/go.mod h1:EpvsxINeehp5qj4YMKMLf2/dekdhKn2IIAO/ZOifS7o=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -373,8 +373,8 @@ github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0Yg
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -470,11 +470,11 @@ github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -587,8 +587,8 @@ github.com/pb33f/ordered-map/v2 v2.3.2 h1:wDyaZ2Pv9QLh64X4utCeD5Zoi9nIv2aW3lwv17
github.com/pb33f/ordered-map/v2 v2.3.2/go.mod h1:1OhFrXu3OYw3kM+FXF+Ug3ugmmZ9LE8z0JfQMLvtV0w=
github.com/pb33f/testify v0.1.1 h1:mnHe7uxKt8dyNYEGUspow72VjD264DB5rOJq4bz5tJw=
github.com/pb33f/testify v0.1.1/go.mod h1:keghMqOLECF1ENzESnfM+cwPcKN5uhTOpvbtjgL6aZg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -651,8 +651,8 @@ github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
@@ -664,8 +664,8 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
@@ -673,8 +673,8 @@ github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLg
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
@@ -777,26 +777,30 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.step.sm/crypto v0.91.0 h1:0mN0DwVOvUuh7VbyTnxABZuAkxwak/Grp8Y/b4YaZDU=
go.step.sm/crypto v0.91.0/go.mod h1:NxxObRBymdbyXLoTCW5Ea6sLxuy5yI/xr9+hSBlbU/Q=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -825,8 +829,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -841,8 +845,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -863,8 +867,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -919,8 +923,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -934,8 +938,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -949,8 +953,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -964,8 +968,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -978,17 +982,17 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE=
google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4=
google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -999,8 +1003,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -35,6 +35,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -90,6 +91,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -145,6 +147,7 @@
"ProcessIsRunning": false
}
],
"Certificates": null,
"Capabilities": [
1,
2
@@ -433,7 +436,8 @@
"192.168.0.1/24"
]
},
"ProcessCheck": null
"ProcessCheck": null,
"CertificateCheck": null
}
}
},
+3
View File
@@ -364,6 +364,9 @@ func LogConfigInfo(cfg *nbconfig.Config) {
if cfg.Relay != nil {
log.Infof("Relay addresses: %v", cfg.Relay.Addresses)
}
if cfg.Signal != nil {
log.Infof("Signal addresses: %v", cfg.Signal.URI)
}
}
// EnsureEncryptionKey generates and saves a DataStoreEncryptionKey if not set
@@ -426,6 +426,9 @@ func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string)
}
out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
}
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &posture.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out
}
@@ -12,7 +12,7 @@ const (
GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip
from peers
where account_id = $1
@@ -179,6 +179,7 @@ type Peer struct {
MetaKernelVersion sql.NullString `nmap:"skip"`
MetaNetworkAddresses []byte `nmap:"skip,json"`
MetaFiles []byte `nmap:"skip,json"`
MetaCertificates []byte `nmap:"skip,json"`
MetaCapabilities []byte `nmap:"skip,json"`
MetaFlags []byte `nmap:"skip,json"`
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
@@ -339,6 +340,12 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
return toret, nil, err
}
}
if p.MetaCertificates != nil {
err := json.Unmarshal(p.MetaCertificates, &dp.Meta.Certificates)
if err != nil {
return toret, nil, err
}
}
if p.MetaCapabilities != nil {
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
if err != nil {
@@ -11,7 +11,7 @@ const (
GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip
from peers
where account_id = ?
@@ -0,0 +1,72 @@
package grpc
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const certChallengeKeyDomain = "netbird-cert-challenge-key"
// certChallenger derives the nonce secret from the management WireGuard key so every
// instance sharing that key issues and verifies the same nonces without extra state.
func certChallenger(serverKey wgtypes.Key) *certposture.Challenger {
h := sha256.New()
h.Write([]byte(certChallengeKeyDomain))
h.Write(serverKey[:])
return certposture.NewChallenger(h.Sum(nil))
}
// stampCertificateChallenges fills the per-peer nonce into every certificate challenge
// right before the response is encrypted for that peer.
func stampCertificateChallenges(checks []*proto.Checks, peerKey, serverKey wgtypes.Key) {
var nonce []byte
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
if nonce == nil {
nonce = certChallenger(serverKey).Nonce(peerKey[:], time.Now())
}
challenge.Nonce = nonce
}
}
// verifiedCertificates turns the peer's proofs into PEM chains for its meta. Possession
// (nonce + signature) is verified here; trust against a check's CAs is evaluated by the
// posture check itself. Any invalid proof rejects the whole set.
func (s *Server) verifiedCertificates(ctx context.Context, peerKey wgtypes.Key, proofs []*proto.CertificateProof) []string {
if len(proofs) == 0 {
return nil
}
serverKey, err := s.secretsManager.GetWGKey()
if err != nil {
log.WithContext(ctx).Warnf("skipping certificate proofs of peer %s: %v", peerKey, err)
return nil
}
challenger := certChallenger(serverKey)
now := time.Now()
chains := make([]string, 0, len(proofs))
for _, p := range proofs {
chain, err := challenger.Verify(certposture.Proof{
Nonce: p.GetNonce(),
Chain: p.GetChain(),
SigAlg: p.GetSigAlg(),
Signature: p.GetSignature(),
}, peerKey[:], now)
if err != nil {
log.WithContext(ctx).Warnf("rejecting certificate proofs of peer %s: %v", peerKey, err)
return nil
}
chains = append(chains, certposture.EncodeChainPEM(chain))
}
return chains
}
@@ -0,0 +1,75 @@
package grpc
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertificateChallenge_StampAndVerifyRoundTrip(t *testing.T) {
serverKey := generateKey(t)
peerKey := generateKey(t).PublicKey()
ca := certtest.NewCA(t, "corp-root")
ctx := context.Background()
checks := toProtocolChecks(ctx, []*nmdata.PostureChecks{{
ID: "cert-check",
Checks: nmdata.ChecksDefinition{CertificateCheck: &nmdata.CertificateCheck{CACertificates: []string{ca.PEM}}},
}})
require.Len(t, checks, 1)
require.Equal(t, []string{ca.PEM}, checks[0].GetCertificateChallenge().GetCaCertificates())
require.Empty(t, checks[0].GetCertificateChallenge().GetNonce())
stampCertificateChallenges(checks, peerKey, serverKey)
nonce := checks[0].GetCertificateChallenge().GetNonce()
require.NotEmpty(t, nonce)
deviceKey := certtest.ECDSAKey(t)
leaf := ca.Issue(t, deviceKey, "device")
sigAlg, sig, err := certposture.Sign(deviceKey, nonce, peerKey[:])
require.NoError(t, err)
proofs := []*proto.CertificateProof{{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}}
s := &Server{secretsManager: &TimeBasedAuthSecretsManager{wgKey: serverKey}}
chains := s.verifiedCertificates(ctx, peerKey, proofs)
require.Len(t, chains, 1)
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
t.Run("proof replayed by another peer is rejected", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, generateKey(t).PublicKey(), proofs))
})
t.Run("nonce from another management key is rejected", func(t *testing.T) {
other := &Server{secretsManager: &TimeBasedAuthSecretsManager{wgKey: generateKey(t)}}
assert.Nil(t, other.verifiedCertificates(ctx, peerKey, proofs))
})
t.Run("one invalid proof rejects the whole set", func(t *testing.T) {
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, append(proofs, bad)))
})
t.Run("no proofs yields no certificates", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, nil))
})
}
func TestStampCertificateChallenges_SkipsFileOnlyChecks(t *testing.T) {
checks := []*proto.Checks{{Files: []string{"/bin/agent"}}}
stampCertificateChallenges(checks, generateKey(t).PublicKey(), generateKey(t))
assert.Nil(t, checks[0].GetCertificateChallenge())
}
func generateKey(t *testing.T) wgtypes.Key {
t.Helper()
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
return key
}
@@ -117,6 +117,7 @@ func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map
return status.Errorf(codes.Internal, "failed processing update message")
}
stampCertificateChallenges(update.Update.GetChecks(), pu.peerKey, key)
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
if err != nil {
pu.cleanupFunc()
+12 -2
View File
@@ -246,6 +246,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
realIP := getRealIP(ctx)
sRealIP := realIP.String()
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncReq.GetMeta().GetCertificateProofs())
metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
@@ -649,6 +650,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
}
peerMeta := extractPeerMeta(ctx, loginReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, loginReq.GetMeta().GetCertificateProofs())
metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
if s.logBlockedPeers {
@@ -725,6 +727,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, status.Errorf(codes.Internal, "failed logging in peer")
}
stampCertificateChallenges(loginResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp)
if err != nil {
log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID)
@@ -976,6 +979,7 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
return status.Errorf(codes.Internal, "failed getting server key")
}
stampCertificateChallenges(plainResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp)
if err != nil {
return status.Errorf(codes.Internal, "error handling request")
@@ -1205,7 +1209,9 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr
return nil, msg
}
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP)
peerMeta := extractPeerMeta(ctx, syncMetaReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncMetaReq.GetMeta().GetCertificateProofs())
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), peerMeta, realIP)
if err != nil {
return nil, mapError(ctx, err)
}
@@ -1281,7 +1287,11 @@ func toProtocolCheck(postureCheck *nmdata.PostureChecks) *proto.Checks {
}
}
if len(protoCheck.Files) == 0 {
if check := postureCheck.Checks.CertificateCheck; check != nil {
protoCheck.CertificateChallenge = &proto.CertificateChallenge{CaCertificates: check.CACertificates}
}
if len(protoCheck.Files) == 0 && protoCheck.CertificateChallenge == nil {
return nil
}
+6 -1
View File
@@ -174,6 +174,7 @@ type PeerSystemMeta struct { //nolint:revive
Environment Environment `gorm:"serializer:json"`
Flags Flags `gorm:"serializer:json"`
Files []File `gorm:"serializer:json"`
Certificates []string `gorm:"serializer:json"`
Capabilities []int32 `gorm:"serializer:json"`
SyncMessageVersion int
}
@@ -199,7 +200,8 @@ func (p PeerSystemMeta) isEmpty() bool {
p.SystemManufacturer == "" &&
p.Environment.Cloud == "" &&
p.Environment.Platform == "" &&
len(p.Files) == 0
len(p.Files) == 0 &&
len(p.Certificates) == 0
}
// AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user.
@@ -418,6 +420,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
if !sameMultiset(oldMeta.Files, newMeta.Files) {
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
}
if !sameMultiset(oldMeta.Certificates, newMeta.Certificates) {
add("certificates", len(oldMeta.Certificates), len(newMeta.Certificates))
}
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
}
+36
View File
@@ -0,0 +1,36 @@
package posture
import (
"context"
"fmt"
"time"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck passes when the peer holds a certificate, proven at meta ingestion,
// that chains to one of the configured PEM encoded CA certificates.
type CertificateCheck struct {
CACertificates []string
}
var _ Check = (*CertificateCheck)(nil)
func (c *CertificateCheck) Check(_ context.Context, peer nbpeer.Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
func (c *CertificateCheck) Name() string {
return CertificateCheckName
}
func (c *CertificateCheck) Validate() error {
if len(c.CACertificates) == 0 {
return fmt.Errorf("%s ca certificates shouldn't be empty", c.Name())
}
if _, err := certposture.ParseCAs(c.CACertificates); err != nil {
return fmt.Errorf("%s: %w", c.Name(), err)
}
return nil
}
@@ -0,0 +1,68 @@
package posture
import (
"context"
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
tests := []struct {
name string
certificates []string
cas []string
want bool
}{
{"chains to configured CA", []string{chain}, []string{ca.PEM}, true},
{"one of several CAs matches", []string{chain}, []string{otherCA.PEM, ca.PEM}, true},
{"unrelated CA", []string{chain}, []string{otherCA.PEM}, false},
{"no certificates proven", nil, []string{ca.PEM}, false},
{"garbage entry does not hide a valid one", []string{"garbage", chain}, []string{ca.PEM}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := CertificateCheck{CACertificates: tt.cas}
got, err := check.Check(context.Background(), peer.Peer{Meta: peer.PeerSystemMeta{Certificates: tt.certificates}})
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestCertificateCheck_Validate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
assert.Error(t, (&CertificateCheck{}).Validate())
assert.Error(t, (&CertificateCheck{CACertificates: []string{"not a pem"}}).Validate())
assert.NoError(t, (&CertificateCheck{CACertificates: []string{ca.PEM}}).Validate())
}
func TestChecks_CertificateCheckRegistered(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
checks := &Checks{Name: "cert", Checks: ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}}}
require.NoError(t, checks.Validate())
require.Len(t, checks.GetChecks(), 1)
assert.Equal(t, CertificateCheckName, checks.GetChecks()[0].Name())
copied := checks.Copy()
checks.Checks.CertificateCheck.CACertificates[0] = "mutated"
assert.Equal(t, ca.PEM, copied.Checks.CertificateCheck.CACertificates[0])
api := checks.ToAPIResponse()
require.NotNil(t, api.Checks.CertificateCheck)
roundTrip, err := NewChecksFromAPIPostureCheck(*api)
require.NoError(t, err)
assert.Equal(t, checks.Checks.CertificateCheck.CACertificates, roundTrip.Checks.CertificateCheck.CACertificates)
}
+19
View File
@@ -19,6 +19,7 @@ const (
GeoLocationCheckName = "GeoLocationCheck"
PeerNetworkRangeCheckName = "PeerNetworkRangeCheck"
ProcessCheckName = "ProcessCheck"
CertificateCheckName = "CertificateCheck"
CheckActionAllow string = "allow"
CheckActionDeny string = "deny"
@@ -61,6 +62,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck `json:",omitempty"`
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"`
ProcessCheck *ProcessCheck `json:",omitempty"`
CertificateCheck *CertificateCheck `json:",omitempty"`
}
// Copy returns a copy of a checks definition.
@@ -113,6 +115,12 @@ func (cd ChecksDefinition) Copy() ChecksDefinition {
}
copy(cdCopy.ProcessCheck.Processes, processCheck.Processes)
}
if cd.CertificateCheck != nil {
cdCopy.CertificateCheck = &CertificateCheck{
CACertificates: make([]string, len(cd.CertificateCheck.CACertificates)),
}
copy(cdCopy.CertificateCheck.CACertificates, cd.CertificateCheck.CACertificates)
}
return cdCopy
}
@@ -157,6 +165,9 @@ func (pc *Checks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks
}
@@ -212,6 +223,10 @@ func buildPostureCheck(postureChecksID string, name string, description string,
postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck)
}
if certificateCheck := checks.CertificateCheck; certificateCheck != nil {
postureChecks.Checks.CertificateCheck = &CertificateCheck{CACertificates: certificateCheck.CaCertificates}
}
return &postureChecks, nil
}
@@ -246,6 +261,10 @@ func (pc *Checks) ToAPIResponse() *api.PostureCheck {
checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks.CertificateCheck = &api.CertificateCheck{CaCertificates: pc.Checks.CertificateCheck.CACertificates}
}
return &api.PostureCheck{
Id: pc.ID,
Name: pc.Name,
+6 -3
View File
@@ -186,7 +186,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
meta_environment, meta_flags, meta_files, meta_certificates, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip,
location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version
FROM peers WHERE account_id = $1`
@@ -204,7 +204,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
peerStatusLastSeen sql.NullTime
peerStatusSessionStartedAt sql.NullInt64
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte
ip, extraDNS, netAddr, env, flags, files, certificates, capabilities, connIP, ipv6 []byte
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString
@@ -217,7 +217,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
&loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS,
&allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform,
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &certificates, &capabilities,
&peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired,
&peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID,
&proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion)
@@ -334,6 +334,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if files != nil {
_ = json.Unmarshal(files, &p.Meta.Files)
}
if certificates != nil {
_ = json.Unmarshal(certificates, &p.Meta.Certificates)
}
if capabilities != nil {
_ = json.Unmarshal(capabilities, &p.Meta.Capabilities)
}
@@ -198,6 +198,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
KernelVersion: p.Meta.KernelVersion,
NetworkAddresses: networkAddresses,
Files: files,
Certificates: p.Meta.Certificates,
Capabilities: p.Meta.Capabilities,
SyncMessageVersion: p.Meta.SyncMessageVersion,
Flags: nmdata.Flags{
@@ -452,6 +453,9 @@ func TwinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
}
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
}
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &nmdata.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out
}
@@ -0,0 +1,182 @@
package certposture
import (
"crypto"
"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/certtest"
)
var (
secret = []byte("test-secret")
peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
otherKey = []byte("peer-public-key-bbbbbbbbbbbbbbbb")
now = time.Now().Truncate(Window)
)
func TestChallenger_NonceIsStableWithinWindowAndPerPeer(t *testing.T) {
c := NewChallenger(secret)
assert.Equal(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(time.Minute)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(Window)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(otherKey, now))
assert.NotEqual(t, c.Nonce(peerKey, now), NewChallenger([]byte("other")).Nonce(peerKey, now))
}
func TestChallenger_VerifyNonce(t *testing.T) {
c := NewChallenger(secret)
nonce := c.Nonce(peerKey, now)
tests := []struct {
name string
nonce []byte
peerKey []byte
at time.Time
wantErr error
}{
{"current window", nonce, peerKey, now, nil},
{"previous window still accepted", nonce, peerKey, now.Add(Window), nil},
{"two windows later expired", nonce, peerKey, now.Add(2 * Window), ErrNonceExpired},
{"issued in the future rejected", c.Nonce(peerKey, now.Add(Window)), peerKey, now, ErrNonceExpired},
{"other peer", nonce, otherKey, now, ErrNonceMismatch},
{"tampered mac", tamper(nonce, len(nonce)-1), peerKey, now, ErrNonceMismatch},
{"malformed", nonce[:10], peerKey, now, ErrNonceMalformed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := c.verifyNonce(tt.nonce, tt.peerKey, tt.at)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestSignVerify_RoundTripPerKeyType(t *testing.T) {
ca := certtest.NewCA(t, "root")
keys := map[string]crypto.Signer{
SigAlgECDSASHA256: certtest.ECDSAKey(t),
SigAlgRSAPSSSHA256: certtest.RSAKey(t),
SigAlgEd25519: certtest.Ed25519Key(t),
}
for wantAlg, key := range keys {
t.Run(wantAlg, func(t *testing.T) {
c := NewChallenger(secret)
proof := signedProof(t, c, ca, key)
assert.Equal(t, wantAlg, proof.SigAlg)
chain, err := c.Verify(proof, peerKey, now)
require.NoError(t, err)
require.Len(t, chain, 1)
assert.NoError(t, VerifyChain(chain, mustPool(t, ca.PEM), now))
})
}
}
func TestVerify_Rejections(t *testing.T) {
ca := certtest.NewCA(t, "root")
c := NewChallenger(secret)
good := signedProof(t, c, ca, certtest.ECDSAKey(t))
tests := []struct {
name string
mutate func(p Proof) Proof
peerKey []byte
wantErr error
}{
{"replayed for other peer", identity, otherKey, ErrNonceMismatch},
{"signature tampered", func(p Proof) Proof { p.Signature = tamper(p.Signature, 5); return p }, peerKey, ErrSignatureInvalid},
{"nonce swapped after signing", func(p Proof) Proof { p.Nonce = c.Nonce(peerKey, now.Add(-Window)); return p }, peerKey, ErrSignatureInvalid},
{"foreign leaf presented", func(p Proof) Proof {
p.Chain = [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "other").Raw}
return p
}, peerKey, ErrSignatureInvalid},
{"alg mismatch", func(p Proof) Proof { p.SigAlg = SigAlgEd25519; return p }, peerKey, ErrSigAlgMismatch},
{"empty chain", func(p Proof) Proof { p.Chain = nil; return p }, peerKey, ErrEmptyChain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := c.Verify(tt.mutate(good), tt.peerKey, now)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestVerify_SecretMismatchAcrossChallengers(t *testing.T) {
ca := certtest.NewCA(t, "root")
proof := signedProof(t, NewChallenger(secret), ca, certtest.ECDSAKey(t))
_, err := NewChallenger([]byte("other-instance-secret")).Verify(proof, peerKey, now)
assert.ErrorIs(t, err, ErrNonceMismatch)
}
func TestChainMatchesCAs(t *testing.T) {
ca := certtest.NewCA(t, "root")
otherCA := certtest.NewCA(t, "other-root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chainPEM := EncodeChainPEM([]*x509.Certificate{leaf})
assert.True(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now))
assert.True(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM, ca.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now.Add(30*24*time.Hour)))
assert.False(t, ChainMatchesCAs(chainPEM, []string{"not a pem"}, now))
assert.False(t, ChainMatchesCAs("not a pem", []string{ca.PEM}, now))
}
func TestChainPEM_RoundTrip(t *testing.T) {
ca := certtest.NewCA(t, "root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chain, err := ParseChainPEM(EncodeChainPEM([]*x509.Certificate{leaf, ca.Cert}))
require.NoError(t, err)
require.Len(t, chain, 2)
assert.Equal(t, leaf.Raw, chain[0].Raw)
assert.Equal(t, ca.Cert.Raw, chain[1].Raw)
}
func TestVerify_AcceptsMaximumPSSSalt(t *testing.T) {
// A TPM chooses the PSS salt itself and older firmware uses the largest salt that
// fits, so a proof from such a key carries more salt than Sign asks a software key for.
ca := certtest.NewCA(t, "root")
key := certtest.RSAKey(t).(*rsa.PrivateKey)
leaf := ca.Issue(t, key, "device")
c := NewChallenger(secret)
nonce := c.Nonce(peerKey, now)
digest := sha256.Sum256(proofMessage(nonce, peerKey))
sig, err := rsa.SignPSS(rand.Reader, key, crypto.SHA256, digest[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto})
require.NoError(t, err)
_, err = c.Verify(Proof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: SigAlgRSAPSSSHA256, Signature: sig}, peerKey, now)
assert.NoError(t, err, "a valid PSS signature must verify regardless of salt length")
}
func signedProof(t *testing.T, c *Challenger, ca *certtest.CA, key crypto.Signer) Proof {
t.Helper()
leaf := ca.Issue(t, key, "device")
nonce := c.Nonce(peerKey, now)
sigAlg, sig, err := Sign(key, nonce, peerKey)
require.NoError(t, err)
return Proof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}
}
func mustPool(t *testing.T, pems ...string) *x509.CertPool {
t.Helper()
pool, err := ParseCAs(pems)
require.NoError(t, err)
return pool
}
func identity(p Proof) Proof { return p }
func tamper(b []byte, i int) []byte {
out := append([]byte(nil), b...)
out[i] ^= 0xff
return out
}
@@ -0,0 +1,109 @@
// Package certtest builds throwaway CAs and leaf certificates for certificate posture tests.
package certtest
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type CA struct {
Cert *x509.Certificate
Key crypto.Signer
PEM string
}
func NewCA(t *testing.T, name string) *CA {
t.Helper()
return newCA(t, name, nil)
}
// NewIntermediate creates a CA signed by parent.
func NewIntermediate(t *testing.T, parent *CA, name string) *CA {
t.Helper()
return newCA(t, name, parent)
}
func newCA(t *testing.T, name string, parent *CA) *CA {
t.Helper()
key := ECDSAKey(t)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
issuer, issuerKey := tmpl, key
if parent != nil {
issuer, issuerKey = parent.Cert, parent.Key
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, issuer, key.Public(), issuerKey)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return &CA{Cert: cert, Key: key, PEM: CertPEM(cert)}
}
// Issue signs a leaf certificate for key with the CA.
func (ca *CA) Issue(t *testing.T, key crypto.Signer, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, key.Public(), ca.Key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return cert
}
func ECDSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func RSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return key
}
func Ed25519Key(t *testing.T) crypto.Signer {
t.Helper()
_, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
return key
}
func CertPEM(cert *x509.Certificate) string {
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
func KeyPEM(t *testing.T, key crypto.Signer) string {
t.Helper()
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
+106
View File
@@ -0,0 +1,106 @@
package certposture
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
)
var ErrNoCertificateInPEM = errors.New("no certificate found in PEM data")
// ParseCAs builds a root pool from PEM encoded CA certificates.
func ParseCAs(pems []string) (*x509.CertPool, error) {
roots := x509.NewCertPool()
for _, p := range pems {
if !roots.AppendCertsFromPEM([]byte(p)) {
return nil, ErrNoCertificateInPEM
}
}
return roots, nil
}
// VerifyChain reports whether the leaf (chain[0]) chains to one of roots using the
// remaining certificates as intermediates.
func VerifyChain(chain []*x509.Certificate, roots *x509.CertPool, now time.Time) error {
if len(chain) == 0 {
return ErrEmptyChain
}
intermediates := x509.NewCertPool()
for _, cert := range chain[1:] {
intermediates.AddCert(cert)
}
_, err := chain[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
return err
}
// ChainMatchesCAs is VerifyChain over the PEM forms stored in peer meta and check config.
func ChainMatchesCAs(chainPEM string, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
return chainMatchesPool(chainPEM, roots, now)
}
// AnyChainMatchesCAs reports whether at least one of the peer's verified chains
// is anchored in one of the configured CAs.
func AnyChainMatchesCAs(chainPEMs, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
for _, chainPEM := range chainPEMs {
if chainMatchesPool(chainPEM, roots, now) {
return true
}
}
return false
}
func chainMatchesPool(chainPEM string, roots *x509.CertPool, now time.Time) bool {
chain, err := ParseChainPEM(chainPEM)
if err != nil {
return false
}
return VerifyChain(chain, roots, now) == nil
}
func EncodeChainPEM(chain []*x509.Certificate) string {
var b strings.Builder
for _, cert := range chain {
_ = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
}
return b.String()
}
func ParseChainPEM(chainPEM string) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
rest := []byte(chainPEM)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, ErrNoCertificateInPEM
}
return chain, nil
}
@@ -0,0 +1,68 @@
package certposture
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
"time"
)
const (
Window = 12 * time.Hour
challengeDomain = "netbird-cert-challenge-v1"
windowLen = 8
nonceLen = windowLen + sha256.Size
)
var (
ErrNonceMalformed = errors.New("certificate challenge nonce is malformed")
ErrNonceExpired = errors.New("certificate challenge nonce is expired")
ErrNonceMismatch = errors.New("certificate challenge nonce was not issued to this peer")
)
// Challenger issues and verifies stateless per-peer nonces. A nonce is bound to the
// peer and to a time window, so any instance sharing the secret can verify it.
type Challenger struct {
secret []byte
window time.Duration
}
func NewChallenger(secret []byte) *Challenger {
return &Challenger{secret: secret, window: Window}
}
func (c *Challenger) Nonce(peerKey []byte, now time.Time) []byte {
return c.nonceForWindow(peerKey, c.windowOf(now))
}
func (c *Challenger) verifyNonce(nonce, peerKey []byte, now time.Time) error {
if len(nonce) != nonceLen {
return ErrNonceMalformed
}
window := binary.BigEndian.Uint64(nonce[:windowLen])
current := c.windowOf(now)
if window != current && window+1 != current {
return ErrNonceExpired
}
if !hmac.Equal(nonce, c.nonceForWindow(peerKey, window)) {
return ErrNonceMismatch
}
return nil
}
func (c *Challenger) windowOf(now time.Time) uint64 {
return uint64(now.Unix() / int64(c.window.Seconds()))
}
func (c *Challenger) nonceForWindow(peerKey []byte, window uint64) []byte {
nonce := make([]byte, windowLen, nonceLen)
binary.BigEndian.PutUint64(nonce, window)
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(challengeDomain))
mac.Write(peerKey)
mac.Write(nonce[:windowLen])
return mac.Sum(nonce)
}
+154
View File
@@ -0,0 +1,154 @@
package certposture
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"errors"
"fmt"
"time"
)
const (
SigAlgECDSASHA256 = "ecdsa-sha256"
SigAlgECDSASHA384 = "ecdsa-sha384"
SigAlgRSAPSSSHA256 = "rsa-pss-sha256"
SigAlgEd25519 = "ed25519"
proofDomain = "netbird-posture-cert-v1"
minRSABits = 2048
)
var (
ErrUnsupportedKey = errors.New("unsupported certificate key")
ErrEmptyChain = errors.New("certificate chain is empty")
ErrSignatureInvalid = errors.New("certificate proof signature is invalid")
ErrSigAlgMismatch = errors.New("signature algorithm does not match the certificate key")
)
// Proof is a client's demonstration that it holds the private key of the leaf
// certificate in Chain, made by signing the challenge nonce bound to its peer key.
type Proof struct {
Nonce []byte
Chain [][]byte
SigAlg string
Signature []byte
}
// Sign produces the proof signature for a nonce using the leaf's private key. The key
// never leaves the signer, which may be backed by a file, a TPM or an OS keystore.
func Sign(signer crypto.Signer, nonce, peerKey []byte) (string, []byte, error) {
sigAlg, err := sigAlgFor(signer.Public())
if err != nil {
return "", nil, err
}
msg := proofMessage(nonce, peerKey)
var sig []byte
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA256)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA384)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256})
case SigAlgEd25519:
sig, err = signer.Sign(rand.Reader, msg, crypto.Hash(0))
}
if err != nil {
return "", nil, fmt.Errorf("sign certificate proof: %w", err)
}
return sigAlg, sig, nil
}
// Verify checks that the proof's nonce was issued by this challenger to peerKey and is
// still fresh, and that the signature validates against the leaf's public key. It
// returns the parsed chain on success. Chain trust is deliberately not evaluated here.
func (c *Challenger) Verify(proof Proof, peerKey []byte, now time.Time) ([]*x509.Certificate, error) {
if err := c.verifyNonce(proof.Nonce, peerKey, now); err != nil {
return nil, err
}
chain, err := parseChain(proof.Chain)
if err != nil {
return nil, err
}
leaf := chain[0]
sigAlg, err := sigAlgFor(leaf.PublicKey)
if err != nil {
return nil, err
}
if sigAlg != proof.SigAlg {
return nil, ErrSigAlgMismatch
}
if !verifySignature(leaf.PublicKey, sigAlg, proofMessage(proof.Nonce, peerKey), proof.Signature) {
return nil, ErrSignatureInvalid
}
return chain, nil
}
func proofMessage(nonce, peerKey []byte) []byte {
msg := make([]byte, 0, len(proofDomain)+len(nonce)+len(peerKey))
msg = append(msg, proofDomain...)
msg = append(msg, nonce...)
return append(msg, peerKey...)
}
func sigAlgFor(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
switch k.Curve {
case elliptic.P256():
return SigAlgECDSASHA256, nil
case elliptic.P384():
return SigAlgECDSASHA384, nil
}
case *rsa.PublicKey:
if k.N.BitLen() >= minRSABits {
return SigAlgRSAPSSSHA256, nil
}
case ed25519.PublicKey:
return SigAlgEd25519, nil
}
return "", ErrUnsupportedKey
}
func verifySignature(pub crypto.PublicKey, sigAlg string, msg, sig []byte) bool {
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
return rsa.VerifyPSS(pub.(*rsa.PublicKey), crypto.SHA256, d[:], sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto}) == nil
case SigAlgEd25519:
return ed25519.Verify(pub.(ed25519.PublicKey), msg, sig)
}
return false
}
func parseChain(der [][]byte) ([]*x509.Certificate, error) {
if len(der) == 0 {
return nil, ErrEmptyChain
}
chain := make([]*x509.Certificate, 0, len(der))
for _, raw := range der {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
return chain, nil
}
+15 -1
View File
@@ -1018,6 +1018,19 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
})
}
proofs := make([]*proto.CertificateProof, 0, len(info.CertificateProofs))
for _, p := range info.CertificateProofs {
proofs = append(proofs, &proto.CertificateProof{
Nonce: p.Nonce,
Chain: p.Chain,
SigAlg: p.SigAlg,
Signature: p.Signature,
})
}
if len(proofs) > 0 {
log.Infof("peer meta carries %d certificate posture proofs", len(proofs))
}
return &proto.PeerSystemMeta{
Hostname: info.Hostname,
GoOS: info.GoOS,
@@ -1037,7 +1050,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
Cloud: info.Environment.Cloud,
Platform: info.Environment.Platform,
},
Files: files,
Files: files,
CertificateProofs: proofs,
Flags: &proto.Flags{
RosenpassEnabled: info.RosenpassEnabled,
+14
View File
@@ -1691,6 +1691,8 @@ components:
$ref: '#/components/schemas/PeerNetworkRangeCheck'
process_check:
$ref: '#/components/schemas/ProcessCheck'
certificate_check:
$ref: '#/components/schemas/CertificateCheck'
NBVersionCheck:
description: Posture check for the version of NetBird
type: object
@@ -1808,6 +1810,18 @@ components:
description: Path to the process executable file in a Windows operating system
type: string
example: "C:\ProgramData\NetBird\netbird.exe"
CertificateCheck:
description: Posture check for a certificate held by the peer that chains to one of the given CA certificates. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
type: object
properties:
ca_certificates:
description: PEM encoded CA certificates the peer's certificate must chain to
type: array
items:
type: string
example: ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"]
required:
- ca_certificates
Location:
description: Describe geographical location information
type: object
+9
View File
@@ -2632,6 +2632,12 @@ type BypassResponse struct {
PeerId string `json:"peer_id"`
}
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
type CertificateCheck struct {
// CaCertificates PEM encoded CA certificates the peer's certificate must chain to
CaCertificates []string `json:"ca_certificates"`
}
// CheckoutResponse defines model for CheckoutResponse.
type CheckoutResponse struct {
// SessionId The unique identifier for the checkout session.
@@ -2643,6 +2649,9 @@ type CheckoutResponse struct {
// Checks List of objects that perform the actual checks
type Checks struct {
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
CertificateCheck *CertificateCheck `json:"certificate_check,omitempty"`
// GeoLocationCheck Posture check for geo location
GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"`
@@ -56,6 +56,7 @@ type PeerSystemMeta struct {
KernelVersion string
NetworkAddresses []NetworkAddress
Files []File
Certificates []string
Capabilities []int32
Flags Flags
SyncMessageVersion int
@@ -18,6 +18,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck
PeerNetworkRangeCheck *PeerNetworkRangeCheck
ProcessCheck *ProcessCheck
CertificateCheck *CertificateCheck
}
// Check is the slim twin of posture.Check. It is sealed: only the check types
@@ -79,5 +80,8 @@ func (pc *PostureChecks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks
}
@@ -0,0 +1,16 @@
package nmdata
import (
"time"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck is the slim twin of posture.CertificateCheck.
type CertificateCheck struct {
CACertificates []string
}
func (c *CertificateCheck) check(peer *Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
@@ -0,0 +1,28 @@
package nmdata
import (
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
c := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}})
without := &Peer{}
with := &Peer{Meta: PeerSystemMeta{Certificates: []string{chain}}}
assert.False(t, c[0].Passes(without))
assert.True(t, c[0].Passes(with))
assert.True(t, PostureVerdictChanged(c, without, with))
otherOnly := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{otherCA.PEM}}})
assert.False(t, otherOnly[0].Passes(with))
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -275,6 +275,7 @@ message PeerSystemMeta {
repeated PeerCapability capabilities = 18;
int32 syncMessageVersion = 19;
repeated CertificateProof certificateProofs = 20;
}
message LoginResponse {
@@ -715,6 +716,24 @@ message NetworkAddress {
message Checks {
repeated string Files = 1;
// certificateChallenge asks the peer to prove possession of a certificate chaining to caCertificates.
CertificateChallenge certificateChallenge = 2;
}
message CertificateChallenge {
// nonce is issued by management, bound to the peer and a time window; the peer signs it.
bytes nonce = 1;
// caCertificates are PEM encoded trust anchors the presented certificate must chain to.
repeated string caCertificates = 2;
}
// CertificateProof demonstrates possession of the private key of chain[0] by signing the challenge nonce.
message CertificateProof {
bytes nonce = 1;
// chain is DER encoded, leaf first.
repeated bytes chain = 2;
string sigAlg = 3;
bytes signature = 4;
}