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 | | 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 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 `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, 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 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 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. 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` `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 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 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 module the system has registered. The URI may carry the PIN itself, as `pin-value` inline
`pin-value` carries it inline; without either no login happens, and tpm2-pkcs11 then shows or `pin-source` naming a file, and `CertPKCS11PIN` takes precedence over both. Without any
no private keys at all. Every other attribute is ignored. 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` 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 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 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. 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 Two consequences of the PIN are worth knowing. It is a secret on disk, which the profile
should be root-only. And a wrong PIN counts against the TPM's dictionary-attack lockout, config already is: it holds the WireGuard private key and is written readable by root
which is shared with everything else on the machine that uses the TPM. 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 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` 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 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 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. the build lacks the support.
To exercise the path without hardware, initialise a SoftHSM token and run the end-to-end 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 // 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 // launching a helper into that user's session. A Mac sitting at the login window
// therefore yields device proofs alone. // 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) challenges := certificateChallenges(checks)
if len(challenges) == 0 { if len(challenges) == 0 {
logNoChallenges(checks) logNoChallenges(checks)
+6 -5
View File
@@ -9,11 +9,12 @@ import (
"github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/proto"
) )
// CollectProofs answers the certificate challenges in checks from the platform store. // 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 // joined by the PKCS#11 token that token names when it names one. Only macOS and
// daemon, so every other platform reads its store in the daemon itself. // Windows keep per-user certificates out of reach of a privileged daemon, so every
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte) []certposture.Proof { // other platform reads its store in the daemon itself.
return Collect(ctx, DefaultStore(), checks, peerKey) 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 // 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 // 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 // a helper with that session's token. A machine at the sign-in screen therefore proves
// device certificates alone. // 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) challenges := certificateChallenges(checks)
if len(challenges) == 0 { if len(challenges) == 0 {
logNoChallenges(checks) logNoChallenges(checks)
+23 -6
View File
@@ -14,22 +14,32 @@ import (
"github.com/netbirdio/netbird/client/internal/pkcs11" "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 // 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 // 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. // tpm2_ptool addcert and pkcs11-tool follow, and every signature happens on the token.
type PKCS11Store struct { type PKCS11Store struct {
uri *pkcs11.URI uri *pkcs11.URI
pin string
} }
// NewPKCS11Store reads the token, module and PIN source from an RFC 7512 PKCS#11 URI. // NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty.
func NewPKCS11Store(uri string) (*PKCS11Store, error) { func NewPKCS11Store(cfg PKCS11Config) (*PKCS11Store, error) {
parsed, err := pkcs11.ParseURI(uri) if cfg.URI == "" {
return &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN}, nil
}
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &PKCS11Store{uri: parsed}, nil return &PKCS11Store{uri: parsed, pin: cfg.PIN}, nil
} }
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
@@ -74,13 +84,20 @@ func (s *PKCS11Store) open() (*pkcs11.Session, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
pin, err := s.uri.PIN() pin, err := s.userPIN()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return module.OpenSession(s.uri.Token, pin) 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 { type tokenCertificate struct {
cert *x509.Certificate cert *x509.Certificate
id []byte id []byte
+28 -1
View File
@@ -56,7 +56,7 @@ func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
if uri == "" { if uri == "" {
t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv) 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) require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) { if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err) 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 { func attr(typ uint, value []byte) pkcs11.Attribute {
return pkcs11.Attribute{Type: typ, Value: value} 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 package certproof
import ( import log "github.com/sirupsen/logrus"
"os"
log "github.com/sirupsen/logrus" // DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
)
// 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.
func DefaultStore() Store { func DefaultStore() Store {
files := NewFileStore(StoreDir()) return NewFileStore(StoreDir())
uri := os.Getenv(PKCS11URIEnv) }
if uri == "" {
// 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 return files
} }
token, err := NewPKCS11Store(uri) token, err := NewPKCS11Store(cfg)
if err != nil { if err != nil {
log.Warnf("ignoring %s: %v", PKCS11URIEnv, err) log.Warnf("ignoring PKCS#11 URI: %v", err)
return files return files
} }
return Stores{files, token} 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"
"github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack" "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/dns"
"github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener" "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), LazyConnection: lazyconn.ParseState(config.LazyConnection),
CertPKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN},
MTU: selectMTU(config.MTU, peerConfig.Mtu), MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath, LogPath: logPath,
+3 -1
View File
@@ -171,6 +171,8 @@ type EngineConfig struct {
MTU uint16 MTU uint16
CertPKCS11 certproof.PKCS11Config
// for debug bundle generation // for debug bundle generation
ProfileConfig *profilemanager.Config 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. // certificates reachable on this device, signing each challenge nonce for our peer key.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) { func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
peerKey := e.config.WgPrivateKey.PublicKey() 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 { func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
+9
View File
@@ -186,6 +186,15 @@ type Config struct {
ClientCertKeyPair *tls.Certificate `json:"-"` 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"/""). // LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
// Runtime-only: re-derived from MDM policy on each load, never persisted. // Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"` LazyConnection string `json:"-"`