Skip to content
quarry Artifact [e5d49d98a6]
Login

Artifact [e5d49d98a6]

Artifact e5d49d98a65ac3ede7145aa7bcb0f9ee3cad70385704b2e27f384458319ddc33:


package xfer

import (
	"bytes"
	"crypto/sha1"
	"crypto/sha3"
	"encoding/hex"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
)

// A login card signs only the bytes after it (sync RFC ยง6.2), and
// fossil reads a request in order, so a card before the login runs as
// nobody. A push gate that verified the login and then credited every
// write card in the body to it would let anyone holding one signed
// request from a person with the check-in letter put their own
// artifacts in front of it and replay it under that person's name.

// preloginRig is a gated store with one account that may write, behind
// a front, and a proxy in front of that which records each request and
// may rewrite it on the way.
type preloginRig struct {
	t       *testing.T
	origin  string
	code    string
	front   string
	proxy   string
	mu      sync.Mutex
	seen    [][]byte
	rewrite func([]byte) []byte
}

func newPreloginRig(t *testing.T) *preloginRig {
	t.Helper()
	for _, bin := range []string{"zit", "fossil"} {
		if _, err := exec.LookPath(bin); err != nil {
			t.Skip(bin + " not on PATH")
		}
	}
	g := &preloginRig{t: t, origin: t.TempDir()}
	zrun(t, g.origin, "init")
	if err := os.WriteFile(filepath.Join(g.origin, "README.md"), []byte("# gated\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, g.origin, "add", "README.md")
	zrun(t, g.origin, "commit", "-m", "first")
	code, err := ProjectCode(g.origin)
	if err != nil {
		t.Fatal(err)
	}
	g.code = code
	f := New()
	t.Cleanup(f.Close)
	f.Register("alpha", g.origin)
	f.SetAuth(&mapAuth{users: map[string]string{"dan": SharedSecret(code, "dan", "hunter2")}})
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	front := httptest.NewServer(mux)
	t.Cleanup(front.Close)
	g.front = front.URL + "/r/alpha"
	proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		body := decodeContainer(raw)
		g.mu.Lock()
		g.seen = append(g.seen, body)
		rewrite := g.rewrite
		g.mu.Unlock()
		if rewrite != nil {
			body = rewrite(body)
		}
		resp, err := postSync(g.front, body, r.Header.Get("Cookie"))
		if err != nil {
			w.WriteHeader(http.StatusBadGateway)
			return
		}
		w.Header().Set("Content-Type", "application/x-fossil")
		w.Write(encodeContainer(resp))
	}))
	t.Cleanup(proxy.Close)
	g.proxy = proxy.URL + "/r/alpha"
	return g
}

func (g *preloginRig) setRewrite(r func([]byte) []byte) {
	g.mu.Lock()
	defer g.mu.Unlock()
	g.rewrite = r
}

// postSync sends body to url as a compressed fossil request and returns the
// decoded reply.
func postSync(url string, body []byte, cookie string) ([]byte, error) {
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(encodeContainer(body)))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-fossil")
	if cookie != "" {
		req.Header.Set("Cookie", cookie)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return decodeContainer(raw), err
}

