bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
package main
|
||||
|
||||
// writes.go — which ops READ this machine and which ones CHANGE it, and the
|
||||
// gate every change on an anchored box runs.
|
||||
//
|
||||
// Host-authored ops, the box's side: the anchor host keeps
|
||||
// unconstrained READ of the machine it governs, and every op that writes or
|
||||
// executes here runs only with a live approval THIS box verified — given from
|
||||
// the person's own device or at this console — bounded by expiry and uses.
|
||||
// Before this the anchor pairing passed the whole ceiling (capsAllowIn), and
|
||||
// on a fleet of machines anchored to one host, that host's word was the only
|
||||
// check on every write.
|
||||
//
|
||||
// The read set is enumerated; everything else writes. A new op is a write until
|
||||
// someone says otherwise, because the failure that matters is a write that was
|
||||
// never classified slipping through as a read.
|
||||
//
|
||||
// LOCAL (unanchored) boxes gate nothing here: the pairing is the authorization
|
||||
// on a person's own machine, as it always was. A factor enrolled there still
|
||||
// bites where it did before (stepupGate).
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// opWrites reports whether `op` changes this machine — writes a file, runs a
|
||||
// program, moves data in, swaps the binary, kills a process — as opposed to
|
||||
// reading it.
|
||||
func opWrites(op string) bool {
|
||||
if strings.HasPrefix(op, "observe:") || strings.HasPrefix(op, "computer:") {
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case "uptime", mgmtAuditFetchOp,
|
||||
"dcn_list", "dcn_stat", "dcn_read_range", "dcn_search", "dcn_catalog",
|
||||
// backup_list creates nothing: backupRootReady (backupstate.go) makes
|
||||
// the backup folder only for an op this function calls a write.
|
||||
"ctx_get", "backup_list",
|
||||
"model:list",
|
||||
"pairing_list",
|
||||
"stepup_request", "stepup_status",
|
||||
"device_request", "device_status",
|
||||
"op_list", "op_run_status", "op_plan_status":
|
||||
return false
|
||||
// egress and inference USE this machine — its connection, its loaded
|
||||
// models — and change nothing on it: no file, no process, no binary.
|
||||
// That holds only because neither can be turned inward: on an anchored
|
||||
// box the egress lane refuses loopback, link-local and private
|
||||
// destinations (egress.go egressInternalRefusal), so it reaches the
|
||||
// public internet and nothing on this machine or its network; and the
|
||||
// store-mutating half of the ollama forward is refused for every pairing
|
||||
// (inferencefilter.go). Neither takes a per-run approval.
|
||||
case "egress", "inference":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// opTightens reports whether a WRITE only REMOVES reach — a revoke pushed to
|
||||
// the box. Never held back: not by the quarantine, not for want of an
|
||||
// approval. A lost device must not keep a revoked grant alive.
|
||||
func opTightens(op string) bool {
|
||||
return op == "dcn_grant_drop" || op == "pairing_grant_drop" || op == "service_grant_drop" ||
|
||||
op == "coder_grant_drop"
|
||||
}
|
||||
|
||||
// ownApproval reports whether a WRITE op already carries its own proof of the
|
||||
// person's approval, verified elsewhere on this box, so the generic per-run
|
||||
// approval below would be a second ask for the same act:
|
||||
// - dcn_grant_sync / dcn_plan_apply carry terms.Approval (dcn.go, dcn_plan.go),
|
||||
// and so does service_grant_sync (service_reach.go)
|
||||
// - model:pull carries req.Approval (modelswap.go)
|
||||
// - pairing_* records are signed by the pinned anchor key (anchor.go): the
|
||||
// person's own governance of who may pair, not a Host's reach.
|
||||
// - dcn_grant_drop and pairing_grant_drop only REMOVE reach; a tighten has
|
||||
// never needed an approval on this box and gating one would let a lost
|
||||
// device keep a revoked grant alive.
|
||||
// - backup_fetch, ctx_put and ctx_del are STANDING WORK under a store the
|
||||
// person configured AT THIS MACHINE (Config.BackupRoot / ContextRoot):
|
||||
// the backup ship runs unattended every night, and the Host's context
|
||||
// store writes blobs into its sandboxed root as it works. Deterministic
|
||||
// standing work takes no approval. The root the person set
|
||||
// here is their standing approval; the op cannot leave it (cleanRel).
|
||||
// ctx_extract is NOT among them — it EXECUTES an extractor over a
|
||||
// document, and is approved per run.
|
||||
// - backup_push is the same standing work in the other direction: it
|
||||
// sends a backup file from that root (ciphertext the host wrote) to a
|
||||
// signed upload URL, and changes nothing here.
|
||||
// - backup_reseal files a request and changes nothing here; the act it
|
||||
// asks for runs only when the person approves it at this machine with
|
||||
// `backup approve` (backupreseal.go), on every box, anchored or not.
|
||||
// - op_sync / op_dry_run / op_apply / op_grant_run carry SIGNED TERMS
|
||||
// (ops.go): an op_apply spends the person's approval inside its terms; a
|
||||
// grant is installed under one at sync; a dry run and a grant run are
|
||||
// the sandbox and the held grant doing what the person already approved.
|
||||
// op_plan_line / op_plan_undo carry the plan the person's device signed
|
||||
// (ops_plan.go), and spend its line.
|
||||
func ownApproval(op string) bool {
|
||||
switch op {
|
||||
case "dcn_grant_sync", "dcn_plan_apply", "model:pull", "service_grant_sync", "coder_grant_sync",
|
||||
"backup_fetch", "backup_push", "backup_reseal", "ctx_put", "ctx_del",
|
||||
"op_sync", "op_dry_run", "op_apply", "op_grant_run", "op_plan_line", "op_plan_undo":
|
||||
return true
|
||||
}
|
||||
return opTightens(op) || strings.HasPrefix(op, "pairing_")
|
||||
}
|
||||
|
||||
// argsDigest is the args as the approval binds them: the sha256 of every
|
||||
// argument framed as "<byte length>:<bytes>\n", in order. NOT a JSON encoding
|
||||
// — Go's json.Marshal HTML-escapes <, > and & and escapes U+2028/9 where
|
||||
// Python's json.dumps does neither, and the pod derives this same digest
|
||||
// (stepup.go's doctrine: never JSON canonicalization across two languages).
|
||||
// The length prefix keeps "a","b" apart from "a\nb". An approval for one
|
||||
// argument list cannot run with another.
|
||||
func argsDigest(args []string) string {
|
||||
h := sha256.New()
|
||||
for _, a := range args {
|
||||
h.Write([]byte(strconv.Itoa(len(a)) + ":"))
|
||||
h.Write([]byte(a))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// mgmtOpPurpose is what the person approves for one run of a write op: this
|
||||
// op, on this machine, with exactly these arguments. A plain |-joined string,
|
||||
// derived the same way on both sides (stepup.go's doctrine: never JSON
|
||||
// canonicalization across two languages).
|
||||
func mgmtOpPurpose(op, server string, args []string) string {
|
||||
return "mgmt_op|" + op + "|" + server + "|" + argsDigest(approvalArgs(op, args))
|
||||
}
|
||||
|
||||
// approvalArgs is what the approval for one op BINDS — the arguments as sent,
|
||||
// except where an argument carries something that cannot be bound: the
|
||||
// ctx_extract job carries either the document's bytes (inline) or a
|
||||
// single-use pull ticket minted per call, and a card that showed either
|
||||
// would show nothing the person can read and bind nothing stable across a
|
||||
// re-ask. For it the approval binds [tool, mime, doc, bytes] — `doc` is the
|
||||
// host's stable name for the document (the pod sends "ctx:<id>"), and `bytes`
|
||||
// is the sha256 of the inline document when it rides the frame, so a label
|
||||
// cannot be approved for one document and used for another. On the PULL
|
||||
// path the bytes are not in hand and `bytes` is "": the approval binds the
|
||||
// label alone there, and the label is the pod's word. The pull carries no
|
||||
// digest by design (the document never left the user's own stores, so
|
||||
// nothing this side has hashed it; the host's extract route says
|
||||
// why) — the box checks the declared size, not the content. That is the
|
||||
// one place a label can be approved for one document and pulled for
|
||||
// another, and it is written here rather than papered over. The pod
|
||||
// derives the same (nac_approval.approval_args).
|
||||
func approvalArgs(op string, args []string) []string {
|
||||
if op != "ctx_extract" || len(args) != 1 {
|
||||
return args
|
||||
}
|
||||
var job struct {
|
||||
Tool, Mime, Doc, B64, Ticket string
|
||||
}
|
||||
if json.Unmarshal([]byte(args[0]), &job) != nil {
|
||||
return args
|
||||
}
|
||||
bytesDigest := ""
|
||||
if job.B64 != "" {
|
||||
sum := sha256.Sum256([]byte(job.B64))
|
||||
bytesDigest = hex.EncodeToString(sum[:])
|
||||
}
|
||||
doc := job.Doc
|
||||
if doc == "" {
|
||||
doc = "ticket:" + job.Ticket
|
||||
}
|
||||
return []string{job.Tool, job.Mime, doc, bytesDigest}
|
||||
}
|
||||
|
||||
// writeGate is the one decision for a management job on this box. Returns
|
||||
// (approval identity, refusal): a non-empty identity means an approval was
|
||||
// verified AND SPENT (consumeApproval) for this run; a non-empty refusal is
|
||||
// sent as-is.
|
||||
//
|
||||
// Order matters and each step is deny-only or pass-through:
|
||||
// 1. an unanchored box gates nothing here (see the file header);
|
||||
// 2. a read is never gated — the anchor keeps unconstrained read;
|
||||
// 3. a tighten (a revoke) is never held back, by anything;
|
||||
// 4. a quarantined pairing's other writes are refused before anything is
|
||||
// checked, so a pairing being held cannot spend approvals or probe;
|
||||
// 5. an op carrying its own approval is checked where that approval lives;
|
||||
// 6. everything else spends an approval for exactly this op/machine/args.
|
||||
func writeGate(self, dcnDir string, job mgmtJob) (string, string) {
|
||||
if currentAnchor() == nil || !opWrites(job.Op) || opTightens(job.Op) {
|
||||
return "", ""
|
||||
}
|
||||
if until := quarantinedUntil(self, time.Now()); !until.IsZero() {
|
||||
return "", quarantineRefusal(until)
|
||||
}
|
||||
if ownApproval(job.Op) {
|
||||
return "", ""
|
||||
}
|
||||
hn, _ := os.Hostname()
|
||||
id, err := boxConsume(dcnDir, job.Approval, mgmtOpPurpose(job.Op, hn, job.Args))
|
||||
if err != nil {
|
||||
return "", "refused: op '" + job.Op + "' changes this machine and " + err.Error()
|
||||
}
|
||||
return id, ""
|
||||
}
|
||||
|
||||
// ── the box-wide directives on the heartbeat ────────────────────────────────
|
||||
|
||||
// The three host-directed actions that ride the heartbeat rather than the
|
||||
// management lane (update.go, contain.go, mirror.go). Each is a write on the
|
||||
// machine and takes the same approval a management write does, carried on
|
||||
// the heartbeat response beside the directive:
|
||||
//
|
||||
// {"update": {...}, "approvals": {"self-update": "<approval>"}}
|
||||
//
|
||||
// The purpose binds the directive's own arguments (the target version; the
|
||||
// pids; the slot and image), so an approval for one roll cannot fund another.
|
||||
// One exception (0.44.0): a self-update to a NEWER version needs no approval,
|
||||
// because the release signature is the approval for it (maybeSelfUpdate).
|
||||
const (
|
||||
directiveSelfUpdate = "self-update"
|
||||
directiveContain = "contain"
|
||||
directiveMirror = "mirror"
|
||||
// localFamilyDirectives is the switchable family the person at the machine
|
||||
// turns the three off with (localdeny.go). Not a caps family — no grant can
|
||||
// name it — so it lives beside allCaps rather than in it.
|
||||
localFamilyDirectives = "directives"
|
||||
)
|
||||
|
||||
func isDirectiveOp(op string) bool {
|
||||
return op == directiveSelfUpdate || op == directiveContain || op == directiveMirror
|
||||
}
|
||||
|
||||
// directiveApproval reads the approval the host attached for one directive.
|
||||
func directiveApproval(heartbeatResponse []byte, what string) string {
|
||||
var hb struct {
|
||||
Approvals map[string]string `json:"approvals"`
|
||||
}
|
||||
if json.Unmarshal(heartbeatResponse, &hb) != nil {
|
||||
return ""
|
||||
}
|
||||
return hb.Approvals[what]
|
||||
}
|
||||
|
||||
// directiveGate is anchorOnly, then the local switch, then the write gate,
|
||||
// for one directive. Returns the refusal, or "" to proceed.
|
||||
//
|
||||
// A directive is REPEATED: the host sends it on every beat until the box
|
||||
// reports the result, and for a mirror for as long as the mirror stands. The
|
||||
// approval is burned the first time the directive passes, and the act it
|
||||
// covered is remembered (directiveLedger) so the same directive on the next
|
||||
// beat — the same what and the same args — proceeds without a second tap:
|
||||
// the person approved THIS act, and the host re-sending it is not a new one.
|
||||
// contain and self-update are remembered for directiveRepeatWindow (a kill
|
||||
// the box could not confirm on the first beat, a swap whose manifest fetch
|
||||
// failed); a mirror placement is remembered until it is disabled or changes.
|
||||
func directiveGate(self, dcnDir, what string, args []string, approval string) string {
|
||||
return directiveGateFor(self, dcnDir, what, args, approval, "")
|
||||
}
|
||||
|
||||
// directiveGateFor is directiveGate with a FINGERPRINT of the thing the
|
||||
// directive acts on, remembered beside the act: a contain remembers the
|
||||
// signature the agent wore when the person approved it, so a pid handed to
|
||||
// a new process inside the repeat window is a new act, not the remembered
|
||||
// one. "" for a directive whose arguments already name the thing (a version,
|
||||
// a finding's fingerprint, a mirror image).
|
||||
func directiveGateFor(self, dcnDir, what string, args []string, approval, fingerprint string) string {
|
||||
if why := directiveWalls(self, what); why != "" {
|
||||
return why
|
||||
}
|
||||
if currentAnchor() == nil {
|
||||
return ""
|
||||
}
|
||||
digest := argsDigest(args)
|
||||
if directiveApproved(what, digest, fingerprint, time.Now()) {
|
||||
return ""
|
||||
}
|
||||
hn, _ := os.Hostname()
|
||||
if _, err := boxConsume(dcnDir, approval, mgmtOpPurpose(what, hn, args)); err != nil {
|
||||
return "refused: " + what + " changes this machine and " + err.Error()
|
||||
}
|
||||
standing := what == directiveMirror
|
||||
rememberDirective(what, digest, fingerprint, standing, time.Now())
|
||||
return ""
|
||||
}
|
||||
|
||||
// directiveWalls is what every directive passes, with an approval or without
|
||||
// one: anchorOnly (which also refuses on a networked box that is not
|
||||
// anchored), then the person's local switch. Returns the refusal, or "".
|
||||
func directiveWalls(self, what string) string {
|
||||
if why := anchorOnly(self, what); why != "" {
|
||||
return why
|
||||
}
|
||||
if localDenied(self, localFamilyDirectives) {
|
||||
return localDenyRefusal(localFamilyDirectives)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── the directive ledger ─────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
directiveRepeatWindow = 10 * time.Minute
|
||||
directiveLedgerFile = "directive_approvals.json"
|
||||
)
|
||||
|
||||
var directiveLedgerMu sync.Mutex
|
||||
|
||||
// directiveLedger: "<what>|<args digest>|<fingerprint>" -> RFC3339 expiry,
|
||||
// or "" for a standing placement (a mirror), which forgetDirective clears.
|
||||
type directiveLedger map[string]string
|
||||
|
||||
func ledgerKey(what, digest, fingerprint string) string {
|
||||
return what + "|" + digest + "|" + fingerprint
|
||||
}
|
||||
|
||||
func directiveLedgerPath() (string, error) {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(d, directiveLedgerFile), nil
|
||||
}
|
||||
|
||||
func directiveLedgerLoad() directiveLedger {
|
||||
out := directiveLedger{}
|
||||
p, err := directiveLedgerPath()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
if raw, err := os.ReadFile(p); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func directiveLedgerSave(l directiveLedger) {
|
||||
p, err := directiveLedgerPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw, _ := json.MarshalIndent(l, "", " ")
|
||||
_ = atomicWrite(p, raw)
|
||||
}
|
||||
|
||||
func directiveApproved(what, digest, fingerprint string, now time.Time) bool {
|
||||
directiveLedgerMu.Lock()
|
||||
defer directiveLedgerMu.Unlock()
|
||||
exp, ok := directiveLedgerLoad()[ledgerKey(what, digest, fingerprint)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if exp == "" {
|
||||
return true
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, exp)
|
||||
return err == nil && t.After(now)
|
||||
}
|
||||
|
||||
func rememberDirective(what, digest, fingerprint string, standing bool, now time.Time) {
|
||||
directiveLedgerMu.Lock()
|
||||
defer directiveLedgerMu.Unlock()
|
||||
l := directiveLedgerLoad()
|
||||
// One standing entry per kind: a new mirror placement replaces the old.
|
||||
for k, exp := range l {
|
||||
if strings.HasPrefix(k, what+"|") && (exp == "" || standing) {
|
||||
delete(l, k)
|
||||
continue
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, exp); err == nil && !t.After(now) {
|
||||
delete(l, k)
|
||||
}
|
||||
}
|
||||
if standing {
|
||||
l[ledgerKey(what, digest, fingerprint)] = ""
|
||||
} else {
|
||||
l[ledgerKey(what, digest, fingerprint)] = now.Add(directiveRepeatWindow).UTC().Format(time.RFC3339)
|
||||
}
|
||||
directiveLedgerSave(l)
|
||||
}
|
||||
|
||||
// forgetDirective drops every remembered act of one kind — a mirror the
|
||||
// host disabled needs a fresh approval to come back.
|
||||
func forgetDirective(what string) {
|
||||
directiveLedgerMu.Lock()
|
||||
defer directiveLedgerMu.Unlock()
|
||||
l := directiveLedgerLoad()
|
||||
for k := range l {
|
||||
if strings.HasPrefix(k, what+"|") {
|
||||
delete(l, k)
|
||||
}
|
||||
}
|
||||
directiveLedgerSave(l)
|
||||
}
|
||||
Reference in New Issue
Block a user