diff --git a/client/internal/certproof/README.md b/client/internal/certproof/README.md index b127cfc63..2babb9349 100644 --- a/client/internal/certproof/README.md +++ b/client/internal/certproof/README.md @@ -18,7 +18,7 @@ to one WireGuard peer key and cannot be replayed by another peer. | Windows | signed-in user's `CurrentUser\MY` | a helper launched with that session's token | | Linux and others | PEM directory, `NB_CERT_STORE_DIR` or `/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 named by `NB_CERT_PKCS11_URI`, such as tpm2-pkcs11 | the daemon, through the token's module, in builds with the `pkcs11` tag | +| 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 @@ -147,18 +147,28 @@ NB_TPM_DEVICE=/tmp/swtpm.sock go test ./client/internal/certproof/ -run TestColl 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 `NB_CERT_PKCS11_URI` names it with an RFC 7512 URI: +The store reads that token when the profile config, `/etc/netbird/config.json` by default, +carries the token's user PIN: +```json +"CertPKCS11PIN": "1234" ``` -NB_CERT_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so&pin-source=file:/etc/netbird/pkcs11.pin' + +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. `pin-source` points at a file holding the user PIN and -`pin-value` carries it inline; without either no login happens, and tpm2-pkcs11 then shows -no private keys at all. Every other attribute is ignored. +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. Certificates and private keys are paired by `CKA_ID`, which is what `tpm2_ptool addcert` and `pkcs11-tool` set. Chains are completed from the other certificates on the token. Each @@ -166,15 +176,17 @@ operation opens a session, logs in, works, logs out and closes, so no token hand 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, so the PIN file -should be root-only. 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. +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 `NB_CERT_PKCS11_URI` logs that +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 diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go index 342229180..e24dec780 100644 --- a/client/internal/certproof/collect_darwin.go +++ b/client/internal/certproof/collect_darwin.go @@ -24,7 +24,7 @@ const helperTimeout = 30 * time.Second // 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) []certposture.Proof { +func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ PKCS11Config) []certposture.Proof { challenges := certificateChallenges(checks) if len(challenges) == 0 { logNoChallenges(checks) diff --git a/client/internal/certproof/collect_other.go b/client/internal/certproof/collect_other.go index 0891743ec..59119cfd1 100644 --- a/client/internal/certproof/collect_other.go +++ b/client/internal/certproof/collect_other.go @@ -9,11 +9,12 @@ import ( "github.com/netbirdio/netbird/shared/management/proto" ) -// CollectProofs answers the certificate challenges in checks from the platform store. -// 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) []certposture.Proof { - return Collect(ctx, DefaultStore(), checks, peerKey) +// CollectProofs answers the certificate challenges in checks from the platform store, +// joined by the PKCS#11 token that token names 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, token PKCS11Config) []certposture.Proof { + return Collect(ctx, storeWithToken(token), checks, peerKey) } // helperStore is the store the helper reads. Nothing launches a helper on these diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go index f83e3177b..e1a5621a7 100644 --- a/client/internal/certproof/collect_windows.go +++ b/client/internal/certproof/collect_windows.go @@ -25,7 +25,7 @@ const helperTimeout = 30 * time.Second // Intune enrol device certificates, and reaches the signed-in user's store by launching // a helper with that session's token. A machine at the sign-in screen therefore proves // device certificates alone. -func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte) []certposture.Proof { +func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ PKCS11Config) []certposture.Proof { challenges := certificateChallenges(checks) if len(challenges) == 0 { logNoChallenges(checks) diff --git a/client/internal/certproof/pkcs11store.go b/client/internal/certproof/pkcs11store.go index ca060ac52..c8af67fad 100644 --- a/client/internal/certproof/pkcs11store.go +++ b/client/internal/certproof/pkcs11store.go @@ -14,22 +14,32 @@ import ( "github.com/netbirdio/netbird/client/internal/pkcs11" ) -const PKCS11URIEnv = "NB_CERT_PKCS11_URI" +// PKCS11Config names the token whose certificates the store yields. URI is an RFC 7512 +// PKCS#11 URI, or empty for the first token the p11-kit proxy exposes. PIN is the user +// PIN, and takes precedence over a pin-value or pin-source the URI carries. +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 and keys are paired by CKA_ID, the convention // tpm2_ptool addcert and pkcs11-tool follow, and every signature happens on the token. type PKCS11Store struct { uri *pkcs11.URI + pin string } -// NewPKCS11Store reads the token, module and PIN source from an RFC 7512 PKCS#11 URI. -func NewPKCS11Store(uri string) (*PKCS11Store, error) { - parsed, err := pkcs11.ParseURI(uri) +// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. +func NewPKCS11Store(cfg PKCS11Config) (*PKCS11Store, error) { + if cfg.URI == "" { + return &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN}, nil + } + parsed, err := pkcs11.ParseURI(cfg.URI) if err != nil { return nil, err } - return &PKCS11Store{uri: parsed}, nil + return &PKCS11Store{uri: parsed, pin: cfg.PIN}, nil } func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { @@ -74,13 +84,20 @@ func (s *PKCS11Store) open() (*pkcs11.Session, error) { if err != nil { return nil, err } - pin, err := s.uri.PIN() + 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 diff --git a/client/internal/certproof/pkcs11store_test.go b/client/internal/certproof/pkcs11store_test.go index 0db2bfc3c..4396d64cd 100644 --- a/client/internal/certproof/pkcs11store_test.go +++ b/client/internal/certproof/pkcs11store_test.go @@ -56,7 +56,7 @@ func TestCollect_PKCS11TokenEndToEnd(t *testing.T) { if uri == "" { t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv) } - store, err := NewPKCS11Store(uri) + store, err := NewPKCS11Store(PKCS11Config{URI: uri}) require.NoError(t, err) if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) { t.Skip(err) @@ -187,3 +187,30 @@ func keyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute { func attr(typ uint, value []byte) pkcs11.Attribute { return pkcs11.Attribute{Type: typ, Value: value} } + +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") +} diff --git a/client/internal/certproof/store_other.go b/client/internal/certproof/store_other.go index 07e2d4526..8b5931570 100644 --- a/client/internal/certproof/store_other.go +++ b/client/internal/certproof/store_other.go @@ -2,23 +2,22 @@ package certproof -import ( - "os" +import log "github.com/sirupsen/logrus" - log "github.com/sirupsen/logrus" -) - -// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs, -// joined by the PKCS#11 token named by NB_CERT_PKCS11_URI when that is set. +// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs. func DefaultStore() Store { - files := NewFileStore(StoreDir()) - uri := os.Getenv(PKCS11URIEnv) - if uri == "" { + return NewFileStore(StoreDir()) +} + +// storeWithToken joins DefaultStore with the PKCS#11 token cfg names, when it names one. +func storeWithToken(cfg PKCS11Config) Store { + files := DefaultStore() + if cfg.URI == "" && cfg.PIN == "" { return files } - token, err := NewPKCS11Store(uri) + token, err := NewPKCS11Store(cfg) if err != nil { - log.Warnf("ignoring %s: %v", PKCS11URIEnv, err) + log.Warnf("ignoring PKCS#11 URI: %v", err) return files } return Stores{files, token} diff --git a/client/internal/certproof/store_other_test.go b/client/internal/certproof/store_other_test.go new file mode 100644 index 000000000..2383e6c0c --- /dev/null +++ b/client/internal/certproof/store_other_test.go @@ -0,0 +1,25 @@ +//go:build !darwin && !windows + +package certproof + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStoreWithToken(t *testing.T) { + assert.IsType(t, &FileStore{}, storeWithToken(PKCS11Config{}), "nothing configured reads the PEM directory alone") + assert.IsType(t, &FileStore{}, storeWithToken(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(cfg).(Stores) + if assert.True(t, ok, "%s joins the token to the PEM directory", name) { + assert.Len(t, store, 2, name) + assert.IsType(t, &PKCS11Store{}, store[1], name) + } + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index 88d829d2f..8798e3e4c 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -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" @@ -671,6 +672,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf LazyConnection: lazyconn.ParseState(config.LazyConnection), + CertPKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN}, + MTU: selectMTU(config.MTU, peerConfig.Mtu), LogPath: logPath, diff --git a/client/internal/engine.go b/client/internal/engine.go index a19f319ff..118f17760 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -171,6 +171,8 @@ type EngineConfig struct { MTU uint16 + CertPKCS11 certproof.PKCS11Config + // for debug bundle generation ProfileConfig *profilemanager.Config @@ -1296,7 +1298,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) { // 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[:]) + info.CertificateProofs = certproof.CollectProofs(e.ctx, checks, peerKey[:], e.config.CertPKCS11) } func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info { diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 412f81b5c..c27bd8cee 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -186,6 +186,15 @@ type Config struct { ClientCertKeyPair *tls.Certificate `json:"-"` + // 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:"-"`