Skip to content
quarry Artifact [b78016ce48]
Login

Artifact [b78016ce48]

Artifact b78016ce489966d43ad4e9ce7b2cfad99721934eb3de1cf94efd91d112697027:


package api

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// Credential pickup (RFC 0030). The asker makes 32 random bytes, the
// pickup key, and binds only its hash, the pickup code, to the request
// it asks for. The forge stores the code, which is public: it is on the
// owner's page and in logs. The key reaches the forge once, in the
// header of the collect call, and is never stored, logged or echoed.
//
// The hash is SHA-256, as the RFC names it (decision 12). The key is 256
// random bits, so the hash needs no salt and no stretching: there is
// nothing to guess against, and a slow hash would only make the route
// dearer to hammer. What keeps it from being an oracle is that the code
// is looked up by equality in an index and then compared in constant
// time, that a key of the wrong shape is hashed all the same, and that
// every way of not holding a request answers the one 404.

// pickupKeyLen is the length of a pickup key in bytes.
const pickupKeyLen = 32

// PickupCodeOf is the public code of a pickup key: the lowercase hex
// SHA-256 of its bytes.
func PickupCodeOf(key []byte) string {
	sum := sha256.Sum256(key)
	return hex.EncodeToString(sum[:])
}

// validPickupCode reports whether s is a pickup code: 64 lowercase hex
// digits. The asker supplies it, so it is checked where it enters.
func validPickupCode(s string) bool {
	if len(s) != 2*sha256.Size {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
			return false
		}
	}
	return true
}

// pickupCtx carries the pickup code an asking call named
// (Quarry-Pickup) from the transport to the Asker.
type pickupCtxKey struct{}

func withPickupCode(ctx context.Context, code string) context.Context {
	if code == "" {
		return ctx
	}
	return context.WithValue(ctx, pickupCtxKey{}, code)
}

func pickupCodeOf(ctx context.Context) string {
	c, _ := ctx.Value(pickupCtxKey{}).(string)
	return c
}

// checkPickupCode refuses a code that is not one, before a request is
// recorded with it.
func checkPickupCode(code string) error {
	if code != "" && !validPickupCode(code) {
		return fmt.Errorf("a pickup code is the lowercase hex SHA-256 of the 32-byte pickup key: %w", ops.ErrInvalid)
	}
	return nil
}

// PickupLimiter is the forge's sign-in limiter as the collect route
// uses it (web.LoginLimiter), for MISSES only: per name and per address,
// five free, then a hold that doubles from thirty seconds. A key that is
// right is never held, since a held address is shared by whoever else
// stands behind it and a key cannot be guessed (256 bits).
type PickupLimiter interface {
	Allowed(user, ip string) (bool, time.Duration)
	Failed(user, ip string)
}

// The plain rates (RFC 0030: "ten a minute" per address). The address
// budget is spent by misses alone; every call, right key or not, spends
// its key's: a poller waiting on an approval may call once in ten
// seconds.
const (
	pickupPerAddress = 10
	pickupPerCode    = 6
	pickupWindow     = time.Minute
)

// pickupRatesCap bounds the limiter's memory: past it, stale entries go,
// and if that is not enough the quarter least recently used goes too, in
// one batch, so a flood of fresh keys cannot make every call scan the
// map.
const pickupRatesCap = 4096

// pickupRates is a sliding window of recent calls per key, in memory:
// the forge is one process, and a restart forgiving a window is fine.
type pickupRates struct {
	mu   sync.Mutex
	hits map[string][]time.Time
}

func (r *pickupRates) size() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return len(r.hits)
}

// allow records a call under key and says whether it is within limit
// calls per window; if not, for how long to wait.
func (r *pickupRates) allow(key string, limit int, now time.Time) (bool, time.Duration) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.hits == nil {
		r.hits = map[string][]time.Time{}
	}
	cut := now.Add(-pickupWindow)
	kept := r.hits[key][:0]
	for _, t := range r.hits[key] {
		if t.After(cut) {
			kept = append(kept, t)
		}
	}
	if len(kept) >= limit {
		r.hits[key] = kept
		return false, kept[0].Add(pickupWindow).Sub(now)
	}
	if _, had := r.hits[key]; !had && len(r.hits) >= pickupRatesCap {
		r.evict(cut)
	}
	r.hits[key] = append(kept, now)
	return true, 0
}

// evict makes room: stale keys first, then the quarter whose last call
// is oldest.
func (r *pickupRates) evict(cut time.Time) {
	type last struct {
		k string
		t time.Time
	}
	var live []last
	for k, v := range r.hits {
		if len(v) == 0 || !v[len(v)-1].After(cut) {
			delete(r.hits, k)
			continue
		}
		live = append(live, last{k, v[len(v)-1]})
	}
	if len(r.hits) < pickupRatesCap {
		return
	}
	sort.Slice(live, func(i, j int) bool { return live[i].t.Before(live[j].t) })
	for _, l := range live[:len(live)/4+1] {
		delete(r.hits, l.k)
	}
}

