add tpm pin to netbird config

This commit is contained in:
pascal
2026-09-21 14:59:39 +02:00
parent d3c5b6718a
commit d04aef6f14
11 changed files with 132 additions and 37 deletions
+22 -10
View File
@@ -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
+1 -1
View File
@@ -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)
+6 -5
View File
@@ -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
+1 -1
View File
@@ -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)
+23 -6
View File
@@ -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
+28 -1
View File
@@ -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")
}
+11 -12
View File
@@ -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}
@@ -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)
}
}
}
+3
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -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,
+3 -1
View File
@@ -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 {
+9
View File
@@ -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:"-"`