Browse Source

clientupdate/distsign: add new library for package signing/verification (#8943)

This library is intended for use during release to sign packages which
are then served from pkgs.tailscale.com.
The library is also then used by clients downloading packages for
`tailscale update` where OS package managers / app stores aren't used.

Updates https://github.com/tailscale/tailscale/issues/8760
Updates https://github.com/tailscale/tailscale/issues/6995

Signed-off-by: Andrew Lytvynov <[email protected]>
Andrew Lytvynov 2 years ago
parent
commit
7364c6beec

+ 338 - 0
clientupdate/distsign/distsign.go

@@ -0,0 +1,338 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package distsign implements signature and validation of arbitrary
+// distributable files.
+//
+// There are 3 parties in this exchange:
+//   - builder, which creates files, signs them with signing keys and publishes
+//     to server
+//   - server, which distributes public signing keys, files and signatures
+//   - client, which downloads files and signatures from server, and validates
+//     the signatures
+//
+// There are 2 types of keys:
+//   - signing keys, that sign individual distributable files on the builder
+//   - root keys, that sign signing keys and are kept offline
+//
+// root keys -(sign)-> signing keys -(sign)-> files
+//
+// All keys are asymmetric Ed25519 key pairs.
+//
+// The server serves static files under some known prefix. The kinds of files are:
+//   - distsign.pub - bundle of PEM-encoded public signing keys
+//   - distsign.pub.sig - signature of distsign.pub using one of the root keys
+//   - $file - any distributable file
+//   - $file.sig - signature of $file using any of the signing keys
+//
+// The root public keys are baked into the client software at compile time.
+// These keys are long-lived and prove the validity of current signing keys
+// from distsign.pub. To rotate root keys, a new client release must be
+// published, they are not rotated dynamically. There are multiple root keys in
+// different locations specifically to allow this rotation without using the
+// discarded root key for any new signatures.
+//
+// The signing public keys are fetched by the client dynamically before every
+// download and can be rotated more readily, assuming that most deployed
+// clients trust the root keys used to issue fresh signing keys.
+package distsign
+
+import (
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/binary"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"hash"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+
+	"github.com/hdevalence/ed25519consensus"
+	"golang.org/x/crypto/blake2s"
+)
+
+const (
+	pemTypePrivate = "PRIVATE KEY"
+	pemTypePublic  = "PUBLIC KEY"
+
+	downloadSizeLimit    = 1 << 29 // 512MB
+	signingKeysSizeLimit = 1 << 20 // 1MB
+	signatureSizeLimit   = ed25519.SignatureSize
+)
+
+// GenerateKey generates a new key pair and encodes it as PEM.
+func GenerateKey() (priv, pub []byte, err error) {
+	pub, priv, err = ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, nil, err
+	}
+	return pem.EncodeToMemory(&pem.Block{
+			Type:  pemTypePrivate,
+			Bytes: []byte(priv),
+		}), pem.EncodeToMemory(&pem.Block{
+			Type:  pemTypePublic,
+			Bytes: []byte(pub),
+		}), nil
+}
+
+// RootKey is a root key Signer used to sign signing keys.
+type RootKey Signer
+
+// SignSigningKeys signs the bundle of public signing keys. The bundle must be
+// a sequence of PEM blocks joined with newlines.
+func (s *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
+	return s.Sign(nil, pubBundle, crypto.Hash(0))
+}
+
+// SigningKey is a signing key Signer used to sign packages.
+type SigningKey Signer
+
+// SignPackageHash signs the hash and the length of a package. Use PackageHash
+// to compute the inputs.
+func (s SigningKey) SignPackageHash(hash []byte, len int64) ([]byte, error) {
+	if len <= 0 {
+		return nil, fmt.Errorf("package length must be positive, got %d", len)
+	}
+	msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
+	return s.Sign(nil, msg, crypto.Hash(0))
+}
+
+// PackageHash is a hash.Hash that counts the number of bytes written. Use it
+// to get the hash and length inputs to SigningKey.SignPackageHash.
+type PackageHash struct {
+	hash.Hash
+	len int64
+}
+
+// NewPackageHash returns an initialized PackageHash using BLAKE2s.
+func NewPackageHash() *PackageHash {
+	h, err := blake2s.New256(nil)
+	if err != nil {
+		// Should never happen with a nil key passed to blake2s.
+		panic(err)
+	}
+	return &PackageHash{Hash: h}
+}
+
+func (ph *PackageHash) Write(b []byte) (int, error) {
+	ph.len += int64(len(b))
+	return ph.Hash.Write(b)
+}
+
+// Reset the PackageHash to its initial state.
+func (ph *PackageHash) Reset() {
+	ph.len = 0
+	ph.Hash.Reset()
+}
+
+// Len returns the total number of bytes written.
+func (ph *PackageHash) Len() int64 { return ph.len }
+
+// Signer is crypto.Signer using a single key (root or signing).
+type Signer struct {
+	crypto.Signer
+}
+
+// NewSigner parses the PEM-encoded private key stored in the file named
+// privKeyPath and creates a Signer for it. The key is expected to be in the
+// same format as returned by GenerateKey.
+func NewSigner(privKeyPath string) (Signer, error) {
+	raw, err := os.ReadFile(privKeyPath)
+	if err != nil {
+		return Signer{}, err
+	}
+	k, err := parsePrivateKey(raw)
+	if err != nil {
+		return Signer{}, fmt.Errorf("failed to parse %q: %w", privKeyPath, err)
+	}
+	return Signer{Signer: k}, nil
+}
+
+// Client downloads and validates files from a distribution server.
+type Client struct {
+	roots    []ed25519.PublicKey
+	pkgsAddr *url.URL
+}
+
+// NewClient returns a new client for distribution server located at pkgsAddr,
+// and uses embedded root keys from the roots/ subdirectory of this package.
+func NewClient(pkgsAddr string) (*Client, error) {
+	u, err := url.Parse(pkgsAddr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
+	}
+	return &Client{roots: roots(), pkgsAddr: u}, nil
+}
+
+func (c *Client) url(path string) string {
+	return c.pkgsAddr.JoinPath(path).String()
+}
+
+// Download fetches a file at path srcPath from pkgsAddr passed in NewClient.
+// The file is downloaded to dstPath and its signature is validated using the
+// embedded root keys. Download returns an error if anything goes wrong with
+// the actual file download or with signature validation.
+func (c *Client) Download(srcPath, dstPath string) error {
+	// Always fetch a fresh signing key.
+	sigPub, err := c.signingKeys()
+	if err != nil {
+		return err
+	}
+
+	srcURL := c.url(srcPath)
+	sigURL := srcURL + ".sig"
+
+	dstPathUnverified := dstPath + ".unverified"
+	hash, len, err := download(srcURL, dstPathUnverified, downloadSizeLimit)
+	if err != nil {
+		return err
+	}
+	sig, err := fetch(sigURL, signatureSizeLimit)
+	if err != nil {
+		// Best-effort clean up of downloaded package.
+		os.Remove(dstPathUnverified)
+		return err
+	}
+	msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
+	if !verifyAny(sigPub, msg, sig) {
+		// Best-effort clean up of downloaded package.
+		os.Remove(dstPathUnverified)
+		return fmt.Errorf("signature %q for key %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, srcURL)
+	}
+
+	if err := os.Rename(dstPathUnverified, dstPath); err != nil {
+		return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
+	}
+
+	return nil
+}
+
+// signingKeys fetches current signing keys from the server and validates them
+// against the roots. Should be called before validation of any downloaded file
+// to get the fresh keys.
+func (c *Client) signingKeys() ([]ed25519.PublicKey, error) {
+	keyURL := c.url("distsign.pub")
+	sigURL := keyURL + ".sig"
+	raw, err := fetch(keyURL, signingKeysSizeLimit)
+	if err != nil {
+		return nil, err
+	}
+	sig, err := fetch(sigURL, signatureSizeLimit)
+	if err != nil {
+		return nil, err
+	}
+	if !verifyAny(c.roots, raw, sig) {
+		return nil, fmt.Errorf("signature %q for key %q does not validate with any known root key; either you are under attack, or running a very old version of Tailscale with outdated root keys", sigURL, keyURL)
+	}
+
+	// Parse the bundle of public signing keys.
+	var keys []ed25519.PublicKey
+	for len(raw) > 0 {
+		pub, rest, err := parsePublicKey(raw)
+		if err != nil {
+			return nil, err
+		}
+		keys = append(keys, pub)
+		raw = rest
+	}
+	if len(keys) == 0 {
+		return nil, fmt.Errorf("no signing keys found at %q", keyURL)
+	}
+	return keys, nil
+}
+
+// fetch reads the response body from url into memory, up to limit bytes.
+func fetch(url string, limit int64) ([]byte, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	return io.ReadAll(io.LimitReader(resp.Body, limit))
+}
+
+// download writes the response body of url into a local file at dst, up to
+// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
+func download(url, dst string, limit int64) ([]byte, int64, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer resp.Body.Close()
+
+	h := NewPackageHash()
+	r := io.TeeReader(io.LimitReader(resp.Body, limit), h)
+
+	f, err := os.Create(dst)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer f.Close()
+
+	if _, err := io.Copy(f, r); err != nil {
+		return nil, 0, err
+	}
+	if err := f.Close(); err != nil {
+		return nil, 0, err
+	}
+
+	return h.Sum(nil), h.Len(), nil
+}
+
+func parsePrivateKey(data []byte) (ed25519.PrivateKey, error) {
+	b, rest := pem.Decode(data)
+	if b == nil {
+		return nil, errors.New("failed to decode PEM data")
+	}
+	if len(rest) > 0 {
+		return nil, errors.New("trailing PEM data")
+	}
+	if b.Type != pemTypePrivate {
+		return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, pemTypePrivate)
+	}
+	if len(b.Bytes) != ed25519.PrivateKeySize {
+		return nil, errors.New("private key has incorrect length for an Ed25519 private key")
+	}
+	return ed25519.PrivateKey(b.Bytes), nil
+}
+
+func parseSinglePublicKey(data []byte) (ed25519.PublicKey, error) {
+	pub, rest, err := parsePublicKey(data)
+	if err != nil {
+		return nil, err
+	}
+	if len(rest) > 0 {
+		return nil, errors.New("trailing PEM data")
+	}
+	return pub, err
+}
+
+func parsePublicKey(data []byte) (pub ed25519.PublicKey, rest []byte, retErr error) {
+	b, rest := pem.Decode(data)
+	if b == nil {
+		return nil, nil, errors.New("failed to decode PEM data")
+	}
+	if b.Type != pemTypePublic {
+		return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, pemTypePublic)
+	}
+	if len(b.Bytes) != ed25519.PublicKeySize {
+		return nil, nil, errors.New("public key has incorrect length for an Ed25519 public key")
+	}
+	return ed25519.PublicKey(b.Bytes), rest, nil
+}
+
+// verifyAny verifies whether sig is valid for msg using any of the keys.
+// verifyAny will panic of any of the keys have the wrong size for Ed25519.
+func verifyAny(keys []ed25519.PublicKey, msg, sig []byte) bool {
+	for _, k := range keys {
+		if ed25519consensus.Verify(k, msg, sig) {
+			return true
+		}
+	}
+	return false
+}

