Andrew Lytvynov 4979ce7a94
feature/tpm: implement ipn.StateStore using TPM sealing (#16030)
Updates #15830

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2025-06-18 14:17:12 -07:00

402 lines
11 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package tpm implements support for TPM 2.0 devices.
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)
func init() {
feature.Register("tpm")
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
hi.TPM = infoOnce()
})
store.Register(storePrefix, newStore)
}
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) {
*s += propToString(value)
}
}
for _, cap := range []struct {
prop tpm2.TPMPT
apply func(info *tailcfg.TPMInfo, value uint32)
}{
{tpm2.TPMPTManufacturer, toStr(&info.Manufacturer)},
{tpm2.TPMPTVendorString1, toStr(&info.Vendor)},
{tpm2.TPMPTVendorString2, toStr(&info.Vendor)},
{tpm2.TPMPTVendorString3, toStr(&info.Vendor)},
{tpm2.TPMPTVendorString4, toStr(&info.Vendor)},
{tpm2.TPMPTRevision, func(info *tailcfg.TPMInfo, value uint32) { info.SpecRevision = int(value) }},
{tpm2.TPMPTVendorTPMType, func(info *tailcfg.TPMInfo, value uint32) { info.Model = int(value) }},
{tpm2.TPMPTFirmwareVersion1, func(info *tailcfg.TPMInfo, value uint32) { info.FirmwareVersion += uint64(value) << 32 }},
{tpm2.TPMPTFirmwareVersion2, func(info *tailcfg.TPMInfo, value uint32) { info.FirmwareVersion += uint64(value) }},
} {
resp, err := tpm2.GetCapability{
Capability: tpm2.TPMCapTPMProperties,
Property: uint32(cap.prop),
PropertyCount: 1,
}.Execute(tpm)
if err != nil {
continue
}
props, err := resp.CapabilityData.Data.TPMProperties()
if err != nil {
continue
}
if len(props.TPMProperty) == 0 {
continue
}
cap.apply(info, props.TPMProperty[0].Value)
}
return info
}
// propToString converts TPM_PT property value, which is a uint32, into a
// string of up to 4 ASCII characters. This encoding applies only to some
// properties, see
// https://trustedcomputinggroup.org/resource/tpm-library-specification/ Part
// 2, section 6.13.
func propToString(v uint32) string {
chars := []byte{
byte(v >> 24),
byte(v >> 16),
byte(v >> 8),
byte(v),
}
// 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
}