ipn/store: automatically migrate between plaintext and encrypted state (#16318)

Add a new `--encrypt-state` flag to `cmd/tailscaled`. Based on that
flag, migrate the existing state file to/from encrypted format if
needed.

Updates #15830

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
Andrew Lytvynov 2025-06-26 17:09:13 -07:00 committed by GitHub
parent d2c1ed22c3
commit 6feb3c35cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 546 additions and 26 deletions

View File

@ -48,5 +48,9 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
if err := f.Close(); err != nil {
return err
}
return rename(tmpName, filename)
return Rename(tmpName, filename)
}
// Rename srcFile to dstFile, similar to [os.Rename] but preserving file
// attributes and ACLs on Windows.
func Rename(srcFile, dstFile string) error { return rename(srcFile, dstFile) }

View File

@ -64,6 +64,7 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/multierr"
"tailscale.com/util/osshare"
"tailscale.com/util/syspolicy"
"tailscale.com/version"
"tailscale.com/version/distro"
"tailscale.com/wgengine"
@ -126,6 +127,7 @@ var args struct {
debug string
port uint16
statepath string
encryptState bool
statedir string
socketpath string
birdSocketPath string
@ -193,6 +195,7 @@ func main() {
flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`)
flag.Var(flagtype.PortValue(&args.port, defaultPort()), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an ephemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile())
flag.BoolVar(&args.encryptState, "encrypt-state", defaultEncryptState(), "encrypt the state file on disk; uses TPM on Linux and Windows, on all other platforms this flag is not supported")
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
@ -268,6 +271,28 @@ func main() {
}
}
if args.encryptState {
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
log.SetFlags(0)
log.Fatalf("--encrypt-state is not supported on %s", runtime.GOOS)
}
// Check if we have TPM support in this build.
if !store.HasKnownProviderPrefix(store.TPMPrefix + "/") {
log.SetFlags(0)
log.Fatal("--encrypt-state is not supported in this build of tailscaled")
}
// Check if we have TPM access.
if !hostinfo.New().TPM.Present() {
log.SetFlags(0)
log.Fatal("--encrypt-state is not supported on this device or a TPM is not accessible")
}
// Check for conflicting prefix in --state, like arn: or kube:.
if args.statepath != "" && store.HasKnownProviderPrefix(args.statepath) {
log.SetFlags(0)
log.Fatal("--encrypt-state can only be used with --state set to a local file path")
}
}
if args.disableLogs {
envknob.SetNoLogsNoSupport()
}
@ -315,13 +340,17 @@ func trySynologyMigration(p string) error {
}
func statePathOrDefault() string {
var path string
if args.statepath != "" {
return args.statepath
path = args.statepath
}
if args.statedir != "" {
return filepath.Join(args.statedir, "tailscaled.state")
if path == "" && args.statedir != "" {
path = filepath.Join(args.statedir, "tailscaled.state")
}
return ""
if path != "" && !store.HasKnownProviderPrefix(path) && args.encryptState {
path = store.TPMPrefix + path
}
return path
}
// serverOptions is the configuration of the Tailscale node agent.
@ -974,3 +1003,15 @@ func applyIntegrationTestEnvKnob() {
}
}
}
func defaultEncryptState() bool {
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
// TPM encryption is only configurable on Windows and Linux. Other
// platforms either use system APIs and are not configurable
// (Android/Apple), or don't support any form of encryption yet
// (plan9/FreeBSD/etc).
return false
}
v, _ := syspolicy.GetBoolean(syspolicy.EncryptState, false)
return v
}

View File

@ -10,4 +10,7 @@ export const sessionStateStorage: IPNStateStorage = {
getState(id) {
return window.sessionStorage[`ipn-state-${id}`] || ""
},
all() {
return JSON.stringify(window.sessionStorage)
},
}

View File

@ -44,6 +44,7 @@ declare global {
interface IPNStateStorage {
setState(id: string, value: string): void
getState(id: string): string
all(): string
}
type IPNConfig = {

View File

@ -15,6 +15,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"iter"
"log"
"math/rand/v2"
"net"
@ -579,6 +580,29 @@ func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
return nil
}
func (s *jsStateStore) All() iter.Seq2[ipn.StateKey, []byte] {
return func(yield func(ipn.StateKey, []byte) bool) {
jsValue := s.jsStateStorage.Call("all")
if jsValue.String() == "" {
return
}
buf, err := hex.DecodeString(jsValue.String())
if err != nil {
return
}
var state map[string][]byte
if err := json.Unmarshal(buf, &state); err != nil {
return
}
for k, v := range state {
if !yield(ipn.StateKey(k), v) {
break
}
}
}
}
func mapSlice[T any, M any](a []T, f func(T) M) []M {
n := make([]M, len(a))
for i, e := range a {

View File

@ -19,6 +19,7 @@
<string id="SINCE_V1_80">Tailscale version 1.80.0 and later</string>
<string id="SINCE_V1_82">Tailscale version 1.82.0 and later</string>
<string id="SINCE_V1_84">Tailscale version 1.84.0 and later</string>
<string id="SINCE_V1_86">Tailscale version 1.86.0 and later</string>
<string id="Tailscale_Category">Tailscale</string>
<string id="UI_Category">UI customization</string>
<string id="Settings_Category">Settings</string>
@ -67,7 +68,7 @@ If you disable or do not configure this policy setting, an interactive user logi
See https://tailscale.com/kb/1315/mdm-keys#set-an-auth-key for more details.]]></string>
<string id="ExitNodeID">Require using a specific Exit Node</string>
<string id="ExitNodeID_Help"><![CDATA[This policy can be used to require always using the specified Exit Node whenever the Tailscale client is connected.
If you enable this policy, set it to the ID of an exit node. The ID is visible on the Machines page of the admin console, or can be queried using the Tailscale API. If the specified exit node is unavailable, this device will have no Internet access unless Tailscale is disconnected. Alternatively, you can set it to "auto:any" (without quotes), which allows the Tailscale client to automatically select the most suitable exit node.
If you disable this policy or supply an empty exit node ID, then usage of exit nodes will be disallowed.
@ -278,6 +279,14 @@ See https://tailscale.com/kb/1315/mdm-keys#set-your-organization-name for more d
If you enable or don't configure this policy, the onboarding flow will be shown to new users who have not yet signed in to a Tailscale account.
If you disable this policy, the onboarding flow will never be shown.]]></string>
<string id="EncryptState">Encrypt client state file stored on disk</string>
<string id="EncryptState_Help"><![CDATA[This policy configures encryption of the Tailscale client state file on disk.
If you enable this policy, the state file will be encrypted using the local TPM device. If a local TPM device is not present or not accessible, Tailscale will fail to start.
If you disable this policy, the state file is stored in plaintext.
If the policy is unconfigured, state encryption will be enabled on newer client versions when the device has a properly-configured TPM.]]></string>
</stringTable>
<presentationTable>
<presentation id="LoginURL">

View File

@ -66,6 +66,10 @@
displayName="$(string.SINCE_V1_84)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
<definition name="SINCE_V1_86"
displayName="$(string.SINCE_V1_86)">
<and><reference ref="TAILSCALE_PRODUCT"/></and>
</definition>
</definitions>
</supportedOn>
<categories>
@ -365,5 +369,15 @@
<text id="KeyExpirationNoticePrompt" valueName="KeyExpirationNotice" required="true" />
</elements>
</policy>
<policy name="EncryptState" class="Machine" displayName="$(string.EncryptState)" explainText="$(string.EncryptState_Help)" key="Software\Policies\Tailscale" valueName="EncryptState">
<parentCategory ref="Top_Category" />
<supportedOn ref="SINCE_V1_86" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
</policies>
</policyDefinitions>

View File

@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"iter"
"log"
"os"
"path/filepath"
@ -37,7 +38,7 @@ func init() {
hostinfo.RegisterHostinfoNewHook(func(hi *tailcfg.Hostinfo) {
hi.TPM = infoOnce()
})
store.Register(storePrefix, newStore)
store.Register(store.TPMPrefix, newStore)
}
func info() *tailcfg.TPMInfo {
@ -103,10 +104,8 @@ func propToString(v uint32) string {
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)
path = strings.TrimPrefix(path, store.TPMPrefix)
if err := paths.MkStateDir(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("creating state directory: %w", err)
}
@ -205,6 +204,19 @@ func (s *tpmStore) writeSealed() error {
return atomicfile.WriteFile(s.path, buf, 0600)
}
func (s *tpmStore) All() iter.Seq2[ipn.StateKey, []byte] {
return func(yield func(ipn.StateKey, []byte) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.cache {
if !yield(k, v) {
break
}
}
}
}
// The nested levels of encoding and encryption are confusing, so here's what's
// going on in plain English.
//

View File

@ -6,13 +6,22 @@ package tpm
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/types/logger"
)
func TestPropToString(t *testing.T) {
@ -29,11 +38,9 @@ func TestPropToString(t *testing.T) {
}
func skipWithoutTPM(t testing.TB) {
tpm, err := open()
if err != nil {
if !tpmSupported() {
t.Skip("TPM not available")
}
tpm.Close()
}
func TestSealUnseal(t *testing.T) {
@ -67,7 +74,7 @@ func TestSealUnseal(t *testing.T) {
func TestStore(t *testing.T) {
skipWithoutTPM(t)
path := storePrefix + filepath.Join(t.TempDir(), "state")
path := store.TPMPrefix + filepath.Join(t.TempDir(), "state")
store, err := newStore(t.Logf, path)
if err != nil {
t.Fatal(err)
@ -180,3 +187,153 @@ func BenchmarkStore(b *testing.B) {
})
}
}
func TestMigrateStateToTPM(t *testing.T) {
if !tpmSupported() {
t.Logf("using mock tpmseal provider")
store.RegisterForTest(t, store.TPMPrefix, newMockTPMSeal)
}
storePath := filepath.Join(t.TempDir(), "store")
// Make sure migration doesn't cause a failure when no state file exists.
if _, err := store.New(t.Logf, store.TPMPrefix+storePath); err != nil {
t.Fatalf("store.New failed for new tpmseal store: %v", err)
}
os.Remove(storePath)
initial, err := store.New(t.Logf, storePath)
if err != nil {
t.Fatalf("store.New failed for new file store: %v", err)
}
// Populate initial state file.
content := map[ipn.StateKey][]byte{
"foo": []byte("bar"),
"baz": []byte("qux"),
}
for k, v := range content {
if err := initial.WriteState(k, v); err != nil {
t.Fatal(err)
}
}
// Expected file keys for plaintext and sealed versions of state.
keysPlaintext := []string{"foo", "baz"}
keysTPMSeal := []string{"key", "nonce", "data"}
for _, tt := range []struct {
desc string
path string
wantKeys []string
}{
{
desc: "plaintext-to-plaintext",
path: storePath,
wantKeys: keysPlaintext,
},
{
desc: "plaintext-to-tpmseal",
path: store.TPMPrefix + storePath,
wantKeys: keysTPMSeal,
},
{
desc: "tpmseal-to-tpmseal",
path: store.TPMPrefix + storePath,
wantKeys: keysTPMSeal,
},
{
desc: "tpmseal-to-plaintext",
path: storePath,
wantKeys: keysPlaintext,
},
} {
t.Run(tt.desc, func(t *testing.T) {
s, err := store.New(t.Logf, tt.path)
if err != nil {
t.Fatalf("migration failed: %v", err)
}
gotContent := maps.Collect(s.All())
if diff := cmp.Diff(content, gotContent); diff != "" {
t.Errorf("unexpected content after migration, diff:\n%s", diff)
}
buf, err := os.ReadFile(storePath)
if err != nil {
t.Fatal(err)
}
var data map[string]any
if err := json.Unmarshal(buf, &data); err != nil {
t.Fatal(err)
}
gotKeys := slices.Collect(maps.Keys(data))
slices.Sort(gotKeys)
slices.Sort(tt.wantKeys)
if diff := cmp.Diff(gotKeys, tt.wantKeys); diff != "" {
t.Errorf("unexpected content keys after migration, diff:\n%s", diff)
}
})
}
}
func tpmSupported() bool {
tpm, err := open()
if err != nil {
return false
}
tpm.Close()
return true
}
type mockTPMSealProvider struct {
path string
mem.Store
}
func newMockTPMSeal(logf logger.Logf, path string) (ipn.StateStore, error) {
path, ok := strings.CutPrefix(path, store.TPMPrefix)
if !ok {
return nil, fmt.Errorf("%q missing tpmseal: prefix", path)
}
s := &mockTPMSealProvider{path: path, Store: mem.Store{}}
buf, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, s.flushState()
}
if err != nil {
return nil, err
}
var data struct {
Key string
Nonce string
Data map[ipn.StateKey][]byte
}
if err := json.Unmarshal(buf, &data); err != nil {
return nil, err
}
if data.Key == "" || data.Nonce == "" {
return nil, fmt.Errorf("%q missing key or nonce", path)
}
for k, v := range data.Data {
s.Store.WriteState(k, v)
}
return s, nil
}
func (p *mockTPMSealProvider) WriteState(k ipn.StateKey, v []byte) error {
if err := p.Store.WriteState(k, v); err != nil {
return err
}
return p.flushState()
}
func (p *mockTPMSealProvider) flushState() error {
data := map[string]any{
"key": "foo",
"nonce": "bar",
"data": maps.Collect(p.Store.All()),
}
buf, err := json.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(p.path, buf, 0600)
}

View File

@ -1013,17 +1013,13 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
}
type testStateStorage struct {
mem mem.Store
mem.Store
written atomic.Bool
}
func (s *testStateStorage) ReadState(id ipn.StateKey) ([]byte, error) {
return s.mem.ReadState(id)
}
func (s *testStateStorage) WriteState(id ipn.StateKey, bs []byte) error {
s.written.Store(true)
return s.mem.WriteState(id, bs)
return s.Store.WriteState(id, bs)
}
// awaitWrite clears the "I've seen writes" bit, in prep for a future

View File

@ -8,6 +8,7 @@ import (
"context"
"errors"
"fmt"
"iter"
"net"
"strconv"
)
@ -83,6 +84,11 @@ type StateStore interface {
// instead, which only writes if the value is different from what's
// already in the store.
WriteState(id StateKey, bs []byte) error
// All returns an iterator over all StateStore keys. Using ReadState or
// WriteState is not safe while iterating and can lead to a deadlock.
// The order of keys in the iterator is not specified and may change
// between runs.
All() iter.Seq2[StateKey, []byte]
}
// WriteState is a wrapper around store.WriteState that only writes if

View File

@ -10,6 +10,7 @@ import (
"context"
"errors"
"fmt"
"iter"
"net/url"
"regexp"
"strings"
@ -253,3 +254,7 @@ func (s *awsStore) persistState() error {
_, err = s.ssmClient.PutParameter(context.TODO(), in)
return err
}
func (s *awsStore) All() iter.Seq2[ipn.StateKey, []byte] {
return s.memory.All()
}

View File

@ -7,6 +7,7 @@ package kubestore
import (
"context"
"fmt"
"iter"
"log"
"net"
"os"
@ -428,3 +429,7 @@ func sanitizeKey[T ~string](k T) string {
return '_'
}, string(k))
}
func (s *Store) All() iter.Seq2[ipn.StateKey, []byte] {
return s.memory.All()
}

View File

@ -7,6 +7,7 @@ package mem
import (
"bytes"
"encoding/json"
"iter"
"sync"
xmaps "golang.org/x/exp/maps"
@ -85,3 +86,16 @@ func (s *Store) ExportToJSON() ([]byte, error) {
}
return json.MarshalIndent(s.cache, "", " ")
}
func (s *Store) All() iter.Seq2[ipn.StateKey, []byte] {
return func(yield func(ipn.StateKey, []byte) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.cache {
if !yield(k, v) {
break
}
}
}
}

View File

@ -7,10 +7,14 @@ package store
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
@ -20,6 +24,7 @@ import (
"tailscale.com/paths"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/testenv"
)
// Provider returns a StateStore for the provided path.
@ -32,6 +37,9 @@ func init() {
var knownStores map[string]Provider
// TPMPrefix is the path prefix used for TPM-encrypted StateStore.
const TPMPrefix = "tpmseal:"
// New returns a StateStore based on the provided arg
// and registered stores.
// The arg is of the form "prefix:rest", where prefix was previously
@ -53,12 +61,23 @@ func New(logf logger.Logf, path string) (ipn.StateStore, error) {
if strings.HasPrefix(path, prefix) {
// We can't strip the prefix here as some NewStoreFunc (like arn:)
// expect the prefix.
if prefix == TPMPrefix {
if runtime.GOOS == "windows" {
path = TPMPrefix + TryWindowsAppDataMigration(logf, strings.TrimPrefix(path, TPMPrefix))
}
if err := maybeMigrateLocalStateFile(logf, path); err != nil {
return nil, fmt.Errorf("failed to migrate existing state file to TPM-sealed format: %w", err)
}
}
return sf(logf, path)
}
}
if runtime.GOOS == "windows" {
path = TryWindowsAppDataMigration(logf, path)
}
if err := maybeMigrateLocalStateFile(logf, path); err != nil {
return nil, fmt.Errorf("failed to migrate existing TPM-sealed state file to plaintext format: %w", err)
}
return NewFileStore(logf, path)
}
@ -77,6 +96,29 @@ func Register(prefix string, fn Provider) {
mak.Set(&knownStores, prefix, fn)
}
// RegisterForTest registers a prefix to be used for NewStore in tests. An
// existing registered prefix will be replaced.
func RegisterForTest(t testenv.TB, prefix string, fn Provider) {
if len(prefix) == 0 {
panic("prefix is empty")
}
old := maps.Clone(knownStores)
t.Cleanup(func() { knownStores = old })
mak.Set(&knownStores, prefix, fn)
}
// HasKnownProviderPrefix reports whether path uses one of the registered
// Provider prefixes.
func HasKnownProviderPrefix(path string) bool {
for prefix := range knownStores {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// TryWindowsAppDataMigration attempts to copy the Windows state file
// from its old location to the new location. (Issue 2856)
//
@ -179,3 +221,101 @@ func (s *FileStore) WriteState(id ipn.StateKey, bs []byte) error {
}
return atomicfile.WriteFile(s.path, bs, 0600)
}
func (s *FileStore) All() iter.Seq2[ipn.StateKey, []byte] {
return func(yield func(ipn.StateKey, []byte) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.cache {
if !yield(k, v) {
break
}
}
}
}
func maybeMigrateLocalStateFile(logf logger.Logf, path string) error {
path, toTPM := strings.CutPrefix(path, TPMPrefix)
// Extract JSON keys from the file on disk and guess what kind it is.
bs, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var content map[string]any
if err := json.Unmarshal(bs, &content); err != nil {
return fmt.Errorf("failed to unmarshal %q: %w", path, err)
}
keys := slices.Sorted(maps.Keys(content))
tpmKeys := []string{"key", "nonce", "data"}
slices.Sort(tpmKeys)
// TPM-sealed files will have exactly these keys.
existingFileSealed := slices.Equal(keys, tpmKeys)
// Plaintext files for nodes that registered at least once will have this
// key, plus other dynamic ones.
_, existingFilePlaintext := content["_machinekey"]
isTPM := existingFileSealed && !existingFilePlaintext
if isTPM == toTPM {
// No migration needed.
return nil
}
newTPMStore, ok := knownStores[TPMPrefix]
if !ok {
return errors.New("this build does not support TPM integration")
}
// Open from (old format) and to (new format) stores for migration. The
// "to" store will be at tmpPath.
var from, to ipn.StateStore
tmpPath := path + ".tmp"
if toTPM {
// Migrate plaintext file to be TPM-sealed.
from, err = NewFileStore(logf, path)
if err != nil {
return fmt.Errorf("NewFileStore(%q): %w", path, err)
}
to, err = newTPMStore(logf, TPMPrefix+tmpPath)
if err != nil {
return fmt.Errorf("newTPMStore(%q): %w", tmpPath, err)
}
} else {
// Migrate TPM-selaed file to plaintext.
from, err = newTPMStore(logf, TPMPrefix+path)
if err != nil {
return fmt.Errorf("newTPMStore(%q): %w", path, err)
}
to, err = NewFileStore(logf, tmpPath)
if err != nil {
return fmt.Errorf("NewFileStore(%q): %w", tmpPath, err)
}
}
defer os.Remove(tmpPath)
// Copy all the items. This is pretty inefficient, because both stores
// write the file to disk for each WriteState, but that's ok for a one-time
// migration.
for k, v := range from.All() {
if err := to.WriteState(k, v); err != nil {
return err
}
}
// Finally, overwrite the state file with the new one we created at
// tmpPath.
if err := atomicfile.Rename(tmpPath, path); err != nil {
return err
}
if toTPM {
logf("migrated %q from plaintext to TPM-sealed format", path)
} else {
logf("migrated %q from TPM-sealed to plaintext format", path)
}
return nil
}

View File

@ -5,6 +5,7 @@ package ipn
import (
"bytes"
"iter"
"sync"
"testing"
@ -31,6 +32,19 @@ func (s *memStore) WriteState(k StateKey, v []byte) error {
return nil
}
func (s *memStore) All() iter.Seq2[StateKey, []byte] {
return func(yield func(StateKey, []byte) bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.m {
if !yield(k, v) {
break
}
}
}
}
func TestWriteState(t *testing.T) {
var ss StateStore = new(memStore)
WriteState(ss, "foo", []byte("bar"))

View File

@ -908,6 +908,9 @@ type TPMInfo struct {
SpecRevision int `json:",omitempty"`
}
// Present reports whether a TPM device is present on this machine.
func (t *TPMInfo) Present() bool { return t != nil }
// ServiceName is the name of a service, of the form `svc:dns-label`. Services
// represent some kind of application provided for users of the tailnet with a
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),

View File

@ -569,11 +569,12 @@ type TestNode struct {
env *TestEnv
tailscaledParser *nodeOutputParser
dir string // temp dir for sock & state
configFile string // or empty for none
sockFile string
stateFile string
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
dir string // temp dir for sock & state
configFile string // or empty for none
sockFile string
stateFile string
upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI
encryptState bool
mu sync.Mutex
onLogLine []func([]byte)
@ -640,7 +641,7 @@ func (n *TestNode) diskPrefs() *ipn.Prefs {
if _, err := os.ReadFile(n.stateFile); err != nil {
t.Fatalf("reading prefs: %v", err)
}
fs, err := store.NewFileStore(nil, n.stateFile)
fs, err := store.New(nil, n.stateFile)
if err != nil {
t.Fatalf("reading prefs, NewFileStore: %v", err)
}
@ -822,6 +823,9 @@ func (n *TestNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
if n.configFile != "" {
cmd.Args = append(cmd.Args, "--config="+n.configFile)
}
if n.encryptState {
cmd.Args = append(cmd.Args, "--encrypt-state")
}
cmd.Env = append(os.Environ(),
"TS_DEBUG_PERMIT_HTTP_C2N=1",
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,

View File

@ -21,6 +21,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"sync/atomic"
"testing"
@ -32,6 +33,7 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/clientupdate"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tstun"
@ -1470,3 +1472,60 @@ func TestNetstackUDPLoopback(t *testing.T) {
d1.MustCleanShutdown(t)
}
func TestEncryptStateMigration(t *testing.T) {
if !hostinfo.New().TPM.Present() {
t.Skip("TPM not available")
}
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
t.Skip("--encrypt-state for tailscaled state not supported on this platform")
}
tstest.Shard(t)
tstest.Parallel(t)
env := NewTestEnv(t)
n := NewTestNode(t, env)
runNode := func(t *testing.T, wantStateKeys []string) {
t.Helper()
// Run the node.
d := n.StartDaemon()
n.AwaitResponding()
n.MustUp()
n.AwaitRunning()
// Check the contents of the state file.
buf, err := os.ReadFile(n.stateFile)
if err != nil {
t.Fatalf("reading %q: %v", n.stateFile, err)
}
t.Logf("state file content:\n%s", buf)
var content map[string]any
if err := json.Unmarshal(buf, &content); err != nil {
t.Fatalf("parsing %q: %v", n.stateFile, err)
}
for _, k := range wantStateKeys {
if _, ok := content[k]; !ok {
t.Errorf("state file is missing key %q", k)
}
}
// Stop the node.
d.MustCleanShutdown(t)
}
wantPlaintextStateKeys := []string{"_machinekey", "_current-profile", "_profiles"}
wantEncryptedStateKeys := []string{"key", "nonce", "data"}
t.Run("regular-state", func(t *testing.T) {
n.encryptState = false
runNode(t, wantPlaintextStateKeys)
})
t.Run("migrate-to-encrypted", func(t *testing.T) {
n.encryptState = true
runNode(t, wantEncryptedStateKeys)
})
t.Run("migrate-to-plaintext", func(t *testing.T) {
n.encryptState = false
runNode(t, wantPlaintextStateKeys)
})
}

View File

@ -51,6 +51,7 @@ import (
_ "tailscale.com/util/eventbus"
_ "tailscale.com/util/multierr"
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/syspolicy"
_ "tailscale.com/version"
_ "tailscale.com/version/distro"
_ "tailscale.com/wgengine"

View File

@ -51,6 +51,7 @@ import (
_ "tailscale.com/util/eventbus"
_ "tailscale.com/util/multierr"
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/syspolicy"
_ "tailscale.com/version"
_ "tailscale.com/version/distro"
_ "tailscale.com/wgengine"

View File

@ -51,6 +51,7 @@ import (
_ "tailscale.com/util/eventbus"
_ "tailscale.com/util/multierr"
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/syspolicy"
_ "tailscale.com/version"
_ "tailscale.com/version/distro"
_ "tailscale.com/wgengine"

View File

@ -51,6 +51,7 @@ import (
_ "tailscale.com/util/eventbus"
_ "tailscale.com/util/multierr"
_ "tailscale.com/util/osshare"
_ "tailscale.com/util/syspolicy"
_ "tailscale.com/version"
_ "tailscale.com/version/distro"
_ "tailscale.com/wgengine"

View File

@ -120,6 +120,10 @@ const (
LogSCMInteractions Key = "LogSCMInteractions"
FlushDNSOnSessionUnlock Key = "FlushDNSOnSessionUnlock"
// EncryptState is a boolean setting that specifies whether to encrypt the
// tailscaled state file with a TPM device.
EncryptState Key = "EncryptState"
// PostureChecking indicates if posture checking is enabled and the client shall gather
// posture data.
// Key is a string value that specifies an option: "always", "never", "user-decides".
@ -186,6 +190,7 @@ var implicitDefinitions = []*setting.Definition{
setting.NewDefinition(ExitNodeID, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(EncryptState, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(Hostname, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),