mirror of
https://github.com/tailscale/tailscale.git
synced 2026-01-25 18:31:27 +01:00
This file was never truly necessary and has never actually been used in the history of Tailscale's open source releases. A Brief History of AUTHORS files --- The AUTHORS file was a pattern developed at Google, originally for Chromium, then adopted by Go and a bunch of other projects. The problem was that Chromium originally had a copyright line only recognizing Google as the copyright holder. Because Google (and most open source projects) do not require copyright assignemnt for contributions, each contributor maintains their copyright. Some large corporate contributors then tried to add their own name to the copyright line in the LICENSE file or in file headers. This quickly becomes unwieldy, and puts a tremendous burden on anyone building on top of Chromium, since the license requires that they keep all copyright lines intact. The compromise was to create an AUTHORS file that would list all of the copyright holders. The LICENSE file and source file headers would then include that list by reference, listing the copyright holder as "The Chromium Authors". This also become cumbersome to simply keep the file up to date with a high rate of new contributors. Plus it's not always obvious who the copyright holder is. Sometimes it is the individual making the contribution, but many times it may be their employer. There is no way for the proejct maintainer to know. Eventually, Google changed their policy to no longer recommend trying to keep the AUTHORS file up to date proactively, and instead to only add to it when requested: https://opensource.google/docs/releasing/authors. They are also clear that: > Adding contributors to the AUTHORS file is entirely within the > project's discretion and has no implications for copyright ownership. It was primarily added to appease a small number of large contributors that insisted that they be recognized as copyright holders (which was entirely their right to do). But it's not truly necessary, and not even the most accurate way of identifying contributors and/or copyright holders. In practice, we've never added anyone to our AUTHORS file. It only lists Tailscale, so it's not really serving any purpose. It also causes confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header in other open source repos which don't actually have an AUTHORS file, so it's ambiguous what that means. Instead, we just acknowledge that the contributors to Tailscale (whoever they are) are copyright holders for their individual contributions. We also have the benefit of using the DCO (developercertificate.org) which provides some additional certification of their right to make the contribution. The source file changes were purely mechanical with: git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g' Updates #cleanup Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
493 lines
15 KiB
Go
493 lines
15 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// 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/feature"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/httpm"
|
|
"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 = feature.HookProxyFromEnvironment.GetOrNil()
|
|
defer tr.CloseIdleConnections()
|
|
hc := &http.Client{
|
|
Transport: tr,
|
|
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
|
c.logf("Download redirected to %q", r.URL)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
quickCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
headReq := must.Get(http.NewRequestWithContext(quickCtx, httpm.HEAD, 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, httpm.GET, 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
|
|
}
|