mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-17 20:29:07 +02:00
start TPM support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package tpm
|
||||
|
||||
import "io"
|
||||
|
||||
func open() (io.ReadWriteCloser, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user