mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-01 14:05:39 +00:00
7d60c19d7d
Our build system caches files locally and only updates them when something changes. Since I need to integrate some distsign stuff into the build system to validate our Windows 7 MSIs, I want to be able to check the cached copy of a package before downloading a fresh copy from pkgs. If the signature changes, then obviously the local copy is outdated and we return an error, at which point we call Download to refresh the package. Updates https://github.com/tailscale/corp/issues/14334 Signed-off-by: Aaron Klotz <aaron@tailscale.com>
486 lines
15 KiB
Go
486 lines
15 KiB
Go
// 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 (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/hdevalence/ed25519consensus"
|
|
"golang.org/x/crypto/blake2s"
|
|
"tailscale.com/net/tshttpproxy"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/must"
|
|
)
|
|
|
|
const (
|
|
pemTypeRootPrivate = "ROOT PRIVATE KEY"
|
|
pemTypeRootPublic = "ROOT PUBLIC KEY"
|
|
pemTypeSigningPrivate = "SIGNING PRIVATE KEY"
|
|
pemTypeSigningPublic = "SIGNING PUBLIC KEY"
|
|
|
|
downloadSizeLimit = 1 << 29 // 512MB
|
|
signingKeysSizeLimit = 1 << 20 // 1MB
|
|
signatureSizeLimit = ed25519.SignatureSize
|
|
)
|
|
|
|
// RootKey is a root key used to sign signing keys.
|
|
type RootKey struct {
|
|
k ed25519.PrivateKey
|
|
}
|
|
|
|
// GenerateRootKey generates a new root key pair and encodes it as PEM.
|
|
func GenerateRootKey() (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: pemTypeRootPrivate,
|
|
Bytes: []byte(priv),
|
|
}), pem.EncodeToMemory(&pem.Block{
|
|
Type: pemTypeRootPublic,
|
|
Bytes: []byte(pub),
|
|
}), nil
|
|
}
|
|
|
|
// ParseRootKey parses the PEM-encoded private root key. The key must be in the
|
|
// same format as returned by GenerateRootKey.
|
|
func ParseRootKey(privKey []byte) (*RootKey, error) {
|
|
k, err := parsePrivateKey(privKey, pemTypeRootPrivate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
|
}
|
|
return &RootKey{k: k}, nil
|
|
}
|
|
|
|
// SignSigningKeys signs the bundle of public signing keys. The bundle must be
|
|
// a sequence of PEM blocks joined with newlines.
|
|
func (r *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
|
|
if _, err := ParseSigningKeyBundle(pubBundle); err != nil {
|
|
return nil, err
|
|
}
|
|
return ed25519.Sign(r.k, pubBundle), nil
|
|
}
|
|
|
|
// SigningKey is a signing key used to sign packages.
|
|
type SigningKey struct {
|
|
k ed25519.PrivateKey
|
|
}
|
|
|
|
// GenerateSigningKey generates a new signing key pair and encodes it as PEM.
|
|
func GenerateSigningKey() (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: pemTypeSigningPrivate,
|
|
Bytes: []byte(priv),
|
|
}), pem.EncodeToMemory(&pem.Block{
|
|
Type: pemTypeSigningPublic,
|
|
Bytes: []byte(pub),
|
|
}), nil
|
|
}
|
|
|
|
// ParseSigningKey parses the PEM-encoded private signing key. The key must be
|
|
// in the same format as returned by GenerateSigningKey.
|
|
func ParseSigningKey(privKey []byte) (*SigningKey, error) {
|
|
k, err := parsePrivateKey(privKey, pemTypeSigningPrivate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
|
}
|
|
return &SigningKey{k: k}, nil
|
|
}
|
|
|
|
// 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 ed25519.Sign(s.k, msg), nil
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// Client downloads and validates files from a distribution server.
|
|
type Client struct {
|
|
logf logger.Logf
|
|
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(logf logger.Logf, pkgsAddr string) (*Client, error) {
|
|
if logf == nil {
|
|
logf = log.Printf
|
|
}
|
|
u, err := url.Parse(pkgsAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
|
|
}
|
|
return &Client{logf: logf, 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(ctx context.Context, 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"
|
|
|
|
c.logf("Downloading %q", srcURL)
|
|
dstPathUnverified := dstPath + ".unverified"
|
|
hash, len, err := c.download(ctx, srcURL, dstPathUnverified, downloadSizeLimit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.logf("Downloading %q", sigURL)
|
|
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 file %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)
|
|
}
|
|
c.logf("Signature OK")
|
|
|
|
if err := os.Rename(dstPathUnverified, dstPath); err != nil {
|
|
return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateLocalBinary fetches the latest signature associated with the binary
|
|
// at srcURLPath and uses it to validate the file located on disk via
|
|
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
|
|
// with the signature download or with signature validation.
|
|
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
|
|
// Always fetch a fresh signing key.
|
|
sigPub, err := c.signingKeys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
srcURL := c.url(srcURLPath)
|
|
sigURL := srcURL + ".sig"
|
|
|
|
localFile, err := os.Open(localFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer localFile.Close()
|
|
|
|
h := NewPackageHash()
|
|
_, err = io.Copy(h, localFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hash, hashLen := h.Sum(nil), h.Len()
|
|
|
|
c.logf("Downloading %q", sigURL)
|
|
sig, err := fetch(sigURL, signatureSizeLimit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
|
|
if !VerifyAny(sigPub, msg, sig) {
|
|
return fmt.Errorf("signature %q for file %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, localFilePath)
|
|
}
|
|
c.logf("Signature OK")
|
|
|
|
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)
|
|
}
|
|
|
|
keys, err := ParseSigningKeyBundle(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse signing key bundle from %q: %w", keyURL, err)
|
|
}
|
|
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 (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
|
defer tr.CloseIdleConnections()
|
|
hc := &http.Client{Transport: tr}
|
|
|
|
quickCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
headReq := must.Get(http.NewRequestWithContext(quickCtx, http.MethodHead, url, nil))
|
|
|
|
res, err := hc.Do(headReq)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, 0, fmt.Errorf("HEAD %q: %v", url, res.Status)
|
|
}
|
|
if res.ContentLength <= 0 {
|
|
return nil, 0, fmt.Errorf("HEAD %q: unexpected Content-Length %v", url, res.ContentLength)
|
|
}
|
|
c.logf("Download size: %v", res.ContentLength)
|
|
|
|
dlReq := must.Get(http.NewRequestWithContext(ctx, http.MethodGet, url, nil))
|
|
dlRes, err := hc.Do(dlReq)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer dlRes.Body.Close()
|
|
// TODO(bradfitz): resume from existing partial file on disk
|
|
if dlRes.StatusCode != http.StatusOK {
|
|
return nil, 0, fmt.Errorf("GET %q: %v", url, dlRes.Status)
|
|
}
|
|
|
|
of, err := os.Create(dst)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer of.Close()
|
|
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
|
|
h := NewPackageHash()
|
|
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
|
|
if err != nil {
|
|
return nil, n, err
|
|
}
|
|
if n != res.ContentLength {
|
|
return nil, n, fmt.Errorf("GET %q: downloaded %v, want %v", url, n, res.ContentLength)
|
|
}
|
|
if err := dlRes.Body.Close(); err != nil {
|
|
return nil, n, err
|
|
}
|
|
if err := of.Close(); err != nil {
|
|
return nil, n, err
|
|
}
|
|
pw.print()
|
|
|
|
return h.Sum(nil), h.Len(), nil
|
|
}
|
|
|
|
type progressWriter struct {
|
|
done int64
|
|
total int64
|
|
lastPrint time.Time
|
|
logf logger.Logf
|
|
}
|
|
|
|
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
|
pw.done += int64(len(p))
|
|
if time.Since(pw.lastPrint) > 2*time.Second {
|
|
pw.print()
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (pw *progressWriter) print() {
|
|
pw.lastPrint = time.Now()
|
|
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
|
}
|
|
|
|
func parsePrivateKey(data []byte, typeTag string) (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 != typeTag {
|
|
return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
|
}
|
|
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
|
|
}
|
|
|
|
// ParseSigningKeyBundle parses the bundle of PEM-encoded public signing keys.
|
|
func ParseSigningKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
|
return parsePublicKeyBundle(bundle, pemTypeSigningPublic)
|
|
}
|
|
|
|
// ParseRootKeyBundle parses the bundle of PEM-encoded public root keys.
|
|
func ParseRootKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
|
return parsePublicKeyBundle(bundle, pemTypeRootPublic)
|
|
}
|
|
|
|
func parsePublicKeyBundle(bundle []byte, typeTag string) ([]ed25519.PublicKey, error) {
|
|
var keys []ed25519.PublicKey
|
|
for len(bundle) > 0 {
|
|
pub, rest, err := parsePublicKey(bundle, typeTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys = append(keys, pub)
|
|
bundle = rest
|
|
}
|
|
if len(keys) == 0 {
|
|
return nil, errors.New("no signing keys found in the bundle")
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func parseSinglePublicKey(data []byte, typeTag string) (ed25519.PublicKey, error) {
|
|
pub, rest, err := parsePublicKey(data, typeTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rest) > 0 {
|
|
return nil, errors.New("trailing PEM data")
|
|
}
|
|
return pub, err
|
|
}
|
|
|
|
func parsePublicKey(data []byte, typeTag string) (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 != typeTag {
|
|
return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
|
}
|
|
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 if 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
|
|
}
|