bastion 0.58.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m5s
ci / fuzz (push) Successful in 4m10s

This commit is contained in:
Privatae
2026-10-29 15:54:51 +00:00
parent 081763a675
commit 01889e2a48
37 changed files with 5629 additions and 90 deletions
+516
View File
@@ -0,0 +1,516 @@
package main
// ops_bounds.go — what an op_grant bounds besides the params, and the record
// that lets this machine hold it there (bastion 0.58.0).
//
// The bounds text an op_grant carries (ops.go parseBounds) names params by
// their names, which match ^[a-z]. Two keys beginning with "_" bound the RUN
// rather than a param, so they can never collide with one:
//
// "_window": {"days": ["mon", …, "sun"], "from": "HH:MM", "to": "HH:MM", "tz": "Europe/Berlin"}
// applies run only inside this window of the machine's clock, read
// in tz (an IANA name, "UTC" when absent). days is optional (every
// day when absent). from > to wraps midnight: the window opens on a
// listed day and closes the next morning.
// "_rate": {"per_day": n, "min_interval_s": s}
// at most n applies in any 24 hours, and s seconds between two.
// Either may be absent, not both.
//
// Either key may be null, which bounds nothing. Any other "_" key is refused:
// a bound this machine does not enforce would read on the card as a limit the
// person never got. An older box refuses both as "names no param", so a grant
// carrying them fails closed there.
//
// An apply outside the window or the rate is NOT NOW, not refused: nothing
// runs, no use is spent, and it is no strike toward the quarantine — the Host
// asked for something the person allows, at a time they do not. The answer is
// an 'O' frame {"state": "not_now", …} and exit 75 (sysexits EX_TEMPFAIL).
// Identity and argument failures stay refusals, as before.
//
// THE RECORD. ops.json keeps, for each grant, the times of the applies that
// spent a use (the run log, which the rate is counted from, so it holds across
// a restart), the key that approved it, and a MAC over the record under a key
// this machine derives from its own encryption key. A record edited outside
// the bastion — a run removed, a use given back, the approver changed — no
// longer checks out, and the grant is unusable until the person drops it and
// approves again. A grant installed by a bastion older than this one carries
// neither and is unusable too (none were held when it shipped).
//
// THE APPROVER. The device key that signed the approval, or the factor enrolled
// at this console. A grant whose approver is no longer pinned (the device key
// unpinned, the factor re-enrolled) is dead: unpinning a device drops every
// grant it approved, here and in every pairing.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
// The zone database is in the binary: a window's tz reads the same on a
// machine with no tzdata installed, and a grant is never refused at one
// box and accepted at another for want of a file.
_ "time/tzdata"
"github.com/gorilla/websocket"
)
const (
opBoundWindow = "_window"
opBoundRate = "_rate"
opRateMinInterval = 30 * 24 * 60 * 60 // seconds: the longest a grant lives
// opNotNowExit is the exit an apply that was held to its window or its
// rate ends with (sysexits.h EX_TEMPFAIL: "try again later").
opNotNowExit = 75
opNotNow = "not_now"
)
var (
opHHMMRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
opTZRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$`)
opDays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} // time.Weekday order
// opNow is the machine's clock as the run gate reads it (a seam for the
// tests, which move it).
opNow = time.Now
)
type opWindow struct {
Days []string `json:"days,omitempty"`
From string `json:"from"`
To string `json:"to"`
TZ string `json:"tz,omitempty"`
loc *time.Location
from, to int // minutes past midnight
day [7]bool
}
type opRate struct {
PerDay *int `json:"per_day,omitempty"`
MinInterval *int `json:"min_interval_s,omitempty"`
}
// opGrantBounds is the whole of what an op_grant's bounds text says.
type opGrantBounds struct {
Params map[string]opBound
Window *opWindow
Rate *opRate
}
func strictJSON(raw []byte, into any) error {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(into); err != nil {
return err
}
if dec.More() {
return errors.New("trailing data")
}
return nil
}
func parseOpWindow(raw json.RawMessage) (*opWindow, error) {
if string(raw) == "null" {
return nil, nil
}
var w opWindow
if err := strictJSON(raw, &w); err != nil {
return nil, errors.New(`bound "_window": {"days": [mon..sun], "from": "HH:MM", "to": "HH:MM", "tz": "<IANA zone>"}`)
}
if !opHHMMRe.MatchString(w.From) || !opHHMMRe.MatchString(w.To) {
return nil, errors.New(`bound "_window": from and to are 24-hour "HH:MM"`)
}
if w.From == w.To {
return nil, errors.New(`bound "_window": from and to are the same minute, which is no window`)
}
hm := func(s string) int { h, _ := strconv.Atoi(s[:2]); m, _ := strconv.Atoi(s[3:]); return h*60 + m }
w.from, w.to = hm(w.From), hm(w.To)
if w.Days != nil {
if len(w.Days) == 0 || len(w.Days) > 7 {
return nil, errors.New(`bound "_window": days names 1..7 days, or is left out for every day`)
}
for _, d := range w.Days {
i := opDayIndex(d)
if i < 0 {
return nil, fmt.Errorf(`bound "_window": %q is not a day (mon, tue, wed, thu, fri, sat, sun)`, d)
}
if w.day[i] {
return nil, fmt.Errorf(`bound "_window": %q is named twice`, d)
}
w.day[i] = true
}
} else {
for i := range w.day {
w.day[i] = true
}
}
tz := w.TZ
if tz == "" {
tz = "UTC"
}
if !opTZRe.MatchString(tz) || strings.EqualFold(tz, "local") {
return nil, fmt.Errorf(`bound "_window": tz %q is not an IANA zone name`, w.TZ)
}
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, fmt.Errorf(`bound "_window": tz %q is not a zone this machine knows`, w.TZ)
}
w.loc = loc
return &w, nil
}
func opDayIndex(d string) int {
for i, s := range opDays {
if s == d {
return i
}
}
return -1
}
func parseOpRate(raw json.RawMessage) (*opRate, error) {
if string(raw) == "null" {
return nil, nil
}
var r opRate
if err := strictJSON(raw, &r); err != nil {
return nil, errors.New(`bound "_rate": {"per_day": n, "min_interval_s": s}`)
}
if r.PerDay == nil && r.MinInterval == nil {
return nil, errors.New(`bound "_rate": names per_day, min_interval_s or both`)
}
if r.PerDay != nil && (*r.PerDay < 1 || *r.PerDay > opGrantUsesMax) {
return nil, fmt.Errorf(`bound "_rate": per_day is 1..%d`, opGrantUsesMax)
}
if r.MinInterval != nil && (*r.MinInterval < 1 || *r.MinInterval > opRateMinInterval) {
return nil, fmt.Errorf(`bound "_rate": min_interval_s is 1..%d`, opRateMinInterval)
}
return &r, nil
}
// parseGrantBounds reads an op_grant's bounds text: the params (parseBounds'
// rules, each inside the schema) and the run bounds above.
func parseGrantBounds(m *opManifest, raw string) (*opGrantBounds, error) {
if len(raw) > opParamsMaxBytes {
return nil, errors.New("bounds too large")
}
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil || top == nil {
return nil, errors.New("bounds are not a JSON object of {param: {min, max | values | prefix | fixed}, _window, _rate}")
}
out := &opGrantBounds{}
params := map[string]json.RawMessage{}
for k, v := range top {
var err error
switch {
case k == opBoundWindow:
out.Window, err = parseOpWindow(v)
case k == opBoundRate:
out.Rate, err = parseOpRate(v)
case strings.HasPrefix(k, "_"):
err = fmt.Errorf("bound %q is not one this machine enforces (%s, %s)", k, opBoundWindow, opBoundRate)
default:
params[k] = v
}
if err != nil {
return nil, err
}
}
prest, _ := json.Marshal(params)
p, err := parseParamBounds(m, string(prest))
if err != nil {
return nil, err
}
out.Params = p
return out, nil
}
// inside: is t inside the window? A window that wraps midnight belongs to the
// day it opens on.
func (w *opWindow) inside(t time.Time) bool {
l := t.In(w.loc)
mins := l.Hour()*60 + l.Minute()
today := int(l.Weekday())
if w.from < w.to {
return w.day[today] && mins >= w.from && mins < w.to
}
yesterday := (today + 6) % 7
return (w.day[today] && mins >= w.from) || (w.day[yesterday] && mins < w.to)
}
// next is when the window next opens after t (zero when it never does, which
// a parsed window cannot be).
func (w *opWindow) next(t time.Time) time.Time {
l := t.In(w.loc)
for i := 0; i <= 8; i++ {
d := l.AddDate(0, 0, i)
open := time.Date(d.Year(), d.Month(), d.Day(), w.from/60, w.from%60, 0, 0, w.loc)
if open.After(t) && w.day[int(open.Weekday())] {
return open
}
}
return time.Time{}
}
func (w *opWindow) words() string {
days := "every day"
if len(w.Days) > 0 && len(w.Days) < 7 {
days = strings.Join(w.Days, ",")
}
return fmt.Sprintf("%s %s-%s %s", days, w.From, w.To, w.loc.String())
}
// opNotNowWhy is the run gate on window and rate: "" when an apply may run at
// now, else why not, which bound held it, and when it next could.
func opNotNowWhy(b *opGrantBounds, runs []time.Time, now time.Time) (why, bound string, next time.Time) {
if b.Window != nil && !b.Window.inside(now) {
return "outside the approved window (" + b.Window.words() + ")", opBoundWindow, b.Window.next(now)
}
if b.Rate == nil || len(runs) == 0 {
return "", "", time.Time{}
}
last := runs[len(runs)-1]
if b.Rate.MinInterval != nil {
gap := time.Duration(*b.Rate.MinInterval) * time.Second
if now.Sub(last) < gap {
return fmt.Sprintf("the last run was %s ago; the approval keeps %s between runs",
now.Sub(last).Round(time.Second), gap), opBoundRate, last.Add(gap)
}
}
if b.Rate.PerDay != nil {
var day []time.Time
for _, r := range runs {
if now.Sub(r) < 24*time.Hour {
day = append(day, r)
}
}
if len(day) >= *b.Rate.PerDay {
return fmt.Sprintf("%d run(s) in the last 24 hours; the approval allows %d", len(day), *b.Rate.PerDay),
opBoundRate, day[len(day)-*b.Rate.PerDay].Add(24 * time.Hour)
}
}
return "", "", time.Time{}
}
// sendNotNow answers an apply that was held to its window or rate: recorded,
// never a strike, nothing spent.
func sendNotNow(ws *websocket.Conn, dir string, t *opTerms, why, bound string, next time.Time) {
out := map[string]any{"state": opNotNow, "grant": t.Nonce, "op": t.Op, "why": why, "bound": bound}
if !next.IsZero() {
out["next"] = next.UTC().Format(time.RFC3339)
}
dcnAudit(dir, "op_grant_run", 0, t.Op+"@"+t.SHA256[:12], "not now "+t.Nonce[:8]+": "+truncate(why, mgmtAuditWhyMax))
b, _ := json.Marshal(out)
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, opNotNowExit)
}
// ── the record ──────────────────────────────────────────────────────────────
// opGrantMACKey is this machine's key for its grant records, derived from its
// encryption key (which lives in the machine's record, masked from every op,
// and is never silently replaced).
func opGrantMACKey() ([]byte, error) {
_, priv, err := opEncKey()
if err != nil {
return nil, err
}
h := sha256.New()
h.Write([]byte("privatae-bastion op-grant record v1\x00"))
h.Write(priv[:])
return h.Sum(nil), nil
}
// opGrantRecMAC covers every field of the record, the grant's nonce with it,
// each framed by its length (argsDigest's framing).
func opGrantRecMAC(nonce string, rec *opGrantRec) (string, error) {
key, err := opGrantMACKey()
if err != nil {
return "", err
}
parts := []string{"v1", nonce, rec.TermsCanonical, rec.Signature, rec.InstalledAt,
strconv.Itoa(rec.UsesLeft), rec.Dropped, rec.Approver, strconv.Itoa(len(rec.Runs))}
parts = append(parts, rec.Runs...)
m := hmac.New(sha256.New, key)
m.Write([]byte(argsDigest(parts)))
return hex.EncodeToString(m.Sum(nil)), nil
}
func opGrantRecSeal(nonce string, rec *opGrantRec) error {
mac, err := opGrantRecMAC(nonce, rec)
if err != nil {
return fmt.Errorf("this machine cannot seal the grant's record: %v", err)
}
rec.MAC = mac
return nil
}
const opGrantTampered = "the record of this grant on this machine does not check out (changed outside the bastion); " +
"it is held unusable — drop it and approve it again"
// opGrantRecCheck: the record is this machine's own (its MAC), the run log
// reads in order and none of it is in the future, and the uses left are the
// uses granted less the runs logged. Returns the run times.
func opGrantRecCheck(nonce string, rec *opGrantRec, t *opTerms, now time.Time) ([]time.Time, error) {
if rec.MAC == "" || rec.Approver == "" {
return nil, errors.New("this grant was installed before this machine kept who approved it and what ran under it; approve it again")
}
want, err := opGrantRecMAC(nonce, rec)
if err != nil {
return nil, err
}
if !hmac.Equal([]byte(want), []byte(rec.MAC)) {
return nil, errors.New(opGrantTampered)
}
runs := make([]time.Time, 0, len(rec.Runs))
for _, s := range rec.Runs {
r, err := time.Parse(time.RFC3339Nano, s)
if err != nil || r.After(now.Add(deviceClockSkew)) || (len(runs) > 0 && r.Before(runs[len(runs)-1])) {
return nil, errors.New(opGrantTampered)
}
runs = append(runs, r)
}
if t != nil && rec.UsesLeft != t.Uses-len(runs) {
return nil, errors.New(opGrantTampered)
}
return runs, nil
}
// ── the approver ────────────────────────────────────────────────────────────
// stepupFactorFingerprint names the factor enrolled at this console for a
// pairing, without its secret: a re-enrolled factor is another one. "" when
// none is enrolled.
func stepupFactorFingerprint(dir string) string {
f, err := stepupLoad(dir)
if err != nil || f.SecretB32 == "" || f.EnrolledAt == "" {
return ""
}
sum := sha256.Sum256([]byte("privatae-bastion stepup factor v1\x00" + f.SecretB32 + "\x00" + f.EnrolledAt))
return hex.EncodeToString(sum[:16])
}
// consumeApprovalKeyed is consumeApproval that also names the key that gave
// the approval: "device:<public key>" or "console:<factor fingerprint>".
func consumeApprovalKeyed(dir, approval, purpose string) (id, key string, err error) {
if isDeviceApproval(approval) {
var a deviceApproval
_ = json.Unmarshal([]byte(strings.TrimSpace(approval)), &a)
if id, err = consumeApproval(dir, approval, purpose); err != nil {
return "", "", err
}
return id, "device:" + a.Key, nil
}
fp := stepupFactorFingerprint(dir)
if id, err = consumeApproval(dir, approval, purpose); err != nil {
return "", "", err
}
if fp == "" {
return "", "", errors.New("the approval came from this console with no factor enrolled to keep it under")
}
return id, "console:" + fp, nil
}
// opApproverLive: is the key that approved a grant still pinned here?
func opApproverLive(dir, approver string) bool {
kind, key, _ := strings.Cut(approver, ":")
switch kind {
case "device":
for _, k := range deviceKeys() {
if k == key && key != "" {
return true
}
}
case "console":
return key != "" && key == stepupFactorFingerprint(dir)
}
return false
}
func opApproverWords(approver string) string {
kind, key, _ := strings.Cut(approver, ":")
if kind == "device" {
return "approval device " + truncate(key, 12)
}
return "this console's factor " + truncate(key, 12)
}
// ── taking grants back ──────────────────────────────────────────────────────
// opsDropWhere drops every held grant in one pairing that match says to,
// sealing each record again. Returns the nonces dropped.
func opsDropWhere(dcnDir, why string, match func(nonce string, rec *opGrantRec) bool) ([]string, error) {
opsMu.Lock()
defer opsMu.Unlock()
f := opsLoad(dcnDir)
var dropped []string
for nonce, rec := range f.Grants {
if rec.Dropped != "" || !match(nonce, &rec) {
continue
}
rec.Dropped = opNow().UTC().Format(time.RFC3339) + " " + why
rec.UsesLeft = 0
// A record that can no longer be sealed is still dropped: the drop is
// what makes it unusable, and an unsealed record is unusable anyway.
_ = opGrantRecSeal(nonce, &rec)
f.Grants[nonce] = rec
dropped = append(dropped, nonce)
}
if len(dropped) == 0 {
return nil, nil
}
return dropped, opsSave(dcnDir, f)
}
// opsDropApprovedBy drops, in every pairing, the grants a device key approved
// (the key is being unpinned). Returns how many.
func opsDropApprovedBy(approver string) int {
cfgs, err := loadPairings()
if err != nil {
return 0
}
n := 0
for _, c := range cfgs {
if c == nil || c.dcnDir() == "" {
continue
}
d, _ := opsDropWhere(c.dcnDir(), "its approving key was unpinned", func(_ string, rec *opGrantRec) bool {
return rec.Approver == approver
})
n += len(d)
}
return n
}
// op_grant_drop <grant nonce | all>: the Host (or the app, through it) takes
// a grant back. A tighten (writes.go opTightens): never held by the
// quarantine, never waiting on an approval. A grant not held here is already
// gone, which is what was asked for.
func handleOpGrantDrop(ws *websocket.Conn, dir string, args []string) {
if dir == "" || len(args) < 1 || strings.TrimSpace(args[0]) == "" {
opFail(ws, "op_grant_drop: need <grant> or all")
return
}
which := strings.TrimSpace(args[0])
dropped, err := opsDropWhere(dir, "taken back by the host", func(nonce string, _ *opGrantRec) bool {
return which == "all" || nonce == which
})
if err != nil {
opFail(ws, "op_grant_drop: could not record the drop: "+err.Error())
return
}
if dropped == nil {
dropped = []string{}
}
for _, n := range dropped {
dcnAudit(dir, "op_grant_drop", 0, truncate(n, 8), "dropped by the host")
}
b, _ := json.Marshal(map[string]any{"state": "dropped", "dropped": dropped})
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}