start TPM support

This commit is contained in:
pascal
2026-09-16 23:00:44 +02:00
parent d89e9c86eb
commit 24f5f2a7ec
22 changed files with 1836 additions and 145 deletions
+85
View File
@@ -17,6 +17,8 @@ to one WireGuard peer key and cannot be replayed by another peer.
| 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 | a `TSS2 PRIVATE KEY` file in that directory, signed by the TPM | the daemon, through `/dev/tpmrm0` |
| Linux | a PKCS#11 token named by `NB_CERT_PKCS11_URI`, such as tpm2-pkcs11 | the daemon, through the token's module, in builds with the `pkcs11` tag |
macOS and Windows both keep per-user certificates out of reach of a privileged daemon,
and both are handled the same way: the daemon reads the machine store itself and
@@ -99,6 +101,89 @@ Unlike macOS, the Windows store acquires keys with `CRYPT_ACQUIRE_SILENT_FLAG`,
that would need a prompt fails immediately instead of blocking. That also means a
smartcard PIN can never be satisfied this way.
## Linux: keys held by the TPM
Enrollment tooling on Linux keeps a TPM-resident key as a `TSS2 PRIVATE KEY` PEM file,
the format of draft-bottomley-tpm2-keys that tpm2-openssl, tpm2-tss-engine and
`tpm2_encodeobject` write. The file holds the key wrapped by its parent; the TPM is the
only thing that can use it. Drop it next to the certificate as usual:
```
openssl genpkey -provider tpm2 -algorithm EC -pkeyopt group:P-256 -out /etc/netbird/certs/device.key
openssl req -provider tpm2 -provider default -new -key /etc/netbird/certs/device.key -subj /CN=device -out device.csr
```
Sign the CSR with the organisation CA and store the result as `device.pem`. The store
parses the key file without touching the TPM, so the certificate is listed as a
candidate like any other, and every signature opens `/dev/tpmrm0`, loads the key under
its parent, signs, flushes and closes again. `NB_TPM_DEVICE` overrides the device path.
What the key file may look like:
- **Parent.** A persistent handle such as `0x81000001` is used as is. The owner
hierarchy, which both tpm2-openssl and tpm2-tss-engine default to, means the key was
created under a transient primary from the TCG default ECC P-256 template, and that
same primary is derived again before loading.
- **No authorization value.** A key created with a password needs someone to type it,
which the daemon cannot arrange, so the certificate is skipped with a log line rather
than blocking on a TPM auth failure.
- **RSA-2048 or P-256, sometimes P-384.** Those are what the PC Client profile requires
of a TPM; P-384 depends on the chip. The TPM chooses the RSA-PSS salt itself, which is
why management verifies PSS proofs with `rsa.PSSSaltLengthAuto`.
Windows needs none of this: a certificate enrolled into the TPM sits behind the Microsoft
Platform Crypto Provider and the CNG path above signs with it unchanged. macOS has no
TPM; its Secure Enclave keys are reachable only through the keychain path.
To exercise the path without hardware, run a software TPM and point the end-to-end test
at it:
```
swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
NB_TPM_DEVICE=/tmp/swtpm.sock go test ./client/internal/certproof/ -run TestCollect_TPMKeyEndToEnd -v
```
## Linux: keys behind a PKCS#11 token
Distributions that follow Red Hat's guidance reach the TPM through tpm2-pkcs11, a PKCS#11
module whose token holds both the key and, after `tpm2_ptool addcert`, the certificate.
The store reads that token when `NB_CERT_PKCS11_URI` names it with an RFC 7512 URI:
```
NB_CERT_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/x86_64-linux-gnu/libtpm2_pkcs11.so&pin-source=file:/etc/netbird/pkcs11.pin'
```
`token` selects the token by label, or the first token present when absent. `module-path`
names the library to load; `module-name=tpm2_pkcs11` resolves to `libtpm2_pkcs11.so` on
the loader's search path, and with neither the p11-kit proxy is loaded, which exposes every
module the system has registered. `pin-source` points at a file holding the user PIN and
`pin-value` carries it inline; without either no login happens, and tpm2-pkcs11 then shows
no private keys at all. Every other attribute is ignored.
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
outlives a call, and the PEM directory keeps working when the token does not: the two are
queried together and a failing token is logged rather than hiding file certificates.
Two consequences of the PIN are worth knowing. It is a secret on disk, so the PIN file
should be root-only. And a wrong PIN counts against the TPM's dictionary-attack lockout,
which is shared with everything else on the machine that uses the TPM.
The module is loaded at runtime without cgo, through `purego`, which means the binary is
dynamically linked against libc. The standard release binary stays fully static, so the
PKCS#11 store is compiled in only with `-tags pkcs11` on linux/amd64 and linux/arm64.
Without the tag, setting `NB_CERT_PKCS11_URI` logs that the build lacks the support.
To exercise the path without hardware, initialise a SoftHSM token and run the end-to-end
test, which imports a key and certificate itself:
```
softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
```
## Only the signed-in user can be validated
This is the central limitation of the design, and it is deliberate.
+182
View File
@@ -0,0 +1,182 @@
package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"io"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/pkcs11"
)
const PKCS11URIEnv = "NB_CERT_PKCS11_URI"
// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes
// TPM-held keys on Linux. Certificates and keys are paired by CKA_ID, the convention
// tpm2_ptool addcert and pkcs11-tool follow, and every signature happens on the token.
type PKCS11Store struct {
uri *pkcs11.URI
}
// NewPKCS11Store reads the token, module and PIN source from an RFC 7512 PKCS#11 URI.
func NewPKCS11Store(uri string) (*PKCS11Store, error) {
parsed, err := pkcs11.ParseURI(uri)
if err != nil {
return nil, err
}
return &PKCS11Store{uri: parsed}, nil
}
func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
session, err := s.open()
if err != nil {
return nil, err
}
defer session.Close()
certs, err := tokenCertificates(session)
if err != nil {
return nil, err
}
log.Infof("%s holds %d certificates", s, len(certs))
pool := make([]*x509.Certificate, 0, len(certs))
for _, cert := range certs {
pool = append(pool, cert.cert)
}
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}})
}
return candidates, nil
}
func (s *PKCS11Store) String() string {
if s.uri.Token == "" {
return "PKCS#11 token"
}
return fmt.Sprintf("PKCS#11 token %q", s.uri.Token)
}
func (s *PKCS11Store) open() (*pkcs11.Session, error) {
module, err := pkcs11.Load(s.uri.Module())
if err != nil {
return nil, err
}
pin, err := s.uri.PIN()
if err != nil {
return nil, err
}
return module.OpenSession(s.uri.Token, pin)
}
type tokenCertificate struct {
cert *x509.Certificate
id []byte
}
func tokenCertificates(session *pkcs11.Session) ([]tokenCertificate, error) {
objects, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassCertificate)},
pkcs11.Attribute{Type: pkcs11.AttrCertificateType, Value: pkcs11.ULong(pkcs11.CertificateX509)},
)
if err != nil {
return nil, err
}
certs := make([]tokenCertificate, 0, len(objects))
for _, object := range objects {
der, err := session.Attribute(object, pkcs11.AttrValue)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Warnf("skipping unparsable certificate on PKCS#11 token: %v", err)
continue
}
id, err := session.Attribute(object, pkcs11.AttrID)
if err != nil {
return nil, err
}
certs = append(certs, tokenCertificate{cert: cert, id: id})
}
return certs, nil
}
var errNoPrivateKey = errors.New("no private key shares the certificate's CKA_ID")
func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) {
if len(id) == 0 {
return 0, errNoPrivateKey
}
keys, err := session.FindObjects(
pkcs11.Attribute{Type: pkcs11.AttrClass, Value: pkcs11.ULong(pkcs11.ClassPrivateKey)},
pkcs11.Attribute{Type: pkcs11.AttrID, Value: id},
)
if err != nil {
return 0, err
}
if len(keys) == 0 {
return 0, errNoPrivateKey
}
return keys[0], nil
}
// pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh
// session at signing time so no token handle outlives a call.
type pkcs11Signer struct {
store *PKCS11Store
leaf *x509.Certificate
id []byte
}
func (s *pkcs11Signer) Public() crypto.PublicKey {
return s.leaf.PublicKey
}
func (s *pkcs11Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
scheme, err := schemeFor(s.leaf.PublicKey, opts)
if err != nil {
return nil, err
}
session, err := s.store.open()
if err != nil {
return nil, err
}
defer session.Close()
key, err := privateKey(session, s.id)
if err != nil {
return nil, err
}
signature, err := session.Sign(pkcs11Mechanism(scheme), key, digest)
if err != nil {
return nil, err
}
if scheme == schemeRSAPSSSHA256 {
return signature, nil
}
return ecdsaSignatureASN1(signature)
}
// pkcs11Mechanism maps a signature scheme onto the token mechanism that consumes a digest.
func pkcs11Mechanism(scheme sigScheme) pkcs11.Mechanism {
if scheme == schemeRSAPSSSHA256 {
return pkcs11.Mechanism{
Type: pkcs11.MechRSAPKCSPSS,
PSS: &pkcs11.PSSParams{Hash: pkcs11.MechSHA256, MGF: pkcs11.MGF1SHA256, SaltLen: sha256.Size},
}
}
return pkcs11.Mechanism{Type: pkcs11.MechECDSA}
}
@@ -0,0 +1,189 @@
package certproof
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"errors"
"math/big"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pkcs11"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
const testPKCS11URIEnv = "NB_TEST_PKCS11_URI"
type failingStore struct{}
func (failingStore) Candidates(context.Context) ([]Candidate, error) {
return nil, errors.New("token unplugged")
}
func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key := certtest.ECDSAKey(t)
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background())
require.NoError(t, err)
assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token")
}
// TestCollect_PKCS11TokenEndToEnd needs an initialised token with a user PIN, named by
// NB_TEST_PKCS11_URI. With SoftHSM:
//
// softhsm2-util --init-token --free --label netbird --pin 1234 --so-pin 1234
// NB_TEST_PKCS11_URI='pkcs11:token=netbird?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-value=1234' \
// go test -tags pkcs11 ./client/internal/certproof/ -run PKCS11 -v
//
// 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(uri)
require.NoError(t, err)
if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) {
t.Skip(err)
}
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-"+name)
leaf := ca.Issue(t, key, "device-"+name)
importIdentity(t, uri, key, 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-held key must prove exactly this run's certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the imported certificate")
})
}
}
// Attribute and key 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
attrPrivateExponent = 0x123
attrPrime1 = 0x124
attrPrime2 = 0x125
attrExponent1 = 0x126
attrExponent2 = 0x127
attrCoefficient = 0x128
attrECParams = 0x180
keyTypeRSA = 0x0
keyTypeEC = 0x3
)
var (
ckTrue = []byte{1}
ckFalse = []byte{0}
// The P-256 named curve OID in DER, which is what CKA_EC_PARAMS carries.
oidP256 = []byte{0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07}
)
func importIdentity(t *testing.T, uri string, key crypto.Signer, leaf *x509.Certificate) {
t.Helper()
parsed, err := pkcs11.ParseURI(uri)
require.NoError(t, err)
module, err := pkcs11.Load(parsed.Module())
require.NoError(t, err)
pin, err := parsed.PIN()
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")
}
func keyAttributes(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.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(attrPrivateExponent, k.D.Bytes()),
attr(attrPrime1, k.Primes[0].Bytes()),
attr(attrPrime2, k.Primes[1].Bytes()),
attr(attrExponent1, k.Precomputed.Dp.Bytes()),
attr(attrExponent2, k.Precomputed.Dq.Bytes()),
attr(attrCoefficient, k.Precomputed.Qinv.Bytes()),
}
}
t.Fatalf("unsupported key %T", key)
return nil
}
func attr(typ uint, value []byte) pkcs11.Attribute {
return pkcs11.Attribute{Type: typ, Value: value}
}
+25 -2
View File
@@ -12,6 +12,8 @@ import (
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/tpm"
)
const (
@@ -32,7 +34,8 @@ type Store interface {
}
// 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.
// 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.
type FileStore struct {
dir string
}
@@ -116,7 +119,7 @@ func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY":
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY", tpm.KeyPEMType:
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
@@ -130,6 +133,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case tpm.KeyPEMType:
return tpm.ParseKey(block.Bytes)
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
@@ -154,3 +159,21 @@ func isCertFile(name string) bool {
}
return false
}
// Stores queries several stores and carries on when one fails, so a broken token cannot
// hide the certificates a directory holds. A failure is logged instead of returned
// because Collect treats a store error as "no proofs at all".
type Stores []Store
func (s Stores) Candidates(ctx context.Context) ([]Candidate, error) {
var all []Candidate
for _, store := range s {
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("certificate store %T unavailable: %v", store, err)
continue
}
all = append(all, candidates...)
}
return all, nil
}
+19 -2
View File
@@ -2,7 +2,24 @@
package certproof
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs.
import (
"os"
log "github.com/sirupsen/logrus"
)
// DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs,
// joined by the PKCS#11 token named by NB_CERT_PKCS11_URI when that is set.
func DefaultStore() Store {
return NewFileStore(StoreDir())
files := NewFileStore(StoreDir())
uri := os.Getenv(PKCS11URIEnv)
if uri == "" {
return files
}
token, err := NewPKCS11Store(uri)
if err != nil {
log.Warnf("ignoring %s: %v", PKCS11URIEnv, err)
return files
}
return Stores{files, token}
}
@@ -0,0 +1,98 @@
package certproof
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
"os"
"testing"
"time"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestFileStore_TPMKeyFile(t *testing.T) {
ca := certtest.NewCA(t, "corp")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
leaf := ca.Issue(t, key, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", tpmtest.KeyPEM(t, &key.PublicKey))
// A key that needs a password can never be used silently, so its certificate is skipped.
locked := certtest.ECDSAKey(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked")))
writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), withAuth))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1, "only the key without an authorization value is usable")
assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate")
assert.True(t, key.PublicKey.Equal(candidates[0].Signer.Public()), "signer must report the certificate's key")
}
// TestCollect_TPMKeyEndToEnd runs against the TPM named by NB_TPM_DEVICE, for example a
// swtpm started with:
//
// swtpm socket --tpm2 --server type=unixio,path=/tmp/swtpm.sock \
// --ctrl type=unixio,path=/tmp/swtpm.ctrl --flags not-need-init,startup-clear
//
// It creates a key the way tpm2-openssl does, under a transient ECC primary in the owner
// hierarchy, and proves the certificate for it through the regular file store.
func TestCollect_TPMKeyEndToEnd(t *testing.T) {
if os.Getenv(tpm.DeviceEnv) == "" {
t.Skipf("set %s to a TPM device or swtpm socket to run", tpm.DeviceEnv)
}
public, private := createTPMKey(t)
keyPEM := tpmtest.EncodePEM(t, public, private)
block, _ := pem.Decode([]byte(keyPEM))
require.NotNil(t, block)
signer, err := tpm.ParseKey(block.Bytes)
require.NoError(t, err)
ca := certtest.NewCA(t, "corp")
leaf := ca.Issue(t, signer, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf))
writeFile(t, dir, "device.key", keyPEM)
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(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 1, "the TPM-held key must prove the certificate")
chain, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, leaf.Equal(chain[0]), "proof must carry the device certificate")
}
func createTPMKey(t *testing.T) (public, private []byte) {
t.Helper()
rwc, err := tpm.Open()
require.NoError(t, err)
defer func() { _ = rwc.Close() }()
parent, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", tss2.ECCSRKTemplate)
require.NoError(t, err)
defer func() { _ = tpm2.FlushContext(rwc, parent) }()
private, public, _, _, _, err = tpm2.CreateKey(rwc, parent, tpm2.PCRSelection{}, "", "", tpmtest.SigningTemplate())
require.NoError(t, err)
return public, private
}
+336
View File
@@ -0,0 +1,336 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"encoding/binary"
"errors"
"fmt"
"runtime"
"strings"
"unsafe"
"github.com/ebitengine/purego"
)
// ulong is CK_ULONG, an unsigned long, which is pointer-sized on the 64-bit Linux ABIs
// this file builds for. The struct layouts below assume that width and natural alignment.
type ulong = uintptr
const (
unavailableInformation = ^ulong(0)
flagOSLockingOK = 0x2
flagRWSession = 0x2
flagSerialSession = 0x4
userTypeUser = 0x1
findBatch = 32
)
type version struct {
major byte
minor byte
}
type attribute struct {
typ ulong
value unsafe.Pointer
len ulong
}
type mechanism struct {
typ ulong
parameter unsafe.Pointer
len ulong
}
type pssParams struct {
hashAlg ulong
mgf ulong
saltLen ulong
}
type tokenInfo struct {
label [32]byte
manufacturerID [32]byte
model [16]byte
serialNumber [16]byte
flags ulong
counters [10]ulong
hardware version
firmware version
utcTime [16]byte
}
type initializeArgs struct {
createMutex uintptr
destroyMutex uintptr
lockMutex uintptr
unlockMutex uintptr
flags ulong
reserved unsafe.Pointer
}
// functionList mirrors CK_FUNCTION_LIST: a CK_VERSION padded to pointer alignment, then
// the PKCS#11 v2.40 entry points in specification order.
type functionList struct {
version version
_ [6]byte
fn [68]uintptr
}
const (
fnInitialize = 0
fnGetSlotList = 4
fnGetTokenInfo = 6
fnOpenSession = 12
fnCloseSession = 13
fnLogin = 18
fnLogout = 19
fnCreateObject = 20
fnGetAttributeValue = 24
fnFindObjectsInit = 26
fnFindObjects = 27
fnFindObjectsFinal = 28
fnSignInit = 42
fnSign = 43
)
// module holds the entry points of one loaded library, bound straight from its
// CK_FUNCTION_LIST.
type module struct {
cInitialize func(args *initializeArgs) ulong
cGetSlotList func(tokenPresent byte, slots *ulong, count *ulong) ulong
cGetTokenInfo func(slot ulong, info *tokenInfo) ulong
cOpenSession func(slot ulong, flags ulong, application unsafe.Pointer, notify uintptr, session *ulong) ulong
cCloseSession func(session ulong) ulong
cLogin func(session ulong, userType ulong, pin *byte, pinLen ulong) ulong
cLogout func(session ulong) ulong
cCreateObject func(session ulong, template *attribute, count ulong, object *ulong) ulong
cGetAttributeValue func(session ulong, object ulong, template *attribute, count ulong) ulong
cFindObjectsInit func(session ulong, template *attribute, count ulong) ulong
cFindObjects func(session ulong, objects *ulong, max ulong, count *ulong) ulong
cFindObjectsFinal func(session ulong) ulong
cSignInit func(session ulong, mech *mechanism, key ulong) ulong
cSign func(session ulong, data *byte, dataLen ulong, signature *byte, signatureLen *ulong) ulong
}
func load(path string) (driver, error) {
lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
if err != nil {
return nil, fmt.Errorf("open PKCS#11 module %s: %w", path, err)
}
symbol, err := purego.Dlsym(lib, "C_GetFunctionList")
if err != nil {
return nil, fmt.Errorf("%s is not a PKCS#11 module: %w", path, err)
}
var getFunctionList func(list **functionList) ulong
purego.RegisterFunc(&getFunctionList, symbol)
var list *functionList
if rv := getFunctionList(&list); rv != rvOK || list == nil {
return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)}
}
m := &module{}
for _, entry := range []struct {
fn any
index int
}{
{&m.cInitialize, fnInitialize},
{&m.cGetSlotList, fnGetSlotList},
{&m.cGetTokenInfo, fnGetTokenInfo},
{&m.cOpenSession, fnOpenSession},
{&m.cCloseSession, fnCloseSession},
{&m.cLogin, fnLogin},
{&m.cLogout, fnLogout},
{&m.cCreateObject, fnCreateObject},
{&m.cGetAttributeValue, fnGetAttributeValue},
{&m.cFindObjectsInit, fnFindObjectsInit},
{&m.cFindObjects, fnFindObjects},
{&m.cFindObjectsFinal, fnFindObjectsFinal},
{&m.cSignInit, fnSignInit},
{&m.cSign, fnSign},
} {
if list.fn[entry.index] == 0 {
return nil, fmt.Errorf("%s lacks PKCS#11 entry point %d", path, entry.index)
}
purego.RegisterFunc(entry.fn, list.fn[entry.index])
}
args := &initializeArgs{flags: flagOSLockingOK}
if rv := m.cInitialize(args); rv != rvOK && rv != rvAlreadyInitialized {
return nil, Error{Op: "C_Initialize", Code: uint(rv)}
}
return m, nil
}
func (m *module) tokens() ([]Token, error) {
var count ulong
if rv := m.cGetSlotList(1, nil, &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
if count == 0 {
return nil, nil
}
slots := make([]ulong, count)
if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK {
return nil, Error{Op: "C_GetSlotList", Code: uint(rv)}
}
tokens := make([]Token, 0, count)
for _, slot := range slots[:count] {
var info tokenInfo
if rv := m.cGetTokenInfo(slot, &info); rv != rvOK {
continue
}
tokens = append(tokens, Token{Slot: uint(slot), Label: strings.TrimRight(string(info.label[:]), " \x00")})
}
return tokens, nil
}
func (m *module) openSession(slot uint, readWrite bool) (uint, error) {
flags := ulong(flagSerialSession)
if readWrite {
flags |= flagRWSession
}
var session ulong
if rv := m.cOpenSession(ulong(slot), flags, nil, 0, &session); rv != rvOK {
return 0, Error{Op: "C_OpenSession", Code: uint(rv)}
}
return uint(session), nil
}
func (m *module) closeSession(session uint) {
m.cCloseSession(ulong(session))
}
func (m *module) login(session uint, pin []byte) error {
var pinPtr *byte
if len(pin) > 0 {
pinPtr = &pin[0]
}
rv := m.cLogin(ulong(session), userTypeUser, pinPtr, ulong(len(pin)))
runtime.KeepAlive(pin)
if rv != rvOK && rv != rvUserAlreadyLoggedIn {
return Error{Op: "C_Login", Code: uint(rv)}
}
return nil
}
func (m *module) logout(session uint) {
m.cLogout(ulong(session))
}
func (m *module) findObjects(session uint, template []Attribute) ([]Object, error) {
attrs := toAttributes(template)
rv := m.cFindObjectsInit(ulong(session), first(attrs), ulong(len(attrs)))
runtime.KeepAlive(template)
if rv != rvOK {
return nil, Error{Op: "C_FindObjectsInit", Code: uint(rv)}
}
defer m.cFindObjectsFinal(ulong(session))
var objects []Object
for {
var batch [findBatch]ulong
var count ulong
if rv := m.cFindObjects(ulong(session), &batch[0], findBatch, &count); rv != rvOK {
return nil, Error{Op: "C_FindObjects", Code: uint(rv)}
}
for _, handle := range batch[:count] {
objects = append(objects, Object(handle))
}
if count < findBatch {
return objects, nil
}
}
}
func (m *module) attribute(session uint, obj Object, typ uint) ([]byte, error) {
attr := attribute{typ: ulong(typ)}
if rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1); rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
if attr.len == unavailableInformation {
return nil, fmt.Errorf("attribute 0x%x is unavailable", typ)
}
if attr.len == 0 {
return nil, nil
}
value := make([]byte, attr.len)
attr.value = unsafe.Pointer(&value[0])
rv := m.cGetAttributeValue(ulong(session), ulong(obj), &attr, 1)
runtime.KeepAlive(value)
if rv != rvOK {
return nil, Error{Op: "C_GetAttributeValue", Code: uint(rv)}
}
return value[:attr.len], nil
}
func (m *module) sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("nothing to sign")
}
native := mechanism{typ: ulong(mech.Type)}
var params *pssParams
if mech.PSS != nil {
params = &pssParams{hashAlg: ulong(mech.PSS.Hash), mgf: ulong(mech.PSS.MGF), saltLen: ulong(mech.PSS.SaltLen)}
native.parameter = unsafe.Pointer(params)
native.len = ulong(unsafe.Sizeof(*params))
}
rv := m.cSignInit(ulong(session), &native, ulong(key))
runtime.KeepAlive(params)
if rv != rvOK {
return nil, Error{Op: "C_SignInit", Code: uint(rv)}
}
var size ulong
if rv := m.cSign(ulong(session), &data[0], ulong(len(data)), nil, &size); rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
signature := make([]byte, size)
rv = m.cSign(ulong(session), &data[0], ulong(len(data)), &signature[0], &size)
runtime.KeepAlive(data)
if rv != rvOK {
return nil, Error{Op: "C_Sign", Code: uint(rv)}
}
return signature[:size], nil
}
func (m *module) createObject(session uint, template []Attribute) (Object, error) {
attrs := toAttributes(template)
var object ulong
rv := m.cCreateObject(ulong(session), first(attrs), ulong(len(attrs)), &object)
runtime.KeepAlive(template)
if rv != rvOK {
return 0, Error{Op: "C_CreateObject", Code: uint(rv)}
}
return Object(object), nil
}
func toAttributes(template []Attribute) []attribute {
attrs := make([]attribute, len(template))
for i, a := range template {
attrs[i].typ = ulong(a.Type)
if len(a.Value) > 0 {
attrs[i].value = unsafe.Pointer(&a.Value[0])
attrs[i].len = ulong(len(a.Value))
}
}
return attrs
}
func first(attrs []attribute) *attribute {
if len(attrs) == 0 {
return nil
}
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
}
@@ -0,0 +1,72 @@
//go:build pkcs11 && linux && (amd64 || arm64)
package pkcs11
import (
"crypto/x509"
"os"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStructLayoutsMatchTheCABI(t *testing.T) {
assert.Equal(t, uintptr(24), unsafe.Sizeof(attribute{}), "CK_ATTRIBUTE")
assert.Equal(t, uintptr(24), unsafe.Sizeof(mechanism{}), "CK_MECHANISM")
assert.Equal(t, uintptr(24), unsafe.Sizeof(pssParams{}), "CK_RSA_PKCS_PSS_PARAMS")
assert.Equal(t, uintptr(208), unsafe.Sizeof(tokenInfo{}), "CK_TOKEN_INFO")
assert.Equal(t, uintptr(48), unsafe.Sizeof(initializeArgs{}), "CK_C_INITIALIZE_ARGS")
assert.Equal(t, uintptr(8), unsafe.Offsetof(functionList{}.fn), "entry points follow the padded CK_VERSION")
assert.Equal(t, uintptr(8+68*8), unsafe.Sizeof(functionList{}), "CK_FUNCTION_LIST v2.40")
}
// TestTrustModule_ListsSystemCertificates drives a real module through the binding:
// p11-kit's trust module exposes the system CA store as certificate objects with no login.
func TestTrustModule_ListsSystemCertificates(t *testing.T) {
module := loadFirst(t,
"/usr/lib/pkcs11/p11-kit-trust.so",
"/usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib/aarch64-linux-gnu/pkcs11/p11-kit-trust.so",
"/usr/lib64/pkcs11/p11-kit-trust.so",
)
tokens, err := module.Tokens()
require.NoError(t, err)
require.NotEmpty(t, tokens, "the trust module must present at least one token")
parsed := 0
for _, token := range tokens {
session, err := module.OpenSession(token.Label, nil)
require.NoError(t, err, token.Label)
objects, err := session.FindObjects(
Attribute{Type: AttrClass, Value: ULong(ClassCertificate)},
Attribute{Type: AttrCertificateType, Value: ULong(CertificateX509)},
)
require.NoError(t, err, token.Label)
for _, object := range objects {
der, err := session.Attribute(object, AttrValue)
require.NoError(t, err)
_, err = x509.ParseCertificate(der)
require.NoError(t, err, "CKA_VALUE must be a DER certificate")
parsed++
}
session.Close()
}
assert.Positive(t, parsed, "system trust anchors must be readable through the binding")
}
func loadFirst(t *testing.T, paths ...string) *Module {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
module, err := Load(path)
require.NoError(t, err, path)
return module
}
t.Skip("p11-kit trust module not installed")
return nil
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !(pkcs11 && linux && (amd64 || arm64))
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))
}
+235
View File
@@ -0,0 +1,235 @@
// Package pkcs11 is a minimal PKCS#11 client. It loads a module at runtime without cgo,
// opens a token session, lists objects and signs with keys the token holds. It exists so
// certificates whose keys live in a TPM behind tpm2-pkcs11 can be proven; whatever a
// certificate store does not need is left out.
package pkcs11
import (
"errors"
"fmt"
"sync"
)
// Object classes, attribute types, mechanisms and generators from PKCS#11 v2.40.
const (
ClassCertificate = 0x1
ClassPublicKey = 0x2
ClassPrivateKey = 0x3
CertificateX509 = 0x0
AttrClass = 0x0
AttrToken = 0x1
AttrLabel = 0x3
AttrValue = 0x11
AttrCertificateType = 0x80
AttrSubject = 0x101
AttrID = 0x102
MechRSAPKCSPSS = 0xd
MechSHA256 = 0x250
MechSHA384 = 0x260
MechECDSA = 0x1041
MGF1SHA256 = 0x2
MGF1SHA384 = 0x3
rvOK = 0x0
rvUserAlreadyLoggedIn = 0x100
rvAlreadyInitialized = 0x191
)
var ErrUnsupported = errors.New("PKCS#11 modules need a build with the pkcs11 tag on linux/amd64 or linux/arm64")
// Error is a PKCS#11 return value other than CKR_OK.
type Error struct {
Op string
Code uint
}
func (e Error) Error() string {
if name, ok := returnValueNames[e.Code]; ok {
return fmt.Sprintf("%s: %s", e.Op, name)
}
return fmt.Sprintf("%s: CKR 0x%x", e.Op, e.Code)
}
var returnValueNames = map[uint]string{
0x2: "CKR_HOST_MEMORY",
0x3: "CKR_SLOT_ID_INVALID",
0x5: "CKR_GENERAL_ERROR",
0x7: "CKR_ARGUMENTS_BAD",
0x12: "CKR_ATTRIBUTE_TYPE_INVALID",
0x13: "CKR_ATTRIBUTE_VALUE_INVALID",
0x30: "CKR_DEVICE_ERROR",
0x54: "CKR_FUNCTION_NOT_SUPPORTED",
0x68: "CKR_KEY_FUNCTION_NOT_PERMITTED",
0x70: "CKR_MECHANISM_INVALID",
0x71: "CKR_MECHANISM_PARAM_INVALID",
0x82: "CKR_OBJECT_HANDLE_INVALID",
0xa0: "CKR_PIN_INCORRECT",
0xa4: "CKR_PIN_LOCKED",
0xb3: "CKR_SESSION_HANDLE_INVALID",
0xd0: "CKR_TEMPLATE_INCOMPLETE",
0xd1: "CKR_TEMPLATE_INCONSISTENT",
0xe0: "CKR_TOKEN_NOT_PRESENT",
0x101: "CKR_USER_NOT_LOGGED_IN",
0x150: "CKR_BUFFER_TOO_SMALL",
0x190: "CKR_CRYPTOKI_NOT_INITIALIZED",
}
// Attribute is one entry of a PKCS#11 template. Integer-valued attributes such as the
// object class are encoded with ULong.
type Attribute struct {
Type uint
Value []byte
}
// Mechanism selects a signing algorithm. PSS carries the parameters CKM_RSA_PKCS_PSS needs.
type Mechanism struct {
Type uint
PSS *PSSParams
}
type PSSParams struct {
Hash uint
MGF uint
SaltLen uint
}
// Object is a handle the token issued for one of its objects.
type Object uint
// Token is a slot with a token present.
type Token struct {
Slot uint
Label string
}
// Module is a loaded and initialised PKCS#11 library. A module is loaded once per path
// and never finalised: tokens such as tpm2-pkcs11 do real work in C_Initialize, and the
// process exit releases everything anyway.
type Module struct {
d driver
}
var (
modulesMu sync.Mutex
modules = map[string]*Module{}
)
// Load opens the shared library at path and initialises it, or returns the module already
// loaded from that path.
func Load(path string) (*Module, error) {
modulesMu.Lock()
defer modulesMu.Unlock()
if m, ok := modules[path]; ok {
return m, nil
}
d, err := load(path)
if err != nil {
return nil, err
}
m := &Module{d: d}
modules[path] = m
return m, nil
}
func (m *Module) Tokens() ([]Token, error) {
return m.d.tokens()
}
// OpenSession opens a read-only session with the token carrying label, or with the first
// token when label is empty, and logs in as the user when pin is not nil. An empty,
// non-nil pin still logs in.
func (m *Module) OpenSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, false)
}
// OpenReadWriteSession is OpenSession for callers that create objects on the token.
func (m *Module) OpenReadWriteSession(label string, pin []byte) (*Session, error) {
return m.openSession(label, pin, true)
}
func (m *Module) openSession(label string, pin []byte, readWrite bool) (*Session, error) {
token, err := m.token(label)
if err != nil {
return nil, err
}
handle, err := m.d.openSession(token.Slot, readWrite)
if err != nil {
return nil, err
}
s := &Session{d: m.d, handle: handle}
if pin == nil {
return s, nil
}
if err := m.d.login(handle, pin); err != nil {
s.Close()
return nil, err
}
s.loggedIn = true
return s, nil
}
func (m *Module) token(label string) (Token, error) {
tokens, err := m.Tokens()
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if label == "" || token.Label == label {
return token, nil
}
}
if label == "" {
return Token{}, errors.New("no token present")
}
return Token{}, fmt.Errorf("no token labelled %q among %d tokens", label, len(tokens))
}
// Session is an open session with one token. Close logs out again if the session logged in.
type Session struct {
d driver
handle uint
loggedIn bool
}
func (s *Session) Close() {
if s.loggedIn {
s.d.logout(s.handle)
}
s.d.closeSession(s.handle)
}
// FindObjects returns the handles of every object matching all attributes of template.
func (s *Session) FindObjects(template ...Attribute) ([]Object, error) {
return s.d.findObjects(s.handle, template)
}
// Attribute reads one attribute of an object.
func (s *Session) Attribute(obj Object, typ uint) ([]byte, error) {
return s.d.attribute(s.handle, obj, typ)
}
// Sign signs data, normally a digest, with the token-held key in a single operation.
func (s *Session) Sign(mech Mechanism, key Object, data []byte) ([]byte, error) {
return s.d.sign(s.handle, mech, key, data)
}
// CreateObject stores a new object described by template on the token.
func (s *Session) CreateObject(template ...Attribute) (Object, error) {
return s.d.createObject(s.handle, template)
}
type driver interface {
tokens() ([]Token, error)
openSession(slot uint, readWrite bool) (uint, error)
closeSession(session uint)
login(session uint, pin []byte) error
logout(session uint)
findObjects(session uint, template []Attribute) ([]Object, error)
attribute(session uint, obj Object, typ uint) ([]byte, error)
sign(session uint, mech Mechanism, key Object, data []byte) ([]byte, error)
createObject(session uint, template []Attribute) (Object, error)
}
+98
View File
@@ -0,0 +1,98 @@
package pkcs11
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
)
// DefaultModule is p11-kit's proxy, which exposes every module the system has registered,
// tpm2-pkcs11 included, so a URI without module-path works on a stock p11-kit setup.
const DefaultModule = "p11-kit-proxy.so"
// URI is the subset of an RFC 7512 PKCS#11 URI this client understands: the token label,
// the module to load and where the user PIN comes from. Unknown attributes are ignored.
type URI struct {
Token string
ModulePath string
pinValue *string
pinSource string
}
func ParseURI(raw string) (*URI, error) {
rest, ok := strings.CutPrefix(raw, "pkcs11:")
if !ok {
return nil, errors.New("PKCS#11 URI must start with pkcs11:")
}
path, query, _ := strings.Cut(rest, "?")
u := &URI{}
if err := eachAttribute(path, ";", func(name, value string) {
if name == "token" {
u.Token = value
}
}); err != nil {
return nil, err
}
err := eachAttribute(query, "&", func(name, value string) {
switch name {
case "module-path":
u.ModulePath = value
case "module-name":
u.ModulePath = "lib" + value + ".so"
case "pin-value":
u.pinValue = &value
case "pin-source":
u.pinSource = value
}
})
if err != nil {
return nil, err
}
return u, nil
}
func eachAttribute(list, sep string, fn func(name, value string)) error {
if list == "" {
return nil
}
for _, pair := range strings.Split(list, sep) {
name, value, ok := strings.Cut(pair, "=")
if !ok {
return fmt.Errorf("PKCS#11 URI attribute %q has no value", pair)
}
value, err := url.PathUnescape(value)
if err != nil {
return fmt.Errorf("PKCS#11 URI attribute %s: %w", name, err)
}
fn(name, value)
}
return nil
}
// Module is the library to load, DefaultModule when the URI names none.
func (u *URI) Module() string {
if u.ModulePath == "" {
return DefaultModule
}
return u.ModulePath
}
// PIN returns the user PIN, or nil when the URI carries none and no login should happen.
// A pin-source names a file whose single line is the PIN.
func (u *URI) PIN() ([]byte, error) {
if u.pinValue != nil {
return []byte(*u.pinValue), nil
}
if u.pinSource == "" {
return nil, nil
}
path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:")
pin, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read PIN: %w", err)
}
return []byte(strings.TrimRight(string(pin), "\r\n")), nil
}
+87
View File
@@ -0,0 +1,87 @@
package pkcs11
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseURI(t *testing.T) {
tests := []struct {
name string
raw string
wantToken string
wantModule string
wantPIN []byte
}{
{
name: "token with module path and pin value",
raw: "pkcs11:token=netbird?module-path=/usr/lib/libtpm2_pkcs11.so&pin-value=1234",
wantToken: "netbird",
wantModule: "/usr/lib/libtpm2_pkcs11.so",
wantPIN: []byte("1234"),
},
{
name: "module name becomes a library file",
raw: "pkcs11:token=netbird?module-name=tpm2_pkcs11",
wantToken: "netbird",
wantModule: "libtpm2_pkcs11.so",
},
{
name: "percent encoding and unknown attributes",
raw: "pkcs11:model=SoftHSM%20v2;token=my%20token;serial=1?max-sessions=1",
wantToken: "my token",
wantModule: DefaultModule,
},
{
name: "bare scheme uses the p11-kit proxy and no login",
raw: "pkcs11:",
wantModule: DefaultModule,
},
{
name: "empty pin value still logs in",
raw: "pkcs11:?pin-value=",
wantModule: DefaultModule,
wantPIN: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uri, err := ParseURI(tt.raw)
require.NoError(t, err)
assert.Equal(t, tt.wantToken, uri.Token, "token label")
assert.Equal(t, tt.wantModule, uri.Module(), "module to load")
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, tt.wantPIN, pin, "PIN, nil meaning no login")
})
}
}
func TestParseURI_Rejections(t *testing.T) {
for _, raw := range []string{"pkcs11", "https://example.com", "pkcs11:token", "pkcs11:token=%zz"} {
_, err := ParseURI(raw)
assert.Error(t, err, raw)
}
}
func TestURI_PINFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "pin")
require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600))
for _, source := range []string{path, "file:" + path, "file://" + path} {
uri, err := ParseURI("pkcs11:token=netbird?pin-source=" + source)
require.NoError(t, err)
pin, err := uri.PIN()
require.NoError(t, err)
assert.Equal(t, []byte("secret"), pin, "PIN from %s must drop the trailing newline", source)
}
uri, err := ParseURI("pkcs11:?pin-source=" + filepath.Join(t.TempDir(), "missing"))
require.NoError(t, err)
_, err = uri.PIN()
assert.Error(t, err, "a missing PIN file must fail loudly instead of logging in without a PIN")
}
+29
View File
@@ -0,0 +1,29 @@
package tpm
import (
"errors"
"fmt"
"io"
"os"
"github.com/google/go-tpm/tpmutil"
)
// The kernel resource manager multiplexes clients and flushes what they leave behind,
// so it is tried before the raw device.
var devicePaths = []string{"/dev/tpmrm0", "/dev/tpm0"}
func open() (io.ReadWriteCloser, error) {
if path := os.Getenv(DeviceEnv); path != "" {
return tpmutil.OpenTPM(path)
}
var errs error
for _, path := range devicePaths {
rwc, err := tpmutil.OpenTPM(path)
if err == nil {
return rwc, nil
}
errs = errors.Join(errs, err)
}
return nil, fmt.Errorf("open TPM: %w", errs)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package tpm
import "io"
func open() (io.ReadWriteCloser, error) {
return nil, ErrUnsupported
}
+61
View File
@@ -0,0 +1,61 @@
package tpm
import (
"crypto"
"errors"
"fmt"
"io"
"go.step.sm/crypto/tpm/tss2"
)
// KeyPEMType is the PEM block type of a TPM 2.0 key file as defined by
// draft-bottomley-tpm2-keys and written by tpm2-openssl and tpm2-tss-engine.
const KeyPEMType = "TSS2 PRIVATE KEY"
var ErrKeyNeedsAuth = errors.New("TPM key requires an authorization value")
// ParseKey reads a TSS2 key file and returns a signer that produces every signature
// inside the TPM; only the digest goes in and only the signature comes out. A key with
// a persistent parent is loaded under it, a key whose parent is a hierarchy under the
// TCG default ECC primary that tpm2-openssl and tpm2-tss-engine derive as well. Keys
// guarded by an authorization value are rejected, since nothing can supply it without
// prompting.
func ParseKey(der []byte) (crypto.Signer, error) {
key, err := tss2.ParsePrivateKey(der)
if err != nil {
return nil, fmt.Errorf("parse TSS2 key: %w", err)
}
if !key.EmptyAuth {
return nil, ErrKeyNeedsAuth
}
public, err := key.Public()
if err != nil {
return nil, fmt.Errorf("decode TSS2 public key: %w", err)
}
return &keySigner{key: key, public: public}, nil
}
type keySigner struct {
key *tss2.TPMKey
public crypto.PublicKey
}
func (s *keySigner) Public() crypto.PublicKey {
return s.public
}
func (s *keySigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
rwc, err := Open()
if err != nil {
return nil, err
}
defer func() { _ = rwc.Close() }()
signer, err := tss2.CreateSigner(rwc, s.key)
if err != nil {
return nil, fmt.Errorf("load TSS2 key: %w", err)
}
signer.SetSRKTemplate(tss2.ECCSRKTemplate)
return signer.Sign(rand, digest, opts)
}
+64
View File
@@ -0,0 +1,64 @@
package tpm
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/pem"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
"github.com/netbirdio/netbird/client/internal/tpm/tpmtest"
)
func TestParseKey_ReportsPublicKeyWithoutTouchingTPM(t *testing.T) {
key := newP256Key(t)
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey)))
require.NoError(t, err)
assert.True(t, key.PublicKey.Equal(signer.Public()), "signer must expose the key the TPM holds")
}
func TestParseKey_RejectsKeyWithAuthorization(t *testing.T) {
key := newP256Key(t)
withAuth := func(k *tss2.TPMKey) { k.EmptyAuth = false }
_, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &key.PublicKey, withAuth)))
assert.ErrorIs(t, err, ErrKeyNeedsAuth)
}
func TestParseKey_RejectsMalformedKey(t *testing.T) {
_, err := ParseKey([]byte("not a TSS2 key"))
assert.Error(t, err)
}
func TestSign_FailsWhenTPMIsUnreachable(t *testing.T) {
t.Setenv(DeviceEnv, filepath.Join(t.TempDir(), "missing"))
signer, err := ParseKey(decodePEM(t, tpmtest.KeyPEM(t, &newP256Key(t).PublicKey)))
require.NoError(t, err)
digest := sha256.Sum256([]byte("challenge"))
_, err = signer.Sign(rand.Reader, digest[:], crypto.SHA256)
assert.Error(t, err, "signing must not fall back to software when the TPM is missing")
}
func newP256Key(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func decodePEM(t *testing.T, pemData string) []byte {
t.Helper()
block, _ := pem.Decode([]byte(pemData))
require.NotNil(t, block)
require.Equal(t, KeyPEMType, block.Type)
return block.Bytes
}
+19
View File
@@ -0,0 +1,19 @@
// Package tpm is the client's one door to the platform TPM 2.0. It opens the device
// and turns TPM-held key files into signers; every operation opens the TPM, runs and
// closes it, so no handle outlives a call.
package tpm
import (
"errors"
"io"
)
// DeviceEnv overrides the TPM device path, which also lets tests point at a swtpm socket.
const DeviceEnv = "NB_TPM_DEVICE"
var ErrUnsupported = errors.New("TPM is not supported on this platform")
// Open connects to the platform TPM 2.0. The caller closes it after one operation.
func Open() (io.ReadWriteCloser, error) {
return open()
}
+48
View File
@@ -0,0 +1,48 @@
// Package tpmtest builds TSS2 key files for tests, with or without a TPM behind them.
package tpmtest
import (
"crypto/ecdsa"
"crypto/elliptic"
"testing"
"github.com/google/go-tpm/legacy/tpm2"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/tpm/tss2"
)
const p256Bytes = 32
// SigningTemplate is the public area of an unrestricted P-256 signing key with no fixed
// scheme, the shape tpm2-openssl creates certificate keys in.
func SigningTemplate() tpm2.Public {
return tpm2.Public{
Type: tpm2.AlgECC,
NameAlg: tpm2.AlgSHA256,
Attributes: tpm2.FlagSign | tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagNoDA,
ECCParameters: &tpm2.ECCParams{CurveID: tpm2.CurveNISTP256},
}
}
// KeyPEM encodes pub as a TSS2 PRIVATE KEY over a placeholder private blob: it parses
// and reports pub, but no TPM can load it.
func KeyPEM(t *testing.T, pub *ecdsa.PublicKey, opts ...tss2.TPMOption) string {
t.Helper()
require.Equal(t, elliptic.P256(), pub.Curve, "fixture keys must be P-256")
area := SigningTemplate()
area.ECCParameters.Point = tpm2.ECPoint{
XRaw: pub.X.FillBytes(make([]byte, p256Bytes)),
YRaw: pub.Y.FillBytes(make([]byte, p256Bytes)),
}
encoded, err := area.Encode()
require.NoError(t, err)
return EncodePEM(t, encoded, []byte("placeholder"), opts...)
}
// EncodePEM wraps the public and private blobs TPM2_Create returned into a TSS2 PRIVATE KEY.
func EncodePEM(t *testing.T, public, private []byte, opts ...tss2.TPMOption) string {
t.Helper()
pemBytes, err := tss2.New(public, private, opts...).EncodeToMemory()
require.NoError(t, err)
return string(pemBytes)
}