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