blob: e0923f78dcb8292fb39ad3161eb1ff1e96f372d8 [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Notecheck checks a go.sum file against a notary.
//
// WARNING! This program is meant as a proof of concept demo and
// should not be used in production scripts.
// It does not set an exit status to report whether the
// checksums matched, and it does not filter the go.sum
// according to the $GONOVERIFY environment variable.
//
// Usage:
//
// notecheck [-v] notary-key go.sum
//
// The -v flag enables verbose output.
//
package main
import (
"bytes"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"golang.org/x/exp/notary/internal/note"
"golang.org/x/exp/notary/internal/tlog"
)
func usage() {
fmt.Fprintf(os.Stderr, "usage: notecheck [-u url] [-h H] [-v] notary-key go.sum...\n")
os.Exit(2)
}
var height = flag.Int("h", 8, "tile height")
var vflag = flag.Bool("v", false, "enable verbose output")
var url = flag.String("u", "", "url to notary (overriding name)")
func main() {
log.SetPrefix("notecheck: ")
log.SetFlags(0)
flag.Usage = usage
flag.Parse()
if flag.NArg() < 2 {
usage()
}
vkey := flag.Arg(0)
verifier, err := note.NewVerifier(vkey)
if err != nil {
log.Fatal(err)
}
if *url == "" {
*url = "https://" + verifier.Name()
}
// TODO(rsc): Load initial db.latest, db.latestNote from on-disk cache.
db := &GoSumDB{
url: *url,
verifiers: note.VerifierList(verifier),
}
db.httpClient.Timeout = 1 * time.Minute
db.tileReader.db = db
db.tileReader.url = db.url + "/"
for _, arg := range flag.Args()[1:] {
data, err := ioutil.ReadFile(arg)
if err != nil {
log.Fatal(err)
}
log.SetPrefix("notecheck: " + arg + ": ")
checkGoSum(db, data)
log.SetPrefix("notecheck: ")
}
}
func checkGoSum(db *GoSumDB, data []byte) {
lines := strings.Split(string(data), "\n")
if lines[len(lines)-1] != "" {
log.Printf("error: final line missing newline")
return
}
// TODO(rsc): This assumes that the /go.mod and the whole-tree hashes
// always appear together in a go.sum.
// Sometimes the /go.mod can appear alone.
// The code needs to be updated to handle that case.
lines = lines[:len(lines)-1]
if len(lines)%2 != 0 {
log.Printf("error: odd number of lines")
}
for i := 0; i+2 <= len(lines); i += 2 {
f1 := strings.Fields(lines[i])
f2 := strings.Fields(lines[i+1])
if len(f1) != 3 || len(f2) != 3 || f1[0] != f2[0] || f1[1]+"/go.mod" != f2[1] {
log.Printf("error: bad line pair:\n\t%s\t%s", lines[i], lines[i+1])
continue
}
dbLines, err := db.Lookup(f1[0], f1[1])
if err != nil {
log.Printf("%s@%s: %v", f1[0], f1[1], err)
continue
}
if strings.Join(lines[i:i+2], "\n") != strings.Join(dbLines, "\n") {
log.Printf("%s@%s: invalid go.sum entries:\ngo.sum:\n\t%s\nsum.golang.org:\n\t%s", f1[0], f1[1], strings.Join(lines[i:i+2], "\n\t"), strings.Join(dbLines, "\n\t"))
}
}
}
// A GoSumDB is a client for a go.sum database.
type GoSumDB struct {
url string // root url of database, without trailing slash
verifiers note.Verifiers // accepted verifiers for signed trees
tileReader tileReader // tlog.TileReader implementation
httpCache parCache
httpClient http.Client
// latest accepted tree head
mu sync.Mutex
latest tlog.Tree
latestNote []byte // signed note
}
// parCache is a minimal simulation of cmd/go's par.Cache.
// When this code moves into cmd/go, it should use the real par.Cache
type parCache struct {
}
func (c *parCache) Do(key interface{}, f func() interface{}) interface{} {
return f()
}
// Lookup returns the go.sum lines for the given module path and version.
func (db *GoSumDB) Lookup(path, vers string) ([]string, error) {
// TODO(rsc): !-encode the path.
data, err := db.httpGet(db.url + "/lookup/" + path + "@" + vers)
if err != nil {
return nil, err
}
id, text, treeMsg, err := tlog.ParseRecord(data)
if err != nil {
return nil, fmt.Errorf("%s@%s: %v", path, vers, err)
}
if err := db.updateLatest(treeMsg); err != nil {
return nil, fmt.Errorf("%s@%s: %v", path, vers, err)
}
if err := db.checkRecord(id, text); err != nil {
return nil, fmt.Errorf("%s@%s: %v", path, vers, err)
}
prefix := path + " " + vers + " "
prefixGoMod := path + " " + vers + "/go.mod "
var hashes []string
for _, line := range strings.Split(string(text), "\n") {
if strings.HasPrefix(line, prefix) || strings.HasPrefix(line, prefixGoMod) {
hashes = append(hashes, line)
}
}
return hashes, nil
}
// updateLatest updates db's idea of the latest tree head
// to incorporate the signed tree head in msg.
// If msg is before the current latest tree head,
// updateLatest still checks that it fits into the known timeline.
// updateLatest returns an error for non-malicious problems.
// If it detects a fork in the tree history, it prints a detailed
// message and calls log.Fatal.
func (db *GoSumDB) updateLatest(msg []byte) error {
if len(msg) == 0 {
return nil
}
note, err := note.Open(msg, db.verifiers)
if err != nil {
return fmt.Errorf("reading tree note: %v\nnote:\n%s", err, msg)
}
tree, err := tlog.ParseTree([]byte(note.Text))
if err != nil {
return fmt.Errorf("reading tree: %v\ntree:\n%s", err, note.Text)
}
Update:
for {
db.mu.Lock()
latest := db.latest
latestNote := db.latestNote
db.mu.Unlock()
switch {
case tree.N <= latest.N:
return db.checkTrees(tree, msg, latest, latestNote)
case tree.N > latest.N:
if err := db.checkTrees(latest, latestNote, tree, msg); err != nil {
return err
}
db.mu.Lock()
if db.latest != latest {
if db.latest.N > latest.N {
db.mu.Unlock()
continue Update
}
log.Fatalf("go.sum database changed underfoot:\n\t%v ->\n\t%v", latest, db.latest)
}
db.latest = tree
db.latestNote = msg
db.mu.Unlock()
return nil
}
}
}
// checkTrees checks that older (from olderNote) is contained in newer (from newerNote).
// If an error occurs, such as malformed data or a network problem, checkTrees returns that error.
// If on the other hand checkTrees finds evidence of misbehavior, it prepares a detailed
// message and calls log.Fatal.
func (db *GoSumDB) checkTrees(older tlog.Tree, olderNote []byte, newer tlog.Tree, newerNote []byte) error {
thr := tlog.TileHashReader(newer, &db.tileReader)
h, err := tlog.TreeHash(older.N, thr)
if err != nil {
return fmt.Errorf("checking tree#%d against tree#%d: %v", older.N, newer.N, err)
}
if h == older.Hash {
return nil
}
// Detected a fork in the tree timeline.
// Start by reporting the inconsistent signed tree notes.
var buf bytes.Buffer
fmt.Fprintf(&buf, "SECURITY ERROR\n")
fmt.Fprintf(&buf, "go.sum database server misbehavior detected!\n\n")
indent := func(b []byte) []byte {
return bytes.Replace(b, []byte("\n"), []byte("\n\t"), -1)
}
fmt.Fprintf(&buf, "old database:\n\t%v\n", indent(olderNote))
fmt.Fprintf(&buf, "new database:\n\t%v\n", indent(newerNote))
// The notes alone are not enough to prove the inconsistency.
// We also need to show that the newer note's tree hash for older.N
// does not match older.Hash. The consumer of this report could
// of course consult the server to try to verify the inconsistency,
// but we are holding all the bits we need to prove it right now,
// so we might as well print them and make the report not depend
// on the continued availability of the misbehaving server.
// Preparing this data only reuses the tiled hashes needed for
// tlog.TreeHash(older.N, thr) above, so assuming thr is caching tiles,
// there are no new access to the server here, and these operations cannot fail.
fmt.Fprintf(&buf, "proof of misbehavior:\n\t%v", h)
if p, err := tlog.ProveTree(newer.N, older.N, thr); err != nil {
fmt.Fprintf(&buf, "\tinternal error: %v\n", err)
} else if err := tlog.CheckTree(p, newer.N, newer.Hash, older.N, h); err != nil {
fmt.Fprintf(&buf, "\tinternal error: generated inconsistent proof\n")
} else {
for _, h := range p {
fmt.Fprintf(&buf, "\n\t%v", h)
}
}
log.Fatalf("%v", buf.String())
panic("not reached")
}
// checkRecord checks that record #id's hash matches data.
func (db *GoSumDB) checkRecord(id int64, data []byte) error {
db.mu.Lock()
tree := db.latest
db.mu.Unlock()
if id >= tree.N {
return fmt.Errorf("cannot validate record %d in tree of size %d", id, tree.N)
}
hashes, err := tlog.TileHashReader(tree, &db.tileReader).ReadHashes([]int64{tlog.StoredHashIndex(0, id)})
if err != nil {
return err
}
if hashes[0] == tlog.RecordHash(data) {
return nil
}
return fmt.Errorf("cannot authenticate record data in server response")
}
type tileReader struct {
url string
cache map[tlog.Tile][]byte
cacheMu sync.Mutex
db *GoSumDB
}
func (r *tileReader) Height() int {
return *height
}
func (r *tileReader) SaveTiles(tiles []tlog.Tile, data [][]byte) {
// TODO(rsc): On-disk cache in GOPATH.
}
func (r *tileReader) ReadTiles(tiles []tlog.Tile) ([][]byte, error) {
// TODO(rsc): Look in on-disk cache in GOPATH.
var wg sync.WaitGroup
out := make([][]byte, len(tiles))
errs := make([]error, len(tiles))
r.cacheMu.Lock()
if r.cache == nil {
r.cache = make(map[tlog.Tile][]byte)
}
for i, tile := range tiles {
if data := r.cache[tile]; data != nil {
out[i] = data
continue
}
wg.Add(1)
go func(i int, tile tlog.Tile) {
defer wg.Done()
data, err := r.db.httpGet(r.url + tile.Path())
if err != nil && tile.W != 1<<uint(tile.H) {
fullTile := tile
fullTile.W = 1 << uint(tile.H)
if fullData, err1 := r.db.httpGet(r.url + fullTile.Path()); err1 == nil {
data = fullData[:tile.W*tlog.HashSize]
err = nil
}
}
if err != nil {
errs[i] = err
return
}
r.cacheMu.Lock()
r.cache[tile] = data
r.cacheMu.Unlock()
out[i] = data
}(i, tile)
}
r.cacheMu.Unlock()
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
return out, nil
}
func (db *GoSumDB) httpGet(url string) ([]byte, error) {
type cached struct {
data []byte
err error
}
c := db.httpCache.Do(url, func() interface{} {
start := time.Now()
resp, err := db.httpClient.Get(url)
if err != nil {
return cached{nil, err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return cached{nil, fmt.Errorf("GET %v: %v", url, resp.Status)}
}
data, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return cached{nil, err}
}
if *vflag {
fmt.Fprintf(os.Stderr, "%.3fs %s\n", time.Since(start).Seconds(), url)
}
return cached{data, nil}
}).(cached)
return c.data, c.err
}