+ 347 - 0
clientupdate/distsign/distsign_test.go

@@ -0,0 +1,347 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package distsign
+
+import (
+	"bytes"
+	"crypto/ed25519"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"golang.org/x/crypto/blake2s"
+)
+
+func TestDownload(t *testing.T) {
+	srv := newTestServer(t)
+	c := srv.client(t)
+
+	tests := []struct {
+		desc    string
+		before  func(*testing.T)
+		src     string
+		want    []byte
+		wantErr bool
+	}{
+		{
+			desc:    "missing file",
+			before:  func(*testing.T) {},
+			src:     "hello",
+			wantErr: true,
+		},
+		{
+			desc: "success",
+			before: func(*testing.T) {
+				srv.addSigned("hello", []byte("world"))
+			},
+			src:  "hello",
+			want: []byte("world"),
+		},
+		{
+			desc: "no signature",
+			before: func(*testing.T) {
+				srv.add("hello", []byte("world"))
+			},
+			src:     "hello",
+			wantErr: true,
+		},
+		{
+			desc: "bad signature",
+			before: func(*testing.T) {
+				srv.add("hello", []byte("world"))
+				srv.add("hello.sig", []byte("potato"))
+			},
+			src:     "hello",
+			wantErr: true,
+		},
+		{
+			desc: "signed with untrusted key",
+			before: func(t *testing.T) {
+				srv.add("hello", []byte("world"))
+				srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
+			},
+			src:     "hello",
+			wantErr: true,
+		},
+		{
+			desc: "signed with root key",
+			before: func(t *testing.T) {
+				srv.add("hello", []byte("world"))
+				srv.add("hello.sig", srv.roots[0].sign([]byte("world")))
+			},
+			src:     "hello",
+			wantErr: true,
+		},
+		{
+			desc: "bad signing key signature",
+			before: func(t *testing.T) {
+				srv.add("distsign.pub.sig", []byte("potato"))
+				srv.addSigned("hello", []byte("world"))
+			},
+			src:     "hello",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			srv.reset()
+			tt.before(t)
+
+			dst := filepath.Join(t.TempDir(), tt.src)
+			t.Cleanup(func() {
+				os.Remove(dst)
+			})
+			err := c.Download(tt.src, dst)
+			if err != nil {
+				if tt.wantErr {
+					return
+				}
+				t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
+			}
+			if tt.wantErr {
+				t.Fatalf("Download(%q) succeeded, expected an error", tt.src)
+			}
+			got, err := os.ReadFile(dst)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !bytes.Equal(tt.want, got) {
+				t.Errorf("Download(%q): got %q, want %q", tt.src, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRotateRoot(t *testing.T) {
+	srv := newTestServer(t)
+	c1 := srv.client(t)
+
+	srv.addSigned("hello", []byte("world"))
+	if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed on a fresh server: %v", err)
+	}
+
+	// Remove first root and replace it with a new key.
+	srv.roots = append(srv.roots[1:], newRootKeyPair(t))
+
+	// Old client can still download files because it still trusts the old
+	// root key.
+	if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after root rotation on old client: %v", err)
+	}
+	// New client should fail download because current signing key is signed by
+	// the revoked root that new client doesn't trust.
+	c2 := srv.client(t)
+	if err := c2.Download("hello", filepath.Join(t.TempDir(), "hello")); err == nil {
+		t.Fatalf("Download succeeded on new client, but signing key is signed with revoked root key")
+	}
+	// Re-sign signing key with another valid root that client still trusts.
+	srv.resignSigningKeys()
+	// Both old and new clients should now be able to download.
+	//
+	// Note: we don't need to re-sign the "hello" file because signing key
+	// didn't change (only signing key's signature).
+	if err := c1.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after root rotation on old client with re-signed signing key: %v", err)
+	}
+	if err := c2.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after root rotation on new client with re-signed signing key: %v", err)
+	}
+}
+
+func TestRotateSigning(t *testing.T) {
+	srv := newTestServer(t)
+	c := srv.client(t)
+
+	srv.addSigned("hello", []byte("world"))
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed on a fresh server: %v", err)
+	}
+
+	// Replace signing key but don't publish it yet.
+	srv.sign = append(srv.sign, newSigningKeyPair(t))
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after new signing key added but before publishing it: %v", err)
+	}
+
+	// Publish new signing key bundle with both keys.
+	srv.resignSigningKeys()
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after new signing key was published: %v", err)
+	}
+
+	// Re-sign the "hello" file with new signing key.
+	srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after re-signing with new signing key: %v", err)
+	}
+
+	// Drop the old signing key.
+	srv.sign = srv.sign[1:]
+	srv.resignSigningKeys()
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after removing old signing key: %v", err)
+	}
+
+	// Add another key and re-sign the file with it *before* publishing.
+	srv.sign = append(srv.sign, newSigningKeyPair(t))
+	srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err == nil {
+		t.Fatalf("Download succeeded when signed with a not-yet-published signing key")
+	}
+	// Fix this by publishing the new key.
+	srv.resignSigningKeys()
+	if err := c.Download("hello", filepath.Join(t.TempDir(), "hello")); err != nil {
+		t.Fatalf("Download failed after publishing new signing key: %v", err)
+	}
+}
+
+type testServer struct {
+	roots []rootKeyPair
+	sign  []signingKeyPair
+	files map[string][]byte
+	srv   *httptest.Server
+}
+
+func newTestServer(t *testing.T) *testServer {
+	var roots []rootKeyPair
+	for i := 0; i < 3; i++ {
+		roots = append(roots, newRootKeyPair(t))
+	}
+
+	ts := &testServer{
+		roots: roots,
+		sign:  []signingKeyPair{newSigningKeyPair(t)},
+	}
+	ts.reset()
+	ts.srv = httptest.NewServer(ts)
+	t.Cleanup(ts.srv.Close)
+	return ts
+}
+
+func (s *testServer) client(t *testing.T) *Client {
+	roots := make([]ed25519.PublicKey, 0, len(s.roots))
+	for _, r := range s.roots {
+		pub, err := parseSinglePublicKey(r.pubRaw)
+		if err != nil {
+			t.Fatalf("parsePublicKey: %v", err)
+		}
+		roots = append(roots, pub)
+	}
+	u, err := url.Parse(s.srv.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return &Client{
+		roots:    roots,
+		pkgsAddr: u,
+	}
+}
+
+func (s *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	path := strings.TrimPrefix(r.URL.Path, "/")
+	data, ok := s.files[path]
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	w.Write(data)
+}
+
+func (s *testServer) addSigned(name string, data []byte) {
+	s.files[name] = data
+	s.files[name+".sig"] = s.sign[0].sign(data)
+}
+
+func (s *testServer) add(name string, data []byte) {
+	s.files[name] = data
+}
+
+func (s *testServer) reset() {
+	s.files = make(map[string][]byte)
+	s.resignSigningKeys()
+}
+
+func (s *testServer) resignSigningKeys() {
+	var pubs [][]byte
+	for _, k := range s.sign {
+		pubs = append(pubs, k.pubRaw)
+	}
+	bundle := bytes.Join(pubs, []byte("\n"))
+	sig := s.roots[0].sign(bundle)
+	s.files["distsign.pub"] = bundle
+	s.files["distsign.pub.sig"] = sig
+}
+
+type rootKeyPair struct {
+	*RootKey
+	keyPair
+}
+
+func newRootKeyPair(t *testing.T) rootKeyPair {
+	kp := newKeyPair(t)
+	priv, err := parsePrivateKey(kp.privRaw)
+	if err != nil {
+		t.Fatalf("parsePrivateKey: %v", err)
+	}
+	return rootKeyPair{
+		RootKey: &RootKey{Signer: priv},
+		keyPair: kp,
+	}
+}
+
+func (s rootKeyPair) sign(bundle []byte) []byte {
+	sig, err := s.SignSigningKeys(bundle)
+	if err != nil {
+		panic(err)
+	}
+	return sig
+}
+
+type signingKeyPair struct {
+	*SigningKey
+	keyPair
+}
+
+func newSigningKeyPair(t *testing.T) signingKeyPair {
+	kp := newKeyPair(t)
+	priv, err := parsePrivateKey(kp.privRaw)
+	if err != nil {
+		t.Fatalf("parsePrivateKey: %v", err)
+	}
+	return signingKeyPair{
+		SigningKey: &SigningKey{Signer: priv},
+		keyPair:    kp,
+	}
+}
+
+func (s signingKeyPair) sign(blob []byte) []byte {
+	hash := blake2s.Sum256(blob)
+	sig, err := s.SignPackageHash(hash[:], int64(len(blob)))
+	if err != nil {
+		panic(err)
+	}
+	return sig
+}
+
+type keyPair struct {
+	privRaw []byte
+	pubRaw  []byte
+}
+
+func newKeyPair(t *testing.T) keyPair {
+	privRaw, pubRaw, err := GenerateKey()
+	if err != nil {
+		t.Fatalf("GenerateKey: %v", err)
+	}
+	return keyPair{
+		privRaw: privRaw,
+		pubRaw:  pubRaw,
+	}
+}

+ 54 - 0
clientupdate/distsign/roots.go

@@ -0,0 +1,54 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package distsign
+
+import (
+	"crypto/ed25519"
+	"embed"
+	"errors"
+	"fmt"
+	"path"
+	"path/filepath"
+	"sync"
+)
+
+//go:embed roots
+var rootsFS embed.FS
+
+var roots = sync.OnceValue(func() []ed25519.PublicKey {
+	roots, err := parseRoots()
+	if err != nil {
+		panic(err)
+	}
+	return roots
+})
+
+func parseRoots() ([]ed25519.PublicKey, error) {
+	files, err := rootsFS.ReadDir("roots")
+	if err != nil {
+		return nil, err
+	}
+	var keys []ed25519.PublicKey
+	for _, f := range files {
+		if !f.Type().IsRegular() {
+			continue
+		}
+		if filepath.Ext(f.Name()) != ".pub" {
+			continue
+		}
+		raw, err := rootsFS.ReadFile(path.Join("roots", f.Name()))
+		if err != nil {
+			return nil, err
+		}
+		key, err := parseSinglePublicKey(raw)
+		if err != nil {
+			return nil, fmt.Errorf("parsing root key %q: %w", f.Name(), err)
+		}
+		keys = append(keys, key)
+	}
+	if len(keys) == 0 {
+		return nil, errors.New("no embedded root keys, please check clientupdate/distsign/roots/")
+	}
+	return keys, nil
+}

+ 3 - 0
clientupdate/distsign/roots/to-be-replaced.pub

@@ -0,0 +1,3 @@
+-----BEGIN PUBLIC KEY-----
+JNBgo4EFQ+DpRcESM2xU19xQWGffvLcmxtBMT4I+Qo0=
+-----END PUBLIC KEY-----

+ 16 - 0
clientupdate/distsign/roots_test.go

@@ -0,0 +1,16 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package distsign
+
+import "testing"
+
+func TestParseRoots(t *testing.T) {
+	roots, err := parseRoots()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(roots) == 0 {
+		t.Error("parseRoots returned no root keys")
+	}
+}