// PickupCollector is the route that hands a credential to whoever
// holds its pickup key (RFC 0030, Collecting): POST
// /api/v1/pickups/collect for a client that knows the forge, and POST
// /r/{repo}/pickup (also /r/{namespace}/{repo}/pickup) for one that
// knows only a sync URL. It is not an op: it answers four statuses with
// bodies of their own and reads a credential from the Authorization
// header, which an op never sees.
type PickupCollector struct {
	d     Deps
	limit PickupLimiter
	addr  func(*http.Request) string
	now   func() time.Time
	rates pickupRates
}

// NewPickupCollector wires the route. limit may be nil (misses are not
// held); addr is the forge's rule for telling clients apart, and nil
// means the socket's host.
func NewPickupCollector(d Deps, limit PickupLimiter, addr func(*http.Request) string) *PickupCollector {
	return &PickupCollector{d: d, limit: limit, addr: addr, now: time.Now}
}

func (c *PickupCollector) client(req *http.Request) string {
	if c.addr != nil {
		return c.addr(req)
	}
	if h, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
		return h
	}
	return req.RemoteAddr
}

// pickupKeyOf reads the key from the Authorization header, "Pickup
// <base64url>". ok is false for anything else; code is then still the
// hash of what was sent, so a key of the wrong shape costs the same work
// as a right one and is counted against the same address.
func pickupKeyOf(req *http.Request) (code string, ok bool) {
	h := strings.TrimSpace(req.Header.Get("Authorization"))
	scheme, val, _ := strings.Cut(h, " ")
	val = strings.TrimSpace(val)
	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(val, "="))
	if !strings.EqualFold(scheme, "Pickup") || err != nil || len(raw) != pickupKeyLen {
		return PickupCodeOf([]byte(h)), false
	}
	return PickupCodeOf(raw), true
}

type pickupGone struct {
	State string `json:"state"`
	Error string `json:"error"`
}

func pickupJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	// A credential is in some of these bodies: nothing may keep it.
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func pickupBusy(w http.ResponseWriter, wait time.Duration) {
	w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
	pickupJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many attempts; wait and try again"})
}

// pickupNotFound is the one answer for every request that is not the
// key holder's to know about: unknown code, wrong key, a key of the
// wrong shape, whatever repository the path names.
func pickupNotFound(w http.ResponseWriter) {
	pickupJSON(w, http.StatusNotFound, map[string]string{"error": "no such pickup"})
}

func (c *PickupCollector) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// The key is a header, never a query string, which every proxy and
	// access log prints. Refused before anything is read.
	if req.URL.RawQuery != "" {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "this route takes no query string; send the key in the Authorization header"})
		return
	}
	if c.d.SignOffs == nil {
		pickupJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "this forge keeps no requests"})
		return
	}
	code, shaped := pickupKeyOf(req)
	ip := c.client(req)
	now := c.now()
	// The key is looked up first, and nothing is recorded under the
	// presented key unless it is right: equality in the index finds the
	// row, and the comparison that decides is constant-time and runs on a
	// miss too, against the code itself.
	s, found, err := c.d.SignOffs.SignOffByPickup(code)
	if err != nil {
		c.fault(w, "lookup", err)
		return
	}
	stored := s.Pickup
	if !found {
		stored = strings.Repeat("0", len(code))
	}
	same := subtle.ConstantTimeCompare([]byte(stored), []byte(code)) == 1
	if !found || !shaped || !same {
		// A miss spends its address's budget and the hold, both keyed
		// by the address alone.
		if ok, wait := c.rates.allow("a:"+ip, pickupPerAddress, now); !ok {
			pickupBusy(w, wait)
			return
		}
		if c.limit != nil {
			if ok, wait := c.limit.Allowed("pickup:"+ip, ip); !ok {
				pickupBusy(w, wait)
				return
			}
			c.limit.Failed("pickup:"+ip, ip)
		}
		pickupNotFound(w)
		return
	}
	// A right key spends its own budget alone: collectors behind one
	// address, or a neighbour filling its window, cannot starve each other.
	if ok, wait := c.rates.allow("c:"+code, pickupPerCode, now); !ok {
		pickupBusy(w, wait)
		return
	}
	// The key is right from here on: nothing below is a guess. Pending
	// and every closed state are answered from the read alone, with no
	// write.
	state, why := pickupWhy(s, now)
	if state != "ready" {
		c.answer(w, state, why)
		return
	}
	// A sync URL's route consumes nothing for a credential that does
	// not reach it.
	if repo := req.PathValue("repo"); repo != "" && !pickupReaches(s, repo) {
		pickupJSON(w, http.StatusConflict, map[string]string{"error": "this credential does not reach that repository; nothing was collected"})
		return
	}
	// Unsealed into memory before it is taken: a value the forge cannot
	// read is never consumed (the lapse revokes it), and one that is
	// taken is already in hand.
	secret, err := c.d.openSignOffSecret(s.ID, s.Secret)
	if err != nil {
		c.fault(w, "opening the sealed value", err)
		return
	}
	t, err := c.d.SignOffs.TakePickupSecret(code, now)
	if err != nil {
		c.fault(w, "taking", err)
		return
	}
	if !t.Taken {
		// Collected or lapsed between the read and the take.
		state, why := pickupWhy(t.Request, now)
		if t.Lapsed {
			state, why = "lapsed", pickupLapsedWords
		}
		if state == "ready" {
			state, why = "collected", pickupCollectedWords
		}
		c.answer(w, state, why)
		return
	}
	// The value returned is the one the take cleared. The pre-opened
	// copy stands in only if that cannot be opened, which it could be a
	// moment ago.
	if got, err := c.d.openSignOffSecret(t.Request.ID, t.Request.Secret); err == nil {
		secret = got
	} else {
		log.Printf("pickup: request %s: the taken value would not open a second time; returning the one opened before the take", t.Request.ID)
	}
	// Who asked, for whom and for what, beside the value, so a collector
	// can check it got the request it meant to (a code is shown before
	// any ask, and another agent could have bound it first).
	out := map[string]any{
		"request": t.Request.ID, "op": t.Request.Op, "agent": t.Request.Agent,
		"repo": t.Request.Repo, "secret": json.RawMessage(secret),
	}
	if t.Request.State == oltp.SignOffRan {
		// Ran on the agent's own authority: nobody approved it. The
		// person named answers for the agent, and is not an approver.
		out["parked"] = true
		out["answerable"] = t.Request.Owner
	} else {
		out["owner"] = t.Request.Owner
	}
	if t.Request.Result != "" {
		out["result"] = json.RawMessage(t.Request.Result)
	}
	pickupJSON(w, http.StatusOK, out)
}

func (c *PickupCollector) answer(w http.ResponseWriter, state, why string) {
	if state == "pending" {
		pickupJSON(w, http.StatusAccepted, map[string]string{"state": "pending"})
		return
	}
	pickupJSON(w, http.StatusGone, pickupGone{State: state, Error: why})
}

// fault answers a store or box failure without detail, and logs it
// without any value.
func (c *PickupCollector) fault(w http.ResponseWriter, what string, err error) {
	log.Printf("pickup: %s: %v", what, err)
	pickupJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}

const (
	pickupCollectedWords = "this credential was already collected"
	pickupLapsedWords    = "this credential was not collected in time and was revoked"
)

// pickupWhy says what a request is, to the one who holds its key: the
// RFC's states, or "ready" when an approved request holds a secret that
// can be taken now.
func pickupWhy(s SignOffRequest, now time.Time) (state, why string) {
	switch s.State {
	case "pending":
		if s.Requested < now.UTC().Add(-oltp.SignOffWindow).Format(time.RFC3339) {
			return "expired", "nobody answered in time"
		}
		return "pending", ""
	case "denied":
		if s.Err == oltp.SignOffUnansweredWhy {
			return "expired", s.Err
		}
		return "denied", "the request was denied"
	case "failed":
		return "failed", "the request was approved but did not run"
	}
	switch {
	case s.Collected != "":
		return "collected", pickupCollectedWords
	case s.Secret == oltp.SignOffSecretLapsed,
		s.Secret != "" && s.Decided != "" && s.Decided < oltp.SignOffSecretCutoff(now).UTC().Format(time.RFC3339):
		return "lapsed", pickupLapsedWords
	case s.Secret != "":
		return "ready", ""
	case s.Result == "":
		// Approved, and the call is still running.
		return "pending", ""
	}
	return "collected", "this request left no credential to collect"
}

// pickupReaches reports whether the credential a request minted reaches
// repo: the repository the request was about when it had one, else the
// token's repositories in its public answer, empty meaning all.
func pickupReaches(s SignOffRequest, repo string) bool {
	if s.Repo != "" {
		// A repository-scoped call mints a credential for that
		// repository and no other.
		return s.Repo == repo
	}
	var res struct {
		Token *struct {
			Repos []string `json:"repos"`
		} `json:"token"`
	}
	if s.Result == "" || json.Unmarshal([]byte(s.Result), &res) != nil || res.Token == nil {
		return false
	}
	if len(res.Token.Repos) == 0 {
		return true
	}
	for _, r := range res.Token.Repos {
		if r == repo {
			return true
		}
	}
	return false
}

// parkOwner is the person a parked call is recorded for: the one who
// answers for an agent caller, or a person caller themselves.
func (d Deps) parkOwner(caller string) string {
	if p := d.personFor(caller); p != "" {
		return p
	}
	if acct, ok := d.accountNamed(caller); ok && acct.Operator == "" && acct.Disabled == "" {
		return caller
	}
	return ""
}

// pickupFree is asked before a call carrying a pickup code runs. It
// refuses a code that is malformed or held by anyone's request; ownAsk is
// true when the code is held by the caller's own pending ask, which the
// caller is re-sending: that is answered as the ask again, and the call
// is not run, so nothing is minted only to be revoked.
func (d Deps) pickupFree(ctx context.Context, code string) (ownAsk bool, err error) {
	if err := checkPickupCode(code); err != nil {
		return false, err
	}
	if d.SignOffs == nil {
		return false, nil
	}
	s, held, err := d.SignOffs.SignOffByPickup(code)
	if err != nil || !held {
		return false, err
	}
	if s.Agent == ops.User(ctx) && s.State == "pending" {
		return true, nil
	}
	return false, pickupAskError(ErrPickupBound)
}

// parkRun holds the credential of a call that just ran outright with a
// pickup code: an approved request, finished, holding the sealed secret,
// the lapse references and the code, in one transaction. The caller is
// answered with the request and never the secret. If it cannot be held,
// what the call minted is revoked at once, so no credential exists that
// nobody may collect, and the caller is told the call was undone.
func (d Deps) parkRun(ctx context.Context, desc *ops.Descriptor, repo string, in, out any, code string) (any, error) {
	caller := ops.User(ctx)
	refs := lapseRefOf(desc.Name, repo, in, out)
	undo := func(why error) (any, error) {
		revoked := true
		for _, ref := range refs {
			if err := d.revokeLapsed(ref, SignOffRequest{Agent: caller}, "its pickup could not be set up; the call was undone"); err != nil {
				revoked = false
				log.Printf("pickup: ERROR: %s by %s on %q ran, could not be parked (%v), and its %s credential %s could not be revoked: %v",
					desc.Name, caller, repo, why, ref.kind(), ref.Token, err)
			}
		}
		if len(refs) == 0 {
			revoked = false
			log.Printf("pickup: ERROR: %s by %s on %q ran and could not be parked (%v); it has no lapse reference to revoke", desc.Name, caller, repo, why)
		}
		if revoked && errors.Is(why, ErrPickupBound) {
			return nil, fmt.Errorf("that pickup code cannot be used; the call ran and was undone, its credential revoked and nothing kept; make a new key and ask again: %w", ops.ErrInvalid)
		}
		if revoked {
			return nil, fmt.Errorf("the call ran but its credential could not be held for pickup, so it was revoked and nothing was kept; ask again: %w", ops.ErrUnavailable)
		}
		return nil, fmt.Errorf("the call ran but its credential could not be held for pickup, and could not be revoked automatically; tell an administrator: %w", ops.ErrUnavailable)
	}
	owner := d.parkOwner(caller)
	if owner == "" || d.SignOffs == nil {
		return undo(errors.New("no person to record it for"))
	}
	result, secret, err := splitSecret(out)
	if err != nil {
		return undo(err)
	}
	raw, err := json.Marshal(in)
	if err != nil {
		return undo(err)
	}
	var rawRefs string
	if len(refs) > 0 {
		b, _ := json.Marshal(refs)
		rawRefs = string(b)
	}
	summary := desc.Sentence(in)
	if summary == "" {
		summary = describeCall(desc.Name, repo, in)
	}
	repoCode := ""
	if repo != "" {
		if r, err := d.repo(repo); err == nil {
			repoCode = r.Code
		}
	}
	s, err := d.SignOffs.ParkSignOff(SignOffRequest{Agent: caller, Owner: owner, Op: desc.Name, Repo: repo, Code: repoCode,
		Input: string(raw), Summary: summary, Pickup: code}, result, rawRefs,
		func(id string) (string, error) { return d.sealSignOffSecret(id, secret) })
	if err != nil {
		return undo(err)
	}
	// The person who answers for the agent hears once, in their inbox
	// alone (no push, no email): a credential was parked on their
	// agent's own authority.
	if owner != caller {
		d.attend(owner, AttentionEvent{Kind: "signoff.parked", Repo: repo, From: caller, Summary: summary, Link: "/me/requests/" + s.ID})
	}
	return askedOut{SignOff: s}, nil
}