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": ""}`) } 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:" or "console:". 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 : 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 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) }