From 7364c6beeca537161bfd6fbf2284e7574a122416 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Tue, 22 Aug 2023 14:35:30 -0600 Subject: [PATCH] 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 --- clientupdate/distsign/distsign.go | 338 +++++++++++++++++ clientupdate/distsign/distsign_test.go | 347 ++++++++++++++++++ clientupdate/distsign/roots.go | 54 +++ .../distsign/roots/to-be-replaced.pub | 3 + clientupdate/distsign/roots_test.go | 16 + 5 files changed, 758 insertions(+) create mode 100644 clientupdate/distsign/distsign.go create mode 100644 clientupdate/distsign/distsign_test.go create mode 100644 clientupdate/distsign/roots.go create mode 100644 clientupdate/distsign/roots/to-be-replaced.pub create mode 100644 clientupdate/distsign/roots_test.go diff --git a/clientupdate/distsign/distsign.go b/clientupdate/distsign/distsign.go new file mode 100644 index 000000000..9d5fb106c --- /dev/null +++ b/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 +} diff --git a/clientupdate/distsign/distsign_test.go b/clientupdate/distsign/distsign_test.go new file mode 100644 index 000000000..2d450b86a --- /dev/null +++ b/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, + } +} diff --git a/clientupdate/distsign/roots.go b/clientupdate/distsign/roots.go new file mode 100644 index 000000000..b148b2189 --- /dev/null +++ b/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 +} diff --git a/clientupdate/distsign/roots/to-be-replaced.pub b/clientupdate/distsign/roots/to-be-replaced.pub new file mode 100644 index 000000000..d9e7989f8 --- /dev/null +++ b/clientupdate/distsign/roots/to-be-replaced.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +JNBgo4EFQ+DpRcESM2xU19xQWGffvLcmxtBMT4I+Qo0= +-----END PUBLIC KEY----- diff --git a/clientupdate/distsign/roots_test.go b/clientupdate/distsign/roots_test.go new file mode 100644 index 000000000..7a9452953 --- /dev/null +++ b/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") + } +}