From 8c395d4f3ce0f28baca89357a7d2dbe447a411f6 Mon Sep 17 00:00:00 2001 From: Andrew Dunham Date: Sat, 18 Jul 2026 22:23:43 -0400 Subject: [PATCH] tpmkey: add support for keys sealed to a local TPM Add a new tpmkey package that seals the key-encryption key to a TPM 2.0 device, as an alternative to a cloud KMS. TPMs cannot perform AEAD operations directly, so instead a random 32-byte key is generated and sealed to the TPM, with the sealed blob stored in a file on disk. At startup the blob is unsealed through the TPM, and the key is used with a software AEAD. The sealed blob can only be used by the TPM that created it, so a copy of the database and key file together cannot be decrypted elsewhere. This also works with virtual TPMs such as those QEMU (and thus Proxmox) provide. --- cmd/setec/setec.go | 72 +++++++--- docs/README.md | 3 +- docs/server.md | 41 +++++- go.mod | 4 +- go.sum | 24 +++- tpmkey/open_linux.go | 15 +++ tpmkey/open_other.go | 17 +++ tpmkey/tpmkey.go | 300 ++++++++++++++++++++++++++++++++++++++++++ tpmkey/tpmkey_test.go | 90 +++++++++++++ 9 files changed, 538 insertions(+), 28 deletions(-) create mode 100644 tpmkey/open_linux.go create mode 100644 tpmkey/open_other.go create mode 100644 tpmkey/tpmkey.go create mode 100644 tpmkey/tpmkey_test.go diff --git a/cmd/setec/setec.go b/cmd/setec/setec.go index 4364c04..69cc184 100644 --- a/cmd/setec/setec.go +++ b/cmd/setec/setec.go @@ -31,6 +31,7 @@ import ( "github.com/tailscale/setec/client/setec" "github.com/tailscale/setec/internal/tinktestutil" "github.com/tailscale/setec/server" + "github.com/tailscale/setec/tpmkey" "github.com/tailscale/setec/types/api" "github.com/tink-crypto/tink-go-awskms/v2/integration/awskms" "github.com/tink-crypto/tink-go/v2/tink" @@ -65,7 +66,17 @@ the node on the tailnet. With the --dev flag, the server runs with a dummy KMS. This mode is intended for debugging and is NOT SAFE for production use. -Otherwise you must provide a --kms-key-name to use to encrypt the database. +Otherwise you must provide a key to encrypt the database, using exactly one of: + + --kms-key-name - to use a key stored in AWS KMS. + + --tpm-key-file - to use a key sealed to a TPM 2.0 device, stored in the named + file. If the file does not exist, a new key is generated, + sealed to the TPM, and saved there. The sealed key can only + be used by the TPM that created it. + + Use the --tpm-device flag to select a TPM device; the + default is /dev/tpmrm0 Most of the settings can be set via environment variables as well as flags. @@ -74,7 +85,9 @@ Most of the settings can be set via environment variables as well as flags. ------------------------------------------------------------------------- --state-dir SETEC_DIR path (required) --hostname SETEC_HOSTNAME string (required) - --kms-key-name SETEC_KMS_KEY_NAME string (required unless --dev) + --kms-key-name SETEC_KMS_KEY_NAME string (see above) + --tpm-key-file SETEC_TPM_KEY_FILE path (see above) + --tpm-device SETEC_TPM_DEVICE path (/dev/tpmrm0) --backup-bucket SETEC_BACKUP_BUCKET string (optional) --backup-bucket-region SETEC_BACKUP_BUCKET_REGION string (optional) --backup-role SETEC_BACKUP_ROLE string (optional) @@ -164,6 +177,8 @@ var serverArgs struct { StateDir string `flag:"state-dir,default=$SETEC_STATE_DIR,Server state directory"` Hostname string `flag:"hostname,default=$SETEC_HOSTNAME,Tailscale hostname to use"` KMSKeyName string `flag:"kms-key-name,default=$SETEC_KMS_KEY_NAME,Name of KMS key to use for database encryption"` + TPMKeyFile string `flag:"tpm-key-file,default=$SETEC_TPM_KEY_FILE,Path of TPM-sealed key file to use for database encryption (created if missing)"` + TPMDevice string `flag:"tpm-device,default=$SETEC_TPM_DEVICE,Path of TPM device to use with --tpm-key-file"` BackupBucket string `flag:"backup-bucket,default=$SETEC_BACKUP_BUCKET,Name of AWS S3 bucket to use for database backups"` BackupBucketRegion string `flag:"backup-bucket-region,default=$SETEC_BACKUP_BUCKET_REGION,AWS region of the backup S3 bucket"` BackupRole string `flag:"backup-role,default=$SETEC_BACKUP_ROLE,Name of AWS IAM role to assume to write backups"` @@ -188,14 +203,15 @@ func runServer(env *command.Env) error { if serverArgs.Hostname == "" { serverArgs.Hostname = "setec-dev" } - if serverArgs.KMSKeyName == "" { + + log.Printf("dev mode: state dir is %q", serverArgs.StateDir) + log.Printf("dev mode: hostname is %q", serverArgs.Hostname) + if serverArgs.KMSKeyName == "" && serverArgs.TPMKeyFile == "" { + log.Println("dev mode: using dummy KMS, NOT SAFE FOR PRODUCTION USE") kek = &tinktestutil.DummyAEAD{ Name: "SetecDevOnlyDummyEncryption", } } - log.Printf("dev mode: state dir is %q", serverArgs.StateDir) - log.Printf("dev mode: hostname is %q", serverArgs.Hostname) - log.Println("dev mode: using dummy KMS, NOT SAFE FOR PRODUCTION USE") } if serverArgs.StateDir == "" { @@ -205,19 +221,37 @@ func runServer(env *command.Env) error { return errors.New("--hostname must be specified") } if kek == nil { - if serverArgs.KMSKeyName == "" { - return errors.New("--kms-key-name must be specified") - } - // Tink requires prefixing the key identifier with a URI - // scheme that identifies the correct backend to use. - uri := "aws-kms://" + serverArgs.KMSKeyName - kmsClient, err := awskms.NewClientWithOptions(uri) - if err != nil { - return fmt.Errorf("creating AWS KMS client: %v", err) - } - kek, err = kmsClient.GetAEAD(uri) - if err != nil { - return fmt.Errorf("getting KMS key handle: %v", err) + switch { + case serverArgs.KMSKeyName != "" && serverArgs.TPMKeyFile != "": + return errors.New("--kms-key-name and --tpm-key-file are mutually exclusive") + case serverArgs.KMSKeyName != "": + // Tink requires prefixing the key identifier with a URI + // scheme that identifies the correct backend to use. + uri := "aws-kms://" + serverArgs.KMSKeyName + kmsClient, err := awskms.NewClientWithOptions(uri) + if err != nil { + return fmt.Errorf("creating AWS KMS client: %v", err) + } + kek, err = kmsClient.GetAEAD(uri) + if err != nil { + return fmt.Errorf("getting KMS key handle: %v", err) + } + case serverArgs.TPMKeyFile != "": + devPath := serverArgs.TPMDevice + if devPath == "" { + devPath = tpmkey.DefaultDevice + } + device, err := tpmkey.OpenDevicePath(devPath) + if err != nil { + return fmt.Errorf("opening TPM device %q: %v", devPath, err) + } + + kek, err = tpmkey.OpenOrCreate(device, serverArgs.TPMKeyFile) + if err != nil { + return fmt.Errorf("opening TPM-sealed key file: %v", err) + } + default: + return errors.New("either --kms-key-name or --tpm-key-file must be specified") } } diff --git a/docs/README.md b/docs/README.md index 94ed79f..27f7c04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,8 @@ the server maintains secrets in encrypted storage, keeps an audit log of accesses, and manages periodic backups. The setec server integrates with existing key-management infrastructure to -bootstrap its own deployment (as of 24-Sep-2023, AWS KMS is supported). +bootstrap its own deployment (as of 24-Sep-2023, AWS KMS is supported; as of +19-Jul-2026, a key sealed to a TPM device can be used). Once the server is running on a tailnet, other programs can use it to access their production secrets with a basic WireGuard-encrypted HTTP request, rather than having to distribute secrets via files, environment variables, or manual diff --git a/docs/server.md b/docs/server.md index a857ba3..bde851b 100644 --- a/docs/server.md +++ b/docs/server.md @@ -39,15 +39,36 @@ The server stores secrets in an encrypted file in the state directory. When the server starts, it requires an **access key** to unlock the database. In production, the server fetches an access key from an AWS KMS secret, whose -ARN is specified via the `--kms-key-name` flag. As of 05-May-2024, AWS KMS is -the only supported production access key store; we may add others in the -future. +ARN is specified via the `--kms-key-name` flag. This mode also requires access to the AWS APIs: If you are running the server in AWS (e.g., an EC2 VM), you would typically grant access to the key via an IAM role on the VM. Alternatively, you can plumb in credentials via environment variables, for example using [`aws-vault`][awsvault] or similar. +For environments where no KMS is available (e.g., a homelab), the server can +instead use a key sealed to a local TPM 2.0 device, via the `--tpm-key-file` +flag. The flag names a file in which the server stores a TPM-sealed key blob; +if the file does not exist, the server generates a new key, seals it to the +TPM, and saves it there on first startup. The sealed blob can only be unsealed +by the TPM that created it, so a copy of the database and the key file together +cannot be decrypted elsewhere. By default the server uses the TPM at +`/dev/tpmrm0`; use `--tpm-device` to select a different device. + +This also works with virtual TPMs, such as the vTPM QEMU (and thus Proxmox) can +attach to a VM. Two caveats to be aware of when using a vTPM: + +- A vTPM's state is stored by the host (for Proxmox, in the VM's "TPM State" + disk), so an attacker who obtains that state along with the database can + still recover the key. Snapshots or backups of the whole VM including its + TPM state likewise contain everything needed to decrypt the database. +- The key is bound to that vTPM instance: if the VM is rebuilt without + preserving its TPM state, the database becomes unrecoverable. Keep a + separate backup of the database key if you cannot afford that risk. + +On systems without TPM support (such as Darwin), the server will report an +error at startup when `--tpm-key-file` is set. + For development and testing purposes, the server also supports a `--dev` flag, which runs using a "dummy" static access key. **This mode is not secure for production use**, but is useful for testing and debugging integrations locally. @@ -91,6 +112,20 @@ production use**, but is useful for testing and debugging integrations locally. --kms-key-name=arn:aws:kms:us-east-1:123456789012:key/b8074b63-13c0-4345-a9d8-e236267d2af1 ``` +3. To run a self-hosted setec server using a key sealed to the local TPM: + + ```shell + TS_AUTHKEY=tskey-auth-kf4k3k3y4testCNTRL-ZmFrZSBrZXkgZm9yIHRlc3Q setec server \ + --hostname=secrets \ + --state-dir=$HOME/setec-state \ + --tpm-key-file=$HOME/setec-state/setec-tpm.key + ``` + + The sealed key file is created automatically the first time the server + starts. The server must be able to read the TPM device (`/dev/tpmrm0` by + default): either run it as root, or add its user to the group owning the + device. + Once you have run the server, you can grant access to it via your [tailnet ACL][acl]. For example, if we assume your server's Tailscale address is 100.64.5.6, the following [ACL grants][grant] would give the administrators of diff --git a/go.mod b/go.mod index 462629c..b7ed00b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/creachadair/mds v0.25.15 github.com/creachadair/msync v0.8.1 github.com/google/go-cmp v0.7.0 + github.com/google/go-tpm v0.9.8 github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 github.com/tink-crypto/tink-go/v2 v2.6.0 golang.org/x/term v0.38.0 @@ -47,6 +48,7 @@ require ( github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect + github.com/google/go-tpm-tools v0.4.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -81,6 +83,6 @@ require ( golang.org/x/tools v0.39.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.11 // indirect gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect ) diff --git a/go.sum b/go.sum index 2c1ed84..037c048 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 h1:DoeEFwEGBdqcawmpiWtSsSVVZ+wk3zpqvcvssO2JLmY= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007/go.mod h1:s8F0JYEods/WL03WxZaGsWCnumZeeLD+WKHzspOV9u0= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= @@ -101,8 +103,20 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= -github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc h1:SG12DWUUM5igxm+//YX5Yq4vhdoRnOG9HkCodkOn+YU= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 h1:STyioPkz8nqMMIk3+YlyJ/WyEJZxho1YUZXu99uAbQ0= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= +github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo= +github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d h1:Ff8goEP/ue2/rZT5qyoRicuySCYDbAXEZS8Cf1fgsUo= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d/go.mod h1:uHy3VaNXNXhl0fiPxKqTxieeouqQmW6A0EfLcaeCYBk= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI= +github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4= +github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= +github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -203,6 +217,8 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= @@ -242,8 +258,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/tpmkey/open_linux.go b/tpmkey/open_linux.go new file mode 100644 index 0000000..0a11e33 --- /dev/null +++ b/tpmkey/open_linux.go @@ -0,0 +1,15 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux + +package tpmkey + +import ( + "github.com/google/go-tpm/tpm2/transport" + "github.com/google/go-tpm/tpm2/transport/linuxtpm" +) + +func openDevice(path string) (transport.TPMCloser, error) { + return linuxtpm.Open(path) +} diff --git a/tpmkey/open_other.go b/tpmkey/open_other.go new file mode 100644 index 0000000..34f6c9d --- /dev/null +++ b/tpmkey/open_other.go @@ -0,0 +1,17 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !linux + +package tpmkey + +import ( + "fmt" + "runtime" + + "github.com/google/go-tpm/tpm2/transport" +) + +func openDevice(path string) (transport.TPMCloser, error) { + return nil, fmt.Errorf("tpmkey: unsupported OS: %s", runtime.GOOS) +} diff --git a/tpmkey/tpmkey.go b/tpmkey/tpmkey.go new file mode 100644 index 0000000..bcfc615 --- /dev/null +++ b/tpmkey/tpmkey.go @@ -0,0 +1,300 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package tpmkey provides a tink AEAD sealed to a local TPM 2.0 device, as an +// alternative to a cloud KMS. +// +// TPMs cannot perform AEAD operations directly, so instead the TPM is used to +// protect the key material: a random 32-byte key is generated and sealed to +// the TPM, and the resulting sealed blob is stored in a file on disk. At +// startup the blob is unsealed through the TPM and the decrypted key is used +// to construct the AEAD. +// +// The sealed blob can only be unsealed by the TPM that created it, so unlike a +// cleartext key file, a copy of the blob and the database together is not +// sufficient to decrypt the secrets. +package tpmkey + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" + "github.com/tink-crypto/tink-go/v2/aead/subtle" + "github.com/tink-crypto/tink-go/v2/tink" + "tailscale.com/atomicfile" +) + +// A quick digression on how and why we "flush" something from the TPM: +// +// Per the TPM2 spec ("TCG PC Client Platform TPM Profile Specification for TPM +// 2.0", found at [0]), the TPM is required to support at minimum 3 loaded objects at a time +// (TPM_PT_HR_TRANSIENT_MIN and TPM_PT_HR_LOADED_MIN). If the TPM runs out of +// slots for loaded objects, it will return a TPM_RC_OBJECT_MEMORY or +// TPM_RC_SESSION_MEMORY error. +// +// This package defaults to using the /dev/tpmrm0 device, which is a "resource +// manager" that will automatically flush objects from the TPM when the process +// closes the device. However, that doesn't prevent us from running out of +// slots during the course of a single process. +// +// To determine whether or not we need to flush an object, we can refer to a +// the TPM2 specification for commands[1]. We break down each command below with +// a citation: +// +// 1. tpm2.Create (section 12.1.1): "The object will need to be loaded +// (TPM2_Load()) before it may be used" and "This command may require +// temporary use of a transient resource, even though the object does not +// remain loaded after the command" +// 2. tpm2.Load (section 12.2.1): "The returned handle is associated with +// the object until the object is flushed (TPM2_FlushContext()) ..." +// 3. tpm2.Unseal (section 12.7.1): "This command returns the data in a +// loaded Sealed Data Object", implying that it returns data about a +// previously-loaded object, but does not itself load an object. +// 4. tpm2.CreatePrimary (section 24.1.1): "The command will create and +// load a Primary Object." +// +// Thus, we should flush after calling Load or CreatePrimary, but not after +// calling Create or Unseal. +// +// [0]: https://trustedcomputinggroup.org/wp-content/uploads/PC-Client-Specific-Platform-TPM-Profile-for-TPM-2p0-v1p07_rc1_121225.pdf +// [1]: https://trustedcomputinggroup.org/wp-content/uploads/Trusted-Platform-Module-2.0-Library-Part-3-Commands_Version-185_pub.pdf + +// DefaultDevice is the TPM device path used when none is specified. +const DefaultDevice = "/dev/tpmrm0" + +// keySize is the size in bytes of the sealed AEAD key. +const keySize = 32 + +// sealedKeyVersion is the version of the on-disk sealed key file. +const sealedKeyVersion = 1 + +// sealedKey is the on-disk format of the sealed key file. +type sealedKey struct { + // Version is the version of this sealed key file. + Version int + // Public holds the TPM2B_PUBLIC area of the sealed object. + Public []byte + // Private holds the TPM2B_PRIVATE area of the sealed object. + Private []byte +} + +// OpenDevicePath opens the TPM device at the given path. +func OpenDevicePath(path string) (transport.TPM, error) { + return openDevice(path) +} + +// OpenOrCreate returns a [tink.AEAD], constructed by unsealing the secret +// sealed to given TPM, with the sealed blob stored in the file at path. +// +// If no file exists at path, a new key is generated, sealed to the TPM, and +// saved there with mode 0600. +func OpenOrCreate(tpm transport.TPM, path string) (tink.AEAD, error) { + bs, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return create(tpm, path) + } else if err != nil { + return nil, err + } + + var sk sealedKey + if err := json.Unmarshal(bs, &sk); err != nil { + return nil, fmt.Errorf("loading sealed key file %q: %w", path, err) + } + if sk.Version != sealedKeyVersion { + return nil, fmt.Errorf("unsupported sealed key file version %d", sk.Version) + } + pub, err := tpm2.Unmarshal[tpm2.TPM2BPublic](sk.Public) + if err != nil { + return nil, fmt.Errorf("parsing sealed key public area: %w", err) + } + priv, err := tpm2.Unmarshal[tpm2.TPM2BPrivate](sk.Private) + if err != nil { + return nil, fmt.Errorf("parsing sealed key private area: %w", err) + } + + srk, flushSRK, err := createSRK(tpm) + if err != nil { + return nil, err + } + defer flushSRK() + + loadCmd := tpm2.Load{ + ParentHandle: tpm2.AuthHandle{ + Handle: srk.handle, + Name: srk.name, + Auth: tpm2.PasswordAuth(nil), + }, + InPrivate: *priv, + InPublic: *pub, + } + loadRsp, err := loadCmd.Execute(tpm) + if err != nil { + return nil, fmt.Errorf("loading sealed key from %q into TPM: %w", path, err) + } + defer func() { + flushCmd := tpm2.FlushContext{FlushHandle: loadRsp.ObjectHandle} + flushCmd.Execute(tpm) + }() + + unsealCmd := tpm2.Unseal{ + ItemHandle: tpm2.AuthHandle{ + Handle: loadRsp.ObjectHandle, + Name: loadRsp.Name, + + // See the comment in [create] for more information on + // the Auth parameter here; note that we're using the + // [tpm2.EncryptOut] instead of [tpm2.EncryptIn] + // parameter here, to protect the unsealed data coming + // "out" of the TPM. + Auth: tpm2.HMAC( + tpm2.TPMAlgSHA256, 16, + tpm2.AESEncryption(128, tpm2.EncryptOut), + tpm2.Salted(srk.handle, srk.pub), + ), + }, + } + unsealRsp, err := unsealCmd.Execute(tpm) + if err != nil { + return nil, fmt.Errorf("unsealing key: %w", err) + } + return newAEAD(unsealRsp.OutData.Buffer) +} + +// create generates a new key, seals it to the TPM, saves the sealed blob to +// path with mode 0600, and returns a [tink.AEAD] keyed by it. +func create(tpm transport.TPM, path string) (tink.AEAD, error) { + // TODO: when runtime/secret is stabilized, we should wrap this + // function so that it erases the raw key material from memory + + secret := make([]byte, keySize) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("generating key: %w", err) + } + + srk, flushSRK, err := createSRK(tpm) + if err != nil { + return nil, err + } + defer flushSRK() + + createCmd := tpm2.Create{ + ParentHandle: tpm2.AuthHandle{ + Handle: srk.handle, + Name: srk.name, + + // Create an authenticated HMAC session between us and + // the TPM. + // + // We also use AESEncryption to encrypt the data we + // send "in" to the TPM here, so that the raw secret + // is not visible in-transit on the wire. This is cheap + // insurance against e.g. someone sniffing the bus + // between the CPU and TPM. + // + // Since the object has no password (so that the + // service can start unattended), inject a "salt" + // derived from the TPM's SRK public key; since only + // the TPM has the private half of the SRK, an + // interposer cannot decrypt the salt, and thus cannot + // derive the session keys that protect the data + // in-transit. + // + // Note that technically an active interposer can MITM + // here by replacing the SRK's public key with an + // attacker-controlled one. While that's *possible*, + // we've chosen to ignore that threat model for now. If + // necessary, we can verify the TPM's endorsement key. + Auth: tpm2.HMAC( + tpm2.TPMAlgSHA256, 16, + tpm2.AESEncryption(128, tpm2.EncryptIn), + tpm2.Salted(srk.handle, srk.pub), + ), + }, + InSensitive: tpm2.TPM2BSensitiveCreate{ + Sensitive: &tpm2.TPMSSensitiveCreate{ + Data: tpm2.NewTPMUSensitiveCreate(&tpm2.TPM2BSensitiveData{ + // This is what we're actually sealing + Buffer: secret, + }), + }, + }, + InPublic: tpm2.New2B(tpm2.TPMTPublic{ + Type: tpm2.TPMAlgKeyedHash, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, // Bind to this specific TPM + FixedParent: true, // Bind to this specific key in the TPM + UserWithAuth: true, // Require a user session (note: we don't set a password) + NoDA: true, // No dictionary attack protection required + }, + }), + } + createRsp, err := createCmd.Execute(tpm) + if err != nil { + return nil, fmt.Errorf("sealing key to TPM: %w", err) + } + + out, err := json.Marshal(sealedKey{ + Version: sealedKeyVersion, + Public: tpm2.Marshal(createRsp.OutPublic), + Private: tpm2.Marshal(createRsp.OutPrivate), + }) + if err != nil { + return nil, fmt.Errorf("serializing sealed key: %w", err) + } + if err := atomicfile.WriteFile(path, out, 0600); err != nil { + return nil, fmt.Errorf("writing sealed key: %w", err) + } + return newAEAD(secret) +} + +// srk describes a loaded storage root key. +type srk struct { + handle tpm2.TPMHandle + name tpm2.TPM2BName + pub tpm2.TPMTPublic +} + +// createSRK creates the standard TCG ECC-P256 storage root key in the TPM's +// owner hierarchy (a.k.a. storage hierarchy), and returns it along with a +// function that flushes it from the TPM when the caller is done with it. The +// SRK template is always the same, so every call yields the same key on a +// given TPM. +// +// For more information on the key types here, see the following: +// +// https://ericchiang.github.io/post/tpm-keys/ +func createSRK(tpm transport.TPM) (*srk, func(), error) { + createCmd := tpm2.CreatePrimary{ + PrimaryHandle: tpm2.TPMRHOwner, + InPublic: tpm2.New2B(tpm2.ECCSRKTemplate), + } + rsp, err := createCmd.Execute(tpm) + if err != nil { + return nil, nil, fmt.Errorf("creating storage root key: %w", err) + } + flush := func() { + flushCmd := tpm2.FlushContext{FlushHandle: rsp.ObjectHandle} + flushCmd.Execute(tpm) + } + pub, err := rsp.OutPublic.Contents() + if err != nil { + flush() + return nil, nil, fmt.Errorf("parsing storage root key public area: %w", err) + } + return &srk{handle: rsp.ObjectHandle, name: rsp.Name, pub: *pub}, flush, nil +} + +// newAEAD returns a Tink AEAD keyed by the given secret. +func newAEAD(secret []byte) (tink.AEAD, error) { + if len(secret) != keySize { + return nil, fmt.Errorf("unsealed key has size %d, want %d", len(secret), keySize) + } + return subtle.NewXChaCha20Poly1305(secret) +} diff --git a/tpmkey/tpmkey_test.go b/tpmkey/tpmkey_test.go new file mode 100644 index 0000000..e887715 --- /dev/null +++ b/tpmkey/tpmkey_test.go @@ -0,0 +1,90 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package tpmkey_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/google/go-tpm/tpm2/transport" + "github.com/google/go-tpm/tpm2/transport/simulator" + + "github.com/tailscale/setec/tpmkey" +) + +// newSimulatedTPM returns a connection to a simulated TPM. +// +// The simulator is a process-wide singleton, so a test that needs a second one +// must Close the first before calling this again. +func newSimulatedTPM(t *testing.T) transport.TPMCloser { + t.Helper() + tpm, err := simulator.OpenSimulator() + if err != nil { + t.Fatalf("opening TPM simulator: %v", err) + } + t.Cleanup(func() { tpm.Close() }) + return tpm +} + +func TestOpenOrCreate(t *testing.T) { + tpm := newSimulatedTPM(t) + + path := filepath.Join(t.TempDir(), "tpm-sealed.key") + k1, err := tpmkey.OpenOrCreate(tpm, path) + if err != nil { + t.Fatalf("OpenOrCreate (create): %v", err) + } + + plaintext := []byte("hello, world") + context := []byte("test context") + ciphertext, err := k1.Encrypt(plaintext, context) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Unsealing again from the same file on the same TPM must yield a + // key that can decrypt data encrypted with the original. + k2, err := tpmkey.OpenOrCreate(tpm, path) + if err != nil { + t.Fatalf("OpenOrCreate (reopen): %v", err) + } + got, err := k2.Decrypt(ciphertext, context) + if err != nil { + t.Fatalf("Decrypt with unsealed key: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("Decrypt = %q, want %q", got, plaintext) + } +} + +func TestOpenWrongTPM(t *testing.T) { + path := filepath.Join(t.TempDir(), "tpm-sealed.key") + + // Seal a key on one TPM... + tpm1 := newSimulatedTPM(t) + if _, err := tpmkey.OpenOrCreate(tpm1, path); err != nil { + t.Fatalf("OpenOrCreate (create): %v", err) + } + tpm1.Close() + + // ...then attempt to unseal it on a different TPM. + tpm2 := newSimulatedTPM(t) + if _, err := tpmkey.OpenOrCreate(tpm2, path); err == nil { + t.Error("OpenOrCreate on a different TPM unexpectedly succeeded") + } +} + +func TestOpenCorruptFile(t *testing.T) { + tpm := newSimulatedTPM(t) + + path := filepath.Join(t.TempDir(), "tpm-sealed.key") + if err := os.WriteFile(path, []byte("not a sealed key"), 0600); err != nil { + t.Fatalf("writing corrupt file: %v", err) + } + if _, err := tpmkey.OpenOrCreate(tpm, path); err == nil { + t.Error("OpenOrCreate with corrupt file unexpectedly succeeded") + } +}