split cert and key location and allow key lookup on tpm

This commit is contained in:
pascal
2026-09-22 17:04:50 +02:00
parent d04aef6f14
commit 7fbde60b7c
17 changed files with 604 additions and 139 deletions
+12 -4
View File
@@ -16,7 +16,7 @@ to one WireGuard peer key and cannot be replayed by another peer.
| 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, `NB_CERT_STORE_DIR` or `/etc/netbird/certs` | the daemon, directly |
| 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 |
@@ -170,9 +170,17 @@ or `pin-source` naming a file, and `CertPKCS11PIN` takes precedence over both. W
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
operation opens a session, logs in, works, logs out and closes, so no token handle
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.
+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, _ PKCS11Config) []certposture.Proof {
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
+6 -6
View File
@@ -9,12 +9,12 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// 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)
// 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
+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, _ PKCS11Config) []certposture.Proof {
func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ Config) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
logNoChallenges(checks)
+107 -12
View File
@@ -23,23 +23,28 @@ type PKCS11Config struct {
}
// 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.
// 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
uri *pkcs11.URI
pin string
certDir string
}
// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty.
func NewPKCS11Store(cfg PKCS11Config) (*PKCS11Store, error) {
// 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 &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN}, nil
return store, nil
}
parsed, err := pkcs11.ParseURI(cfg.URI)
if err != nil {
return nil, err
}
return &PKCS11Store{uri: parsed, pin: cfg.PIN}, nil
store.uri = parsed
return store, nil
}
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
@@ -53,25 +58,115 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
if err != nil {
return nil, err
}
log.Infof("%s holds %d certificates", s, len(certs))
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
}
chain := buildChain(cert.cert, pool)
log.Infof("%s candidate %q issued by %q built a chain of %d certificates", s, cert.cert.Subject, cert.cert.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: cert.cert, id: cert.id}})
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"
+173 -58
View File
@@ -52,15 +52,7 @@ func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
// 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) {
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})
require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err)
}
store, uri := pkcs11TestStore(t, "")
keys := map[string]crypto.Signer{"ecdsa": certtest.ECDSAKey(t), "rsa": certtest.RSAKey(t)}
for name, key := range keys {
@@ -83,25 +75,20 @@ func TestCollect_PKCS11TokenEndToEnd(t *testing.T) {
}
}
// Attribute and key types the import needs and the store does not.
// Attribute types the import needs and the store does not.
const (
attrPrivate = 0x2
attrIssuer = 0x81
attrSerialNumber = 0x82
attrKeyType = 0x100
attrSensitive = 0x103
attrSign = 0x108
attrModulus = 0x120
attrPublicExponent = 0x122
attrVerify = 0x10a
attrPrivateExponent = 0x123
attrPrime1 = 0x124
attrPrime2 = 0x125
attrExponent1 = 0x126
attrExponent2 = 0x127
attrCoefficient = 0x128
attrECParams = 0x180
keyTypeRSA = 0x0
keyTypeEC = 0x3
)
var (
@@ -111,7 +98,71 @@ var (
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)
@@ -121,57 +172,24 @@ func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Cert
require.NoError(t, err)
session, err := module.OpenReadWriteSession(parsed.Token, pin)
require.NoError(t, err)
defer session.Close()
id := make([]byte, 8)
_, err = rand.Read(id)
require.NoError(t, err)
label := []byte(leaf.Subject.CommonName)
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, label),
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")
template := []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, label),
attr(pkcs11.AttrID, id),
}
_, err = session.CreateObject(append(template, keyAttributes(t, key)...)...)
require.NoError(t, err, "import private key")
return session
}
func keyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
func privateKeyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
t.Helper()
switch k := key.(type) {
case *ecdsa.PrivateKey:
return []pkcs11.Attribute{
attr(attrKeyType, pkcs11.ULong(keyTypeEC)),
attr(attrECParams, oidP256),
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(attrKeyType, pkcs11.ULong(keyTypeRSA)),
attr(attrModulus, k.N.Bytes()),
attr(attrPublicExponent, big.NewInt(int64(k.E)).Bytes()),
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()),
@@ -184,10 +202,107 @@ func keyAttributes(t *testing.T, key crypto.Signer) []pkcs11.Attribute {
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
@@ -202,7 +317,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store, err := NewPKCS11Store(tt.cfg)
store, err := NewPKCS11Store(tt.cfg, "")
require.NoError(t, err)
pin, err := store.userPIN()
require.NoError(t, err)
@@ -211,6 +326,6 @@ func TestNewPKCS11Store_PIN(t *testing.T) {
})
}
_, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"})
_, 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")
}
+63 -27
View File
@@ -33,6 +33,21 @@ 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.
@@ -52,55 +67,76 @@ func StoreDir() string {
}
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
entries, err := os.ReadDir(s.dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
paths, err := certFiles(s.dir)
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", s.dir, err)
return nil, err
}
var candidates []Candidate
for _, entry := range entries {
if entry.IsDir() || !isCertFile(entry.Name()) {
continue
}
path := filepath.Join(s.dir, entry.Name())
candidate, err := s.load(path)
for _, path := range paths {
chain, signer, err := loadPEM(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
candidates = append(candidates, candidate)
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
}
func (s *FileStore) load(path string) (Candidate, error) {
// 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 Candidate{}, err
return nil, nil, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return Candidate{}, err
return nil, nil, err
}
if len(chain) == 0 {
return Candidate{}, errors.New("no certificate")
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 {
keyData, err := os.ReadFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if err != nil {
return Candidate{}, fmt.Errorf("no private key: %w", err)
}
if _, signer, err = parsePEM(keyData); err != nil {
return Candidate{}, err
}
if signer == nil {
return Candidate{}, errors.New("no private key in key file")
}
return nil, nil, errors.New("no private key in key file")
}
return Candidate{Chain: chain, Signer: signer}, nil
return chain, signer, nil
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
+6 -5
View File
@@ -9,13 +9,14 @@ func DefaultStore() Store {
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 == "" {
// 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)
token, err := NewPKCS11Store(cfg.PKCS11, cfg.dir())
if err != nil {
log.Warnf("ignoring PKCS#11 URI: %v", err)
return files
+18 -7
View File
@@ -6,20 +6,31 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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")
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(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)
}
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)
}
}
+4 -1
View File
@@ -672,7 +672,10 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
LazyConnection: lazyconn.ParseState(config.LazyConnection),
CertPKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN},
CertStore: certproof.Config{
Dir: config.CertStoreDir,
PKCS11: certproof.PKCS11Config{URI: config.CertPKCS11URI, PIN: config.CertPKCS11PIN},
},
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
+2 -2
View File
@@ -171,7 +171,7 @@ type EngineConfig struct {
MTU uint16
CertPKCS11 certproof.PKCS11Config
CertStore certproof.Config
// for debug bundle generation
ProfileConfig *profilemanager.Config
@@ -1298,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[:], e.config.CertPKCS11)
info.CertificateProofs = certproof.CollectProofs(e.ctx, checks, peerKey[:], e.config.CertStore)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
-8
View File
@@ -3,7 +3,6 @@
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"runtime"
@@ -327,10 +326,3 @@ func first(attrs []attribute) *attribute {
}
return &attrs[0]
}
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
func ULong(v uint) []byte {
buf := make([]byte, unsafe.Sizeof(ulong(0)))
binary.NativeEndian.PutUint64(buf, uint64(v))
return buf
}
-7
View File
@@ -2,13 +2,6 @@
package pkcs11
import "encoding/binary"
func load(string) (driver, error) {
return nil, ErrUnsupported
}
// ULong encodes an integer attribute value; no module ever reads it in this build.
func ULong(v uint) []byte {
return binary.NativeEndian.AppendUint64(nil, uint64(v))
}
+24
View File
@@ -5,6 +5,7 @@
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"sync"
@@ -23,8 +24,16 @@ const (
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
@@ -233,3 +242,18 @@ type driver interface {
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
createObject(session uint, template []Attribute) (Object, error)
}
// ulongSize is the width of CK_ULONG on the 64-bit platforms the driver builds for.
const ulongSize = 8
// ULong encodes an integer attribute value the way the module reads a CK_ULONG.
func ULong(v uint) []byte {
return binary.NativeEndian.AppendUint64(nil, uint64(v))
}
func ulongValue(b []byte) (uint, error) {
if len(b) != ulongSize {
return 0, fmt.Errorf("CK_ULONG value has %d bytes", len(b))
}
return uint(binary.NativeEndian.Uint64(b)), nil
}
+95
View File
@@ -0,0 +1,95 @@
package pkcs11
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/asn1"
"errors"
"fmt"
"math"
"math/big"
)
var curvesByOID = map[string]elliptic.Curve{
"1.2.840.10045.3.1.7": elliptic.P256(),
"1.3.132.0.34": elliptic.P384(),
"1.3.132.0.35": elliptic.P521(),
}
// PublicKey reads a CKO_PUBLIC_KEY object as a Go public key. RSA and EC keys are
// supported, the two kinds a certificate posture proof can be signed with.
func (s *Session) PublicKey(obj Object) (crypto.PublicKey, error) {
raw, err := s.Attribute(obj, AttrKeyType)
if err != nil {
return nil, err
}
keyType, err := ulongValue(raw)
if err != nil {
return nil, fmt.Errorf("CKA_KEY_TYPE: %w", err)
}
switch keyType {
case KeyRSA:
modulus, exponent, err := s.attributes(obj, AttrModulus, AttrPublicExponent)
if err != nil {
return nil, err
}
return rsaPublicKey(modulus, exponent)
case KeyEC:
params, point, err := s.attributes(obj, AttrECParams, AttrECPoint)
if err != nil {
return nil, err
}
return ecPublicKey(params, point)
}
return nil, fmt.Errorf("unsupported key type 0x%x", keyType)
}
func (s *Session) attributes(obj Object, first, second uint) ([]byte, []byte, error) {
a, err := s.Attribute(obj, first)
if err != nil {
return nil, nil, err
}
b, err := s.Attribute(obj, second)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) {
e := new(big.Int).SetBytes(exponent)
if e.Sign() <= 0 || e.Cmp(big.NewInt(math.MaxInt32)) > 0 {
return nil, errors.New("CKA_PUBLIC_EXPONENT is out of range")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: int(e.Int64())}, nil
}
// ecPublicKey decodes CKA_EC_PARAMS, the named curve OID, and CKA_EC_POINT, the
// uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare.
func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) {
var oid asn1.ObjectIdentifier
if _, err := asn1.Unmarshal(params, &oid); err != nil {
return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err)
}
curve, ok := curvesByOID[oid.String()]
if !ok {
return nil, fmt.Errorf("unsupported curve %s", oid)
}
size := (curve.Params().BitSize + 7) / 8
raw := point
if len(raw) != 1+2*size {
if _, err := asn1.Unmarshal(point, &raw); err != nil {
return nil, fmt.Errorf("CKA_EC_POINT: %w", err)
}
}
if len(raw) != 1+2*size || raw[0] != 4 {
return nil, errors.New("CKA_EC_POINT is not an uncompressed point")
}
return &ecdsa.PublicKey{
Curve: curve,
X: new(big.Int).SetBytes(raw[1 : 1+size]),
Y: new(big.Int).SetBytes(raw[1+size:]),
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/asn1"
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestECPublicKey(t *testing.T) {
curves := []struct {
name string
curve elliptic.Curve
oid asn1.ObjectIdentifier
}{
{"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}},
{"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}},
}
for _, tt := range curves {
t.Run(tt.name, func(t *testing.T) {
key, err := ecdsa.GenerateKey(tt.curve, rand.Reader)
require.NoError(t, err)
params, err := asn1.Marshal(tt.oid)
require.NoError(t, err)
point := uncompressedPoint(key)
wrapped, err := asn1.Marshal(point)
require.NoError(t, err)
// PKCS#11 wraps the point in an OCTET STRING, but some modules return it bare.
for form, encoded := range map[string][]byte{"DER octet string": wrapped, "bare point": point} {
pub, err := ecPublicKey(params, encoded)
require.NoError(t, err, form)
assert.True(t, key.PublicKey.Equal(pub), "%s must decode to the generated key", form)
}
})
}
}
func TestECPublicKey_Rejections(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
p256, err := asn1.Marshal(asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7})
require.NoError(t, err)
brainpool, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 36, 3, 3, 2, 8, 1, 1, 7})
require.NoError(t, err)
point := uncompressedPoint(key)
_, err = ecPublicKey(brainpool, point)
assert.Error(t, err, "curves the proof cannot use must be rejected")
_, err = ecPublicKey(p256, point[:len(point)-1])
assert.Error(t, err, "a truncated point must be rejected")
_, err = ecPublicKey([]byte("junk"), point)
assert.Error(t, err, "malformed parameters must be rejected")
}
func TestRSAPublicKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pub, err := rsaPublicKey(key.N.Bytes(), big.NewInt(int64(key.E)).Bytes())
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(pub), "modulus and exponent must decode to the generated key")
_, err = rsaPublicKey(key.N.Bytes(), nil)
assert.Error(t, err, "a missing exponent must be rejected")
}
func TestULongRoundTrip(t *testing.T) {
v, err := ulongValue(ULong(ClassPrivateKey))
require.NoError(t, err)
assert.Equal(t, uint(ClassPrivateKey), v)
_, err = ulongValue([]byte{1, 2, 3})
assert.Error(t, err, "a value of the wrong width must be rejected")
}
func uncompressedPoint(key *ecdsa.PrivateKey) []byte {
size := (key.Curve.Params().BitSize + 7) / 8
point := append([]byte{4}, key.X.FillBytes(make([]byte, size))...)
return append(point, key.Y.FillBytes(make([]byte, size))...)
}
+5
View File
@@ -186,6 +186,11 @@ 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.