func (g *preloginRig) fossil(dir string, args ...string) (string, error) {
	cmd := exec.Command("fossil", args...)
	cmd.Dir = dir
	home := g.t.TempDir()
	cmd.Env = append(os.Environ(), "HOME="+home, "FOSSIL_HOME="+home, "FOSSIL_USER=tester")
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// checkout clones the store with fossil, opens it, and commits one
// file, returning the checkout ready to push.
func (g *preloginRig) checkout(msg string) string {
	g.t.Helper()
	work := g.t.TempDir()
	repo := filepath.Join(work, "c.fossil")
	if out, err := g.fossil(work, "clone", g.front, repo); err != nil {
		g.t.Fatalf("fossil clone: %v\n%s", err, out)
	}
	co := filepath.Join(work, "co")
	os.MkdirAll(co, 0o755)
	for _, args := range [][]string{{"settings", "autosync", "off", "-R", repo}, {"open", repo, "--nosync"}} {
		if out, err := g.fossil(co, args...); err != nil {
			g.t.Fatalf("fossil %v: %v\n%s", args, err, out)
		}
	}
	if err := os.WriteFile(filepath.Join(co, "NEW.md"), []byte(msg+"\n"), 0o644); err != nil {
		g.t.Fatal(err)
	}
	g.fossil(co, "add", "NEW.md")
	if out, err := g.fossil(co, "commit", "-m", msg, "--no-warnings"); err != nil {
		g.t.Fatalf("fossil commit: %v\n%s", err, out)
	}
	return co
}

func (g *preloginRig) as(base, user, pw string) string {
	u, _ := url.Parse(base)
	u.User = url.UserPassword(user, pw)
	return u.String()
}

// stored reports whether the store holds the artifact with this hash.
func (g *preloginRig) stored(hash string) bool {
	cmd := exec.Command("zit", "artifact", hash)
	cmd.Dir = g.origin
	return cmd.Run() == nil
}

func (g *preloginRig) landed(msg string) bool {
	return strings.Contains(zrun(g.t, g.origin, "timeline", "-n", "10"), msg)
}

// forged is a file card carrying content nobody signed, and its hash.
func forged(content string) (card, hash string) {
	sum := sha3.Sum256([]byte(content))
	hash = hex.EncodeToString(sum[:])
	return "file " + hash + " " + strconv.Itoa(len(content)) + "\n" + content, hash
}

// spliceBeforeLogin puts cards in front of a body's login card.
func spliceBeforeLogin(body []byte, cards string) []byte {
	i := bytes.Index(body, []byte("login "))
	if i < 0 {
		return body
	}
	return append(append(append([]byte{}, body[:i]...), cards...), body[i:]...)
}

// A real fossil push by dan, who may write, with a file card spliced in
// front of its login card: refused, and nothing it carried is stored.
// The same push without the splice lands.
func TestAFileCardBeforeTheLoginIsNotTheLogins(t *testing.T) {
	g := newPreloginRig(t)
	co := g.checkout("dan was here")
	card, hash := forged("not dan's\n")
	g.setRewrite(func(body []byte) []byte {
		if bytes.Contains(body, []byte("\npush ")) {
			return spliceBeforeLogin(body, card)
		}
		return body
	})
	out, _ := g.fossil(co, "push", g.as(g.proxy, "dan", "hunter2"), "--once")
	if g.stored(hash) || g.landed("dan was here") || !strings.Contains(out, "login card") {
		t.Errorf("FAIL: a file before the login: stored %v, landed %v\n%s", g.stored(hash), g.landed("dan was here"), out)
	} else {
		t.Log("ok: a file card before a writer's login is refused and nothing it carried is stored")
	}
	g.setRewrite(nil)
	if out, err := g.fossil(co, "push", g.as(g.front, "dan", "hunter2"), "--once"); err != nil || !g.landed("dan was here") {
		t.Errorf("FAIL: the clean push: %v\n%s", err, out)
	} else {
		t.Log("ok: the same push without the splice lands")
	}
}

// A signed pull, captured and replayed with a push card and a file card
// in front of its login: refused, nothing stored.
func TestAReplayedPullCannotBecomeAPush(t *testing.T) {
	g := newPreloginRig(t)
	work := t.TempDir()
	repo := filepath.Join(work, "c.fossil")
	if out, err := g.fossil(work, "clone", g.front, repo); err != nil {
		t.Fatalf("fossil clone: %v\n%s", err, out)
	}
	g.fossil(work, "pull", g.as(g.proxy, "dan", "hunter2"), "-R", repo, "--once")
	var captured []byte
	g.mu.Lock()
	seen := g.seen
	g.mu.Unlock()
	for _, b := range seen {
		if bytes.Contains(b, []byte("login dan ")) && bytes.Contains(b, []byte("\npull ")) {
			captured = b
		}
	}
	if captured == nil {
		t.Fatalf("FAIL: no signed pull was captured from %d requests", len(seen))
	}
	card, hash := forged("replayed\n")
	reply, err := postSync(g.front, spliceBeforeLogin(captured, "push "+g.code+" "+g.code+"\n"+card), "")
	if err != nil {
		t.Fatal(err)
	}
	if g.stored(hash) || !bytes.HasPrefix(reply, []byte("error ")) {
		t.Errorf("FAIL: a replayed pull carried a push: stored %v reply %q", g.stored(hash), reply)
	} else {
		t.Log("ok: a captured pull with a push in front of its login is refused")
	}
}

// The x-f-l-c cookie signs the whole body, so with it every card is the
// login's and a push carrying a file is accepted as before.
func TestACookieLoginSignsEveryCard(t *testing.T) {
	g := newPreloginRig(t)
	card, hash := forged("signed by the cookie\n")
	body := []byte("pragma client-version 22800 20260101 000000\npush " + g.code + " " + g.code + "\n" + card)
	nonce := sha1.Sum(body)
	n := hex.EncodeToString(nonce[:])
	sig := sha1.Sum([]byte(n + SharedSecret(g.code, "dan", "hunter2")))
	reply, err := postSync(g.front, body, "x-f-l-c=login+dan+"+n+"+"+hex.EncodeToString(sig[:]))
	if err != nil {
		t.Fatal(err)
	}
	if !g.stored(hash) || bytes.Contains(reply, []byte("error ")) {
		t.Errorf("FAIL: a cookie-signed push: stored %v reply %q", g.stored(hash), reply)
	} else {
		t.Log("ok: a cookie login covers the whole body and its push lands")
	}
}