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
}