diff --git a/client/internal/certproof/README.md b/client/internal/certproof/README.md index 2babb9349..9f7831bc0 100644 --- a/client/internal/certproof/README.md +++ b/client/internal/certproof/README.md @@ -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. diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go index e24dec780..b52f00e50 100644 --- a/client/internal/certproof/collect_darwin.go +++ b/client/internal/certproof/collect_darwin.go @@ -24,7 +24,7 @@ const helperTimeout = 30 * time.Second // installs device identities, and reaches the console user's login keychain only by // launching a helper into that user's session. A Mac sitting at the login window // therefore yields device proofs alone. -func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ 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) diff --git a/client/internal/certproof/collect_other.go b/client/internal/certproof/collect_other.go index 59119cfd1..d9c49c807 100644 --- a/client/internal/certproof/collect_other.go +++ b/client/internal/certproof/collect_other.go @@ -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 diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go index e1a5621a7..074662fdb 100644 --- a/client/internal/certproof/collect_windows.go +++ b/client/internal/certproof/collect_windows.go @@ -25,7 +25,7 @@ const helperTimeout = 30 * time.Second // Intune enrol device certificates, and reaches the signed-in user's store by launching // a helper with that session's token. A machine at the sign-in screen therefore proves // device certificates alone. -func CollectProofs(ctx context.Context, checks []*proto.Checks, peerKey []byte, _ 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) diff --git a/client/internal/certproof/pkcs11store.go b/client/internal/certproof/pkcs11store.go index c8af67fad..24d0a3b5f 100644 --- a/client/internal/certproof/pkcs11store.go +++ b/client/internal/certproof/pkcs11store.go @@ -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" diff --git a/client/internal/certproof/pkcs11store_test.go b/client/internal/certproof/pkcs11store_test.go index 4396d64cd..2a99bb296 100644 --- a/client/internal/certproof/pkcs11store_test.go +++ b/client/internal/certproof/pkcs11store_test.go @@ -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") } diff --git a/client/internal/certproof/store.go b/client/internal/certproof/store.go index 607bda257..666bb1be3 100644 --- a/client/internal/certproof/store.go +++ b/client/internal/certproof/store.go @@ -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 ".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 ".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) { diff --git a/client/internal/certproof/store_other.go b/client/internal/certproof/store_other.go index 8b5931570..be467fa51 100644 --- a/client/internal/certproof/store_other.go +++ b/client/internal/certproof/store_other.go @@ -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 diff --git a/client/internal/certproof/store_other_test.go b/client/internal/certproof/store_other_test.go index 2383e6c0c..5787df9f9 100644 --- a/client/internal/certproof/store_other_test.go +++ b/client/internal/certproof/store_other_test.go @@ -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) } } diff --git a/client/internal/connect.go b/client/internal/connect.go index 8798e3e4c..1c278f315 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -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, diff --git a/client/internal/engine.go b/client/internal/engine.go index 118f17760..0a62e7326 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -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 { diff --git a/client/internal/pkcs11/driver_linux.go b/client/internal/pkcs11/driver_linux.go index f0082b6b5..0d9eb8705 100644 --- a/client/internal/pkcs11/driver_linux.go +++ b/client/internal/pkcs11/driver_linux.go @@ -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 -} diff --git a/client/internal/pkcs11/driver_stub.go b/client/internal/pkcs11/driver_stub.go index ad6a5fd8f..1f870bd8e 100644 --- a/client/internal/pkcs11/driver_stub.go +++ b/client/internal/pkcs11/driver_stub.go @@ -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)) -} diff --git a/client/internal/pkcs11/pkcs11.go b/client/internal/pkcs11/pkcs11.go index 639cd59a5..8f93da2a3 100644 --- a/client/internal/pkcs11/pkcs11.go +++ b/client/internal/pkcs11/pkcs11.go @@ -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 +} diff --git a/client/internal/pkcs11/publickey.go b/client/internal/pkcs11/publickey.go new file mode 100644 index 000000000..70955e348 --- /dev/null +++ b/client/internal/pkcs11/publickey.go @@ -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 +} diff --git a/client/internal/pkcs11/publickey_test.go b/client/internal/pkcs11/publickey_test.go new file mode 100644 index 000000000..527d6db46 --- /dev/null +++ b/client/internal/pkcs11/publickey_test.go @@ -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))...) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index c27bd8cee..18dd978c0 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -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.