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)
}
}