summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrew Lytvynov <awly@tailscale.com>2025-06-18 14:17:12 -0700
committerGitHub <noreply@github.com>2025-06-18 14:17:12 -0700
commit4979ce7a94cd023db5cd03cbb556934d9652dfd2 (patch)
treed317ef61279fcff08cb6bb74e8c71660b92180eb
parentad0dfcb1857105597b1bed3422c9057aafd7b22f (diff)
downloadtailscale-4979ce7a94cd023db5cd03cbb556934d9652dfd2.tar.xz
tailscale-4979ce7a94cd023db5cd03cbb556934d9652dfd2.zip
feature/tpm: implement ipn.StateStore using TPM sealing (#16030)
Updates #15830 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
-rw-r--r--cmd/tailscaled/depaware.txt2
-rw-r--r--feature/tpm/tpm.go322
-rw-r--r--feature/tpm/tpm_linux.go11
-rw-r--r--feature/tpm/tpm_other.go10
-rw-r--r--feature/tpm/tpm_test.go165
-rw-r--r--feature/tpm/tpm_windows.go11
-rw-r--r--ipn/store/stores.go2
7 files changed, 500 insertions, 23 deletions
diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index 387b944c1..7c4885a4b 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -474,7 +474,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
golang.org/x/crypto/nacl/box from tailscale.com/types/key
- golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
+ golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
LD golang.org/x/crypto/ssh from github.com/pkg/sftp+
diff --git a/feature/tpm/tpm.go b/feature/tpm/tpm.go
index 18e56ae89..6feac85e3 100644
--- a/feature/tpm/tpm.go
+++ b/feature/tpm/tpm.go
@@ -5,14 +5,29 @@
package tpm
import (
+ "bytes"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
"slices"
+ "strings"
"sync"
"github.com/google/go-tpm/tpm2"
"github.com/google/go-tpm/tpm2/transport"
+ "golang.org/x/crypto/nacl/secretbox"
+ "tailscale.com/atomicfile"
"tailscale.com/feature"
"tailscale.com/hostinfo"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/store"
+ "tailscale.com/paths"
"tailscale.com/tailcfg"
+ "tailscale.com/types/logger"
)
var infoOnce = sync.OnceValue(info)
@@ -22,10 +37,16 @@ func init() {
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
hi.TPM = infoOnce()
})
+ store.Register(storePrefix, newStore)
}
-//lint:ignore U1000 used in Linux and Windows builds only
-func infoFromCapabilities(tpm transport.TPM) *tailcfg.TPMInfo {
+func info() *tailcfg.TPMInfo {
+ tpm, err := open()
+ if err != nil {
+ return nil
+ }
+ defer tpm.Close()
+
info := new(tailcfg.TPMInfo)
toStr := func(s *string) func(*tailcfg.TPMInfo, uint32) {
return func(info *tailcfg.TPMInfo, value uint32) {
@@ -81,3 +102,300 @@ func propToString(v uint32) string {
// Delete any non-printable ASCII characters.
return string(slices.DeleteFunc(chars, func(b byte) bool { return b < ' ' || b > '~' }))
}
+
+const storePrefix = "tpmseal:"
+
+func newStore(logf logger.Logf, path string) (ipn.StateStore, error) {
+ path = strings.TrimPrefix(path, storePrefix)
+ if err := paths.MkStateDir(filepath.Dir(path)); err != nil {
+ return nil, fmt.Errorf("creating state directory: %w", err)
+ }
+ var parsed map[ipn.StateKey][]byte
+ bs, err := os.ReadFile(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to open %q: %w", path, err)
+ }
+ logf("tpm.newStore: initializing state file")
+
+ var key [32]byte
+ // crypto/rand.Read never returns an error.
+ rand.Read(key[:])
+
+ store := &tpmStore{
+ logf: logf,
+ path: path,
+ key: key,
+ cache: make(map[ipn.StateKey][]byte),
+ }
+ if err := store.writeSealed(); err != nil {
+ return nil, fmt.Errorf("failed to write initial state file: %w", err)
+ }
+ return store, nil
+ }
+
+ // State file exists, unseal and parse it.
+ var sealed encryptedData
+ if err := json.Unmarshal(bs, &sealed); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal state file: %w", err)
+ }
+ if len(sealed.Data) == 0 || sealed.Key == nil || len(sealed.Nonce) == 0 {
+ return nil, fmt.Errorf("state file %q has not been TPM-sealed or is corrupt", path)
+ }
+ data, err := unseal(logf, sealed)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unseal state file: %w", err)
+ }
+ if err := json.Unmarshal(data.Data, &parsed); err != nil {
+ return nil, fmt.Errorf("failed to parse state file: %w", err)
+ }
+ return &tpmStore{
+ logf: logf,
+ path: path,
+ key: data.Key,
+ cache: parsed,
+ }, nil
+}
+
+// tpmStore is an ipn.StateStore that stores the state in a secretbox-encrypted
+// file using a TPM-sealed symmetric key.
+type tpmStore struct {
+ logf logger.Logf
+ path string
+ key [32]byte
+
+ mu sync.RWMutex
+ cache map[ipn.StateKey][]byte
+}
+
+func (s *tpmStore) ReadState(k ipn.StateKey) ([]byte, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ v, ok := s.cache[k]
+ if !ok {
+ return nil, ipn.ErrStateNotExist
+ }
+ return bytes.Clone(v), nil
+}
+
+func (s *tpmStore) WriteState(k ipn.StateKey, bs []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if bytes.Equal(s.cache[k], bs) {
+ return nil
+ }
+ s.cache[k] = bytes.Clone(bs)
+
+ return s.writeSealed()
+}
+
+func (s *tpmStore) writeSealed() error {
+ bs, err := json.Marshal(s.cache)
+ if err != nil {
+ return err
+ }
+ sealed, err := seal(s.logf, decryptedData{Key: s.key, Data: bs})
+ if err != nil {
+ return fmt.Errorf("failed to seal state file: %w", err)
+ }
+ buf, err := json.Marshal(sealed)
+ if err != nil {
+ return err
+ }
+ return atomicfile.WriteFile(s.path, buf, 0600)
+}
+
+// The nested levels of encoding and encryption are confusing, so here's what's
+// going on in plain English.
+//
+// Not all TPM devices support symmetric encryption (TPM2_EncryptDecrypt2)
+// natively, but they do support "sealing" small values (see
+// tpmSeal/tpmUnseal). The size limit is too small for the actual state file,
+// so we seal a symmetric key instead. This symmetric key is then used to seal
+// the actual data using nacl/secretbox.
+// Confusingly, both TPMs and secretbox use "seal" terminology.
+//
+// tpmSeal/tpmUnseal do the lower-level sealing of small []byte blobs, which we
+// use to seal a 32-byte secretbox key.
+//
+// seal/unseal do the higher-level sealing of store data using secretbox, and
+// also sealing of the symmetric key using TPM.
+
+// decryptedData contains the fully decrypted raw data along with the symmetric
+// key used for secretbox. This struct should only live in memory and never get
+// stored to disk!
+type decryptedData struct {
+ Key [32]byte
+ Data []byte
+}
+
+func (decryptedData) MarshalJSON() ([]byte, error) {
+ return nil, errors.New("[unexpected]: decryptedData should never get JSON-marshaled!")
+}
+
+// encryptedData contains the secretbox-sealed data and nonce, along with a
+// TPM-sealed key. All fields are required.
+type encryptedData struct {
+ Key *tpmSealedData `json:"key"`
+ Nonce []byte `json:"nonce"`
+ Data []byte `json:"data"`
+}
+
+func seal(logf logger.Logf, dec decryptedData) (*encryptedData, error) {
+ var nonce [24]byte
+ // crypto/rand.Read never returns an error.
+ rand.Read(nonce[:])
+
+ sealedData := secretbox.Seal(nil, dec.Data, &nonce, &dec.Key)
+ sealedKey, err := tpmSeal(logf, dec.Key[:])
+ if err != nil {
+ return nil, fmt.Errorf("failed to seal encryption key to TPM: %w", err)
+ }
+
+ return &encryptedData{
+ Key: sealedKey,
+ Nonce: nonce[:],
+ Data: sealedData,
+ }, nil
+}
+
+func unseal(logf logger.Logf, data encryptedData) (*decryptedData, error) {
+ if len(data.Nonce) != 24 {
+ return nil, fmt.Errorf("nonce should be 24 bytes long, got %d", len(data.Nonce))
+ }
+
+ unsealedKey, err := tpmUnseal(logf, data.Key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unseal encryption key with TPM: %w", err)
+ }
+ if len(unsealedKey) != 32 {
+ return nil, fmt.Errorf("unsealed key should be 32 bytes long, got %d", len(unsealedKey))
+ }
+ unsealedData, ok := secretbox.Open(nil, data.Data, (*[24]byte)(data.Nonce), (*[32]byte)(unsealedKey))
+ if !ok {
+ return nil, errors.New("failed to unseal data")
+ }
+
+ return &decryptedData{
+ Key: *(*[32]byte)(unsealedKey),
+ Data: unsealedData,
+ }, nil
+}
+
+type tpmSealedData struct {
+ Private []byte
+ Public []byte
+}
+
+// withSRK runs fn with the loaded Storage Root Key (SRK) handle. The SRK is
+// flushed after fn returns.
+func withSRK(logf logger.Logf, tpm transport.TPM, fn func(srk tpm2.AuthHandle) error) error {
+ srkCmd := tpm2.CreatePrimary{
+ PrimaryHandle: tpm2.TPMRHOwner,
+ InPublic: tpm2.New2B(tpm2.ECCSRKTemplate),
+ }
+ srkRes, err := srkCmd.Execute(tpm)
+ if err != nil {
+ return fmt.Errorf("tpm2.CreatePrimary: %w", err)
+ }
+ defer func() {
+ cmd := tpm2.FlushContext{FlushHandle: srkRes.ObjectHandle}
+ if _, err := cmd.Execute(tpm); err != nil {
+ logf("tpm2.FlushContext: failed to flush SRK handle: %v", err)
+ }
+ }()
+
+ return fn(tpm2.AuthHandle{
+ Handle: srkRes.ObjectHandle,
+ Name: srkRes.Name,
+ Auth: tpm2.HMAC(tpm2.TPMAlgSHA256, 32),
+ })
+}
+
+// tpmSeal seals the data using SRK of the local TPM.
+func tpmSeal(logf logger.Logf, data []byte) (*tpmSealedData, error) {
+ tpm, err := open()
+ if err != nil {
+ return nil, fmt.Errorf("opening TPM: %w", err)
+ }
+ defer tpm.Close()
+
+ var res *tpmSealedData
+ err = withSRK(logf, tpm, func(srk tpm2.AuthHandle) error {
+ sealCmd := tpm2.Create{
+ ParentHandle: srk,
+ InSensitive: tpm2.TPM2BSensitiveCreate{
+ Sensitive: &tpm2.TPMSSensitiveCreate{
+ Data: tpm2.NewTPMUSensitiveCreate(&tpm2.TPM2BSensitiveData{
+ Buffer: data,
+ }),
+ },
+ },
+ InPublic: tpm2.New2B(tpm2.TPMTPublic{
+ Type: tpm2.TPMAlgKeyedHash,
+ NameAlg: tpm2.TPMAlgSHA256,
+ ObjectAttributes: tpm2.TPMAObject{
+ FixedTPM: true,
+ FixedParent: true,
+ UserWithAuth: true,
+ },
+ }),
+ }
+ sealRes, err := sealCmd.Execute(tpm)
+ if err != nil {
+ return fmt.Errorf("tpm2.Create: %w", err)
+ }
+
+ res = &tpmSealedData{
+ Private: sealRes.OutPrivate.Buffer,
+ Public: sealRes.OutPublic.Bytes(),
+ }
+ return nil
+ })
+ return res, err
+}
+
+// tpmUnseal unseals the data using SRK of the local TPM.
+func tpmUnseal(logf logger.Logf, data *tpmSealedData) ([]byte, error) {
+ tpm, err := open()
+ if err != nil {
+ return nil, fmt.Errorf("opening TPM: %w", err)
+ }
+ defer tpm.Close()
+
+ var res []byte
+ err = withSRK(logf, tpm, func(srk tpm2.AuthHandle) error {
+ // Load the sealed object into the TPM first under SRK.
+ loadCmd := tpm2.Load{
+ ParentHandle: srk,
+ InPrivate: tpm2.TPM2BPrivate{Buffer: data.Private},
+ InPublic: tpm2.BytesAs2B[tpm2.TPMTPublic](data.Public),
+ }
+ loadRes, err := loadCmd.Execute(tpm)
+ if err != nil {
+ return fmt.Errorf("tpm2.Load: %w", err)
+ }
+ defer func() {
+ cmd := tpm2.FlushContext{FlushHandle: loadRes.ObjectHandle}
+ if _, err := cmd.Execute(tpm); err != nil {
+ log.Printf("tpm2.FlushContext: failed to flush loaded sealed blob handle: %v", err)
+ }
+ }()
+
+ // Then unseal the object.
+ unsealCmd := tpm2.Unseal{
+ ItemHandle: tpm2.NamedHandle{
+ Handle: loadRes.ObjectHandle,
+ Name: loadRes.Name,
+ },
+ }
+ unsealRes, err := unsealCmd.Execute(tpm)
+ if err != nil {
+ return fmt.Errorf("tpm2.Unseal: %w", err)
+ }
+ res = unsealRes.OutData.Buffer
+
+ return nil
+ })
+ return res, err
+}
diff --git a/feature/tpm/tpm_linux.go b/feature/tpm/tpm_linux.go
index a90c0e153..f2d0f1402 100644
--- a/feature/tpm/tpm_linux.go
+++ b/feature/tpm/tpm_linux.go
@@ -4,15 +4,10 @@
package tpm
import (
+ "github.com/google/go-tpm/tpm2/transport"
"github.com/google/go-tpm/tpm2/transport/linuxtpm"
- "tailscale.com/tailcfg"
)
-func info() *tailcfg.TPMInfo {
- t, err := linuxtpm.Open("/dev/tpm0")
- if err != nil {
- return nil
- }
- defer t.Close()
- return infoFromCapabilities(t)
+func open() (transport.TPMCloser, error) {
+ return linuxtpm.Open("/dev/tpm0")
}
diff --git a/feature/tpm/tpm_other.go b/feature/tpm/tpm_other.go
index ba7c67621..108b2c057 100644
--- a/feature/tpm/tpm_other.go
+++ b/feature/tpm/tpm_other.go
@@ -5,8 +5,12 @@
package tpm
-import "tailscale.com/tailcfg"
+import (
+ "errors"
-func info() *tailcfg.TPMInfo {
- return nil
+ "github.com/google/go-tpm/tpm2/transport"
+)
+
+func open() (transport.TPMCloser, error) {
+ return nil, errors.New("TPM not supported on this platform")
}
diff --git a/feature/tpm/tpm_test.go b/feature/tpm/tpm_test.go
index fc0fc178c..a022b69b2 100644
--- a/feature/tpm/tpm_test.go
+++ b/feature/tpm/tpm_test.go
@@ -3,7 +3,17 @@
package tpm
-import "testing"
+import (
+ "bytes"
+ "crypto/rand"
+ "errors"
+ "path/filepath"
+ "strconv"
+ "testing"
+
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/store"
+)
func TestPropToString(t *testing.T) {
for prop, want := range map[uint32]string{
@@ -17,3 +27,156 @@ func TestPropToString(t *testing.T) {
}
}
}
+
+func skipWithoutTPM(t testing.TB) {
+ tpm, err := open()
+ if err != nil {
+ t.Skip("TPM not available")
+ }
+ tpm.Close()
+}
+
+func TestSealUnseal(t *testing.T) {
+ skipWithoutTPM(t)
+
+ data := make([]byte, 100*1024)
+ rand.Read(data)
+ var key [32]byte
+ rand.Read(key[:])
+
+ sealed, err := seal(t.Logf, decryptedData{Key: key, Data: data})
+ if err != nil {
+ t.Fatalf("seal: %v", err)
+ }
+ if bytes.Contains(sealed.Data, data) {
+ t.Fatalf("sealed data %q contains original input %q", sealed.Data, data)
+ }
+
+ unsealed, err := unseal(t.Logf, *sealed)
+ if err != nil {
+ t.Fatalf("unseal: %v", err)
+ }
+ if !bytes.Equal(data, unsealed.Data) {
+ t.Errorf("got unsealed data: %q, want: %q", unsealed, data)
+ }
+ if key != unsealed.Key {
+ t.Errorf("got unsealed key: %q, want: %q", unsealed.Key, key)
+ }
+}
+
+func TestStore(t *testing.T) {
+ skipWithoutTPM(t)
+
+ path := storePrefix + filepath.Join(t.TempDir(), "state")
+ store, err := newStore(t.Logf, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ checkState := func(t *testing.T, store ipn.StateStore, k ipn.StateKey, want []byte) {
+ got, err := store.ReadState(k)
+ if err != nil {
+ t.Errorf("ReadState(%q): %v", k, err)
+ }
+ if !bytes.Equal(want, got) {
+ t.Errorf("ReadState(%q): got %q, want %q", k, got, want)
+ }
+ }
+
+ k1, k2 := ipn.StateKey("k1"), ipn.StateKey("k2")
+ v1, v2 := []byte("v1"), []byte("v2")
+
+ t.Run("read-non-existent-key", func(t *testing.T) {
+ _, err := store.ReadState(k1)
+ if !errors.Is(err, ipn.ErrStateNotExist) {
+ t.Errorf("ReadState succeeded, want %v", ipn.ErrStateNotExist)
+ }
+ })
+
+ t.Run("read-write-k1", func(t *testing.T) {
+ if err := store.WriteState(k1, v1); err != nil {
+ t.Errorf("WriteState(%q, %q): %v", k1, v1, err)
+ }
+ checkState(t, store, k1, v1)
+ })
+
+ t.Run("read-write-k2", func(t *testing.T) {
+ if err := store.WriteState(k2, v2); err != nil {
+ t.Errorf("WriteState(%q, %q): %v", k2, v2, err)
+ }
+ checkState(t, store, k2, v2)
+ })
+
+ t.Run("update-k2", func(t *testing.T) {
+ v2 = []byte("new v2")
+ if err := store.WriteState(k2, v2); err != nil {
+ t.Errorf("WriteState(%q, %q): %v", k2, v2, err)
+ }
+ checkState(t, store, k2, v2)
+ })
+
+ t.Run("reopen-store", func(t *testing.T) {
+ store, err := newStore(t.Logf, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkState(t, store, k1, v1)
+ checkState(t, store, k2, v2)
+ })
+}
+
+func BenchmarkStore(b *testing.B) {
+ skipWithoutTPM(b)
+ b.StopTimer()
+
+ stores := make(map[string]ipn.StateStore)
+ key := ipn.StateKey(b.Name())
+
+ // Set up tpmStore
+ tpmStore, err := newStore(b.Logf, filepath.Join(b.TempDir(), "tpm.store"))
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := tpmStore.WriteState(key, []byte("-1")); err != nil {
+ b.Fatal(err)
+ }
+ stores["tpmStore"] = tpmStore
+
+ // Set up FileStore
+ fileStore, err := store.NewFileStore(b.Logf, filepath.Join(b.TempDir(), "file.store"))
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := fileStore.WriteState(key, []byte("-1")); err != nil {
+ b.Fatal(err)
+ }
+ stores["fileStore"] = fileStore
+
+ b.StartTimer()
+
+ for name, store := range stores {
+ b.Run(name, func(b *testing.B) {
+ b.Run("write-noop", func(b *testing.B) {
+ for range b.N {
+ if err := store.WriteState(key, []byte("-1")); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ b.Run("write", func(b *testing.B) {
+ for i := range b.N {
+ if err := store.WriteState(key, []byte(strconv.Itoa(i))); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ b.Run("read", func(b *testing.B) {
+ for range b.N {
+ if _, err := store.ReadState(key); err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+ })
+ }
+}
diff --git a/feature/tpm/tpm_windows.go b/feature/tpm/tpm_windows.go
index 578d687af..429d20cb8 100644
--- a/feature/tpm/tpm_windows.go
+++ b/feature/tpm/tpm_windows.go
@@ -4,15 +4,10 @@
package tpm
import (
+ "github.com/google/go-tpm/tpm2/transport"
"github.com/google/go-tpm/tpm2/transport/windowstpm"
- "tailscale.com/tailcfg"
)
-func info() *tailcfg.TPMInfo {
- t, err := windowstpm.Open()
- if err != nil {
- return nil
- }
- defer t.Close()
- return infoFromCapabilities(t)
+func open() (transport.TPMCloser, error) {
+ return windowstpm.Open()
}
diff --git a/ipn/store/stores.go b/ipn/store/stores.go
index 1f98891bf..1a98574c9 100644
--- a/ipn/store/stores.go
+++ b/ipn/store/stores.go
@@ -45,6 +45,8 @@ var knownStores map[string]Provider
// the suffix an AWS ARN for an SSM.
// - (Linux-only) if the string begins with "kube:",
// the suffix is a Kubernetes secret name
+// - (Linux or Windows) if the string begins with "tpmseal:", the suffix is
+// filepath that is sealed with the local TPM device.
// - In all other cases, the path is treated as a filepath.
func New(logf logger.Logf, path string) (ipn.StateStore, error) {
for prefix, sf := range knownStores {