package main // Anchoring — binding a networked bastion to ONE host that approves who pairs // next: every new pairing on an anchored machine needs the anchor host's // approval. // // What an anchor IS: the owner claim (owner.go) with one more field. The box // key signs "\n\n", and the token that was // redeemed to make it binds the claim to exactly one host — the anchor host. // The owner.go header's warning still holds: this is trusted by THIS box, for // ONE decision (who may pair next), and by nothing and nobody else. A root // intruder can anchor an unanchored box; the defence is to anchor every // machine while its owner is the only one who can, and to make every pairing // state visible on the heartbeat so tampering is at least seen. // // What the anchor host holds: its entity_sign key, which this box PINS here on // first contact (verify_pubkey), the same TOFU the DCN store grants use over // the same mTLS+bearer lane (dcn.go:48). Every pairing grant, revoke and // consent record is verified under that pinned key before it changes a state. // // The rules this file enforces without a host in the loop: // - a NETWORKED box with no anchor serves NOTHING, on every lane. The anchor // is what turns default-deny on; before it exists there is no one to ask. // Local boxes are exempt — they serve one host and cannot be anchored. // - a pending pairing is a FILE. run.go starts nothing for it. // - a pairing grant flips pending -> active; caps on it are a ceiling for that // host on this machine (opFamily / capsAllow). Deny beats allow. // - a revoke flips active -> revoked and the lanes come down at once. // - rotation needs root AND the old anchor's signed consent, or the old // anchor silent for anchorDeadAfter. Without the second path a lost anchor // host bricks the machine's network. import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) const ( // pairingGrantSuffix: pairings/.grant.json — the anchor's signed grant // (and later revoke) for that host. loadPairings skips this suffix. pairingGrantSuffix = ".grant.json" // unapprovedAfter is how long a pairing may sit pending before every // heartbeat also lists it as an unapproved attempt. Ten minutes covers the // normal "I paired, now I'll go approve it" gap; anything longer is either // forgotten or was never the anchor user's doing, and both deserve a line // the detection plane can act on. unapprovedAfter = 10 * time.Minute // anchorDeadAfter: with no heartbeat success on the anchor pairing for this // long, root alone may rotate the anchor. 14 days is the default // — long enough that a holiday does not open the door, short enough // that a lost host does not brick the machine's network for a quarter. anchorDeadAfter = 14 * 24 * time.Hour // consentMaxAge bounds how long a signed anchor consent stays usable, so a // record left on disk months ago cannot be replayed to re-home the box. consentMaxAge = 30 * 24 * time.Hour ) // anchorMu guards every read-modify-write of anchor.json, the grant files and // the pairing state file: the mgmt lane (grant sync/drop) and the heartbeat // (last_ok) touch them from different goroutines. var anchorMu sync.Mutex // anchorClaim is what travels on the heartbeat. verify_pubkey stays home: it is // the box's pin, not a claim about the box. type anchorClaim struct { Owner string `json:"owner"` AnchorHost string `json:"anchor_host"` AnchorHandle string `json:"anchor_handle"` ClaimedAt string `json:"claimed_at"` PublicKey string `json:"public_key"` Signature string `json:"signature"` // over "\n\n" } type anchorFile struct { anchorClaim // The anchor host's entity_sign key, b64 raw ed25519, pinned TOFU from the // anchor pairing's DCN pin (or the first pairing_grant_sync) — never typed // by hand, never from any other pairing. VerifyPubkey string `json:"verify_pubkey,omitempty"` // The person's approval devices (device.go): b64 raw ed25519 public keys // of their own bastions, pinned here with machine authority (pin-device). // A write a host asks of this machine runs on an approval one of these // signed, verified here. Same file, same authority as the anchor key. DeviceKeys []string `json:"device_keys,omitempty"` } func anchorPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "anchor.json"), nil } func anchorConsentPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "anchor.consent.json"), nil } // anchorSigningInput is the exact byte sequence signed and verified, in one // place so signer and verifier cannot drift (same discipline as owner.go). // // Prefixed with a kind tag: the owner claim and the anchor claim are signed by // the SAME box key, and without the tag an owner claim for the name // "x\nhost" would be byte-identical to an anchor claim for owner "x" on // "host" (or the reverse). The tag makes the two spaces disjoint; the names // are also refused if they contain a newline (claimNameOK), belt and braces. func anchorSigningInput(owner, anchorHost, claimedAt string) []byte { return []byte("anchor\n" + owner + "\n" + anchorHost + "\n" + claimedAt) } // claimNameOK refuses a name that could be read as more than one field of a // signing input: no newline or carriage return, not empty, bounded. func claimNameOK(name string) error { if name == "" { return errors.New("name is empty") } if len(name) > 120 { return errors.New("name too long (max 120)") } if strings.ContainsAny(name, "\n\r") { return errors.New("name must not contain a newline") } return nil } func loadAnchorFile() (*anchorFile, error) { p, err := anchorPath() if err != nil { return nil, err } raw, err := os.ReadFile(p) if err != nil { return nil, err } var f anchorFile if err := json.Unmarshal(raw, &f); err != nil { return nil, err } return &f, nil } func saveAnchorFile(f *anchorFile) error { p, err := anchorPath() if err != nil { return err } b, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return atomicWrite(p, b) } // anchorVerified checks the file's own signature under the box key it names. // A hand-edited anchor (the obvious forgery) fails here and counts as no anchor // — the same rule as currentOwnerClaim: unverifiable must be ABSENT. func anchorVerified(f *anchorFile) bool { if f == nil || f.Owner == "" || f.AnchorHost == "" || f.ClaimedAt == "" || f.PublicKey == "" || f.Signature == "" { return false } pub, err := base64.StdEncoding.DecodeString(f.PublicKey) if err != nil || len(pub) != ed25519.PublicKeySize { return false } sig, err := base64.StdEncoding.DecodeString(f.Signature) if err != nil { return false } return ed25519.Verify(ed25519.PublicKey(pub), anchorSigningInput(f.Owner, f.AnchorHost, f.ClaimedAt), sig) } // currentAnchor returns the verified anchor claim, or nil when the box is not // anchored (the normal state for every local bastion) or the file is // malformed or tampered. func currentAnchor() *anchorClaim { f, err := loadAnchorFile() if err != nil || !anchorVerified(f) { return nil } c := f.anchorClaim return &c } // anchorVerifyKey returns the pinned anchor verify key, pinning it on first // use from the anchor pairing's DCN pin when anchor.json has none yet. The // DCN pin was itself TOFU'd over the anchor pairing's own mTLS+bearer lane, // so copying it widens nothing. "" when neither exists yet. func anchorVerifyKey() string { anchorMu.Lock() defer anchorMu.Unlock() return anchorVerifyKeyLocked() } func anchorVerifyKeyLocked() string { f, err := loadAnchorFile() if err != nil || !anchorVerified(f) { return "" } if f.VerifyPubkey != "" { return f.VerifyPubkey } cfgs, err := loadPairings() if err != nil { return "" } ac := pairingFor(cfgs, f.AnchorHost) if ac == nil { return "" } dcn, err := dcnLoadFile(ac.dcnDir()) if err != nil || dcn.VerifyPubkey == "" { return "" } f.VerifyPubkey = dcn.VerifyPubkey if saveAnchorFile(f) != nil { return "" } return f.VerifyPubkey } // pinAnchorVerifyKey pins key when the box has none (TOFU over the anchor's // mgmt lane); refuses a different key once one is held. Re-pinning is a // rotation-level event (claim-anchor --rotate), not an op. func pinAnchorVerifyKey(key string) error { anchorMu.Lock() defer anchorMu.Unlock() have := anchorVerifyKeyLocked() if have == "" { if pub, err := base64.StdEncoding.DecodeString(key); err != nil || len(pub) != ed25519.PublicKeySize { return errors.New("bad verify key") } f, err := loadAnchorFile() if err != nil || !anchorVerified(f) { return errors.New("box is not anchored") } f.VerifyPubkey = key return saveAnchorFile(f) } if have != key { return errors.New("verify key mismatch — refusing re-pin (rotate the anchor to change keys)") } return nil } // ── signed records from the anchor host ───────────────────────────────────── // pairingTerms is parsed FROM verified canonical bytes only. One struct for the // three kinds (pairing_grant, pairing_revoke, anchor_consent): the fields a // kind does not use stay empty. // // Two conventions the host side must share: // - expires == "" means NEVER. A grant with no expiry stands until revoked; // a revoke record's own expiry is ignored (a revoke is final). // - anchor_consent with subject == "" means ANY host may become the next // anchor; a non-empty subject (host_name or handle) restricts it to that // one, and claim-anchor --rotate compares it against the token's handle. type pairingTerms struct { Kind string `json:"kind"` Server string `json:"server"` // this box's os.Hostname() Anchor string `json:"anchor"` // the anchor host_name Subject string `json:"subject"` // the host_name the record is about ("" on consent = any) SubjectHandle string `json:"subject_handle,omitempty"` Caps []string `json:"caps,omitempty"` // Models narrows the `inference` cap to NAMED models, and is the // only field here that is a ceiling INSIDE a family rather than a family. // Absent or empty = unscoped, which is what every grant signed before this // field existed means and what the anchor pairing always holds. The // reserved entry "pii" includes the sterilizer lane (modelscope.go). Models []string `json:"models,omitempty"` IssuedAt string `json:"issued_at"` Expires string `json:"expires,omitempty"` // RFC3339; "" = never // VerifyKey is the SUBJECT host's entity_sign key (b64 raw ed25519), signed // into the grant by the anchor. On an anchored box a member's key is pinned // from here — never from a key the member host offers on first use // (pinnedKeyFor). Absent on grants signed before the field existed. VerifyKey string `json:"verify_key,omitempty"` } // pinnedKeyFor is the entity_sign key this box holds for a pairing FROM // ANCHORING, or "" when it holds none: the anchor host's from claim-anchor // (anchor.json), a member's from the anchor-signed pairing grant. On an // anchored box a key a host OFFERS on its first DCN sync or session is never // pinned (session.go, dcn.go) — a key that arrives on the lane it is meant to // verify proves only that someone holds the lane. func pinnedKeyFor(pairing string) string { a := currentAnchor() if a == nil || pairing == "" { return "" } if a.AnchorHost == pairing { return anchorVerifyKey() } if t, ok := pairingGrantTerms(pairing); ok { return t.VerifyKey } return "" } // verifyAnchorRecord checks signature (under the pinned anchor key), kind, // server and anchor binding, and expiry. Every wall a record must pass before // it changes a state on this box, in one function, so grant / revoke / // consent cannot drift apart. func verifyAnchorRecord(termsB64, sigB64, wantKind string) (*pairingTerms, error) { a := currentAnchor() if a == nil { return nil, errors.New("this box is not anchored") } keyB64 := anchorVerifyKey() if keyB64 == "" { return nil, errors.New("anchor verify key not pinned yet — the anchor host must sync first") } pub, err := base64.StdEncoding.DecodeString(keyB64) if err != nil || len(pub) != ed25519.PublicKeySize { return nil, errors.New("bad pinned anchor verify key") } canonical, err := base64.StdEncoding.DecodeString(strings.TrimSpace(termsB64)) if err != nil { return nil, errors.New("bad canonical terms") } sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64)) if err != nil { return nil, errors.New("bad signature encoding") } if !ed25519.Verify(ed25519.PublicKey(pub), canonical, sig) { return nil, errors.New("signature does not verify under the pinned anchor key") } var t pairingTerms if err := json.Unmarshal(canonical, &t); err != nil { return nil, errors.New("signed terms unparsable") } if t.Kind != wantKind { return nil, fmt.Errorf("record kind is %q, want %q", t.Kind, wantKind) } if hn, _ := os.Hostname(); hn != "" && t.Server != hn { return nil, fmt.Errorf("record is for server %q, this box is %q", t.Server, hn) } if t.Anchor != a.AnchorHost { return nil, fmt.Errorf("record names anchor %q, this box's anchor is %q", t.Anchor, a.AnchorHost) } if t.Expires != "" { exp, err := time.Parse(time.RFC3339, t.Expires) if err != nil || !exp.After(time.Now()) { return nil, errors.New("record expired") } } return &t, nil } // pairingGrantFile is pairings/.grant.json: the anchor's grant for that // host as the exact signed bytes, plus the revoke that ended it, if any. Both // kept: the file is the record of what this box was told and when. type pairingGrantFile struct { TermsCanonical string `json:"terms_canonical"` Signature string `json:"signature"` SyncedAt string `json:"synced_at"` RevokeTermsCanonical string `json:"revoke_terms_canonical,omitempty"` RevokeSignature string `json:"revoke_signature,omitempty"` RevokedAt string `json:"revoked_at,omitempty"` } func pairingGrantPath(host string) (string, error) { if !validHostName(host) { return "", fmt.Errorf("unsafe host name %q", host) } pd, err := pairingsDir() if err != nil { return "", err } return filepath.Join(pd, host+pairingGrantSuffix), nil } func loadPairingGrant(host string) (*pairingGrantFile, error) { p, err := pairingGrantPath(host) if err != nil { return nil, err } raw, err := os.ReadFile(p) if err != nil { return nil, err } var g pairingGrantFile if err := json.Unmarshal(raw, &g); err != nil { return nil, err } return &g, nil } func savePairingGrant(host string, g *pairingGrantFile) error { p, err := pairingGrantPath(host) if err != nil { return err } b, err := json.MarshalIndent(g, "", " ") if err != nil { return err } return atomicWrite(p, b) } // pairingCaps returns the verified caps ceiling for host on this box. ok is // false when the host holds no grant, the grant no longer verifies, it was // revoked, or it names another subject — every one of those is default-deny. // Re-verified on every call: a tampered grant file authorises nothing. func pairingCaps(host string) (caps []string, ok bool) { t, ok := pairingGrantTerms(host) if !ok { return nil, false } return t.Caps, true } // pairingGrantTerms is the whole verified grant for host — the caps ceiling and // the model scope inside it. Every wall pairingCaps documented above // applies here: it IS the same read, and both callers get an answer only from // signed bytes that still verify under the pinned anchor key. func pairingGrantTerms(host string) (*pairingTerms, bool) { g, err := loadPairingGrant(host) if err != nil || g.RevokeSignature != "" { return nil, false } t, err := verifyAnchorRecord(g.TermsCanonical, g.Signature, "pairing_grant") if err != nil || t.Subject != host { return nil, false } return t, true } // opFamily maps an op id to the caps family a pairing grant names. The host // side signs the same list (allCaps below): // // observe observe:* and the computer:* observe rung, plus `uptime` (the only // exec op; read-only) // session session:* (terminal) // dcn dcn_* + ctx_* + backup_fetch (it writes to this machine's storage), // backup_push, backup_reseal and backup_list (this machine's own backups) // harness the harness LANE (harness.go H frames, gated as "harness:run") and // mode:* — this is not a mgmt op; the lane gates itself // egress the egress LANE (egress.go; requests leave this machine's own // connection) — gated as "egress"; not a mgmt op either // models model:* — CHANGING which models are on this machine (modelswap.go): // pulling one onto the user's disk, dropping one this host // put here. Separate from "inference", which is only the right to // USE what is already loaded. // inference the three INBOUND forwards the host dials over its own tunnel: // ollama :11434 (embed), the OpenAI-compatible server :18080 and // the PII sterilizer :18081 (run.go / pii.go) — gated as // "inference" at accept, and the heartbeat withholds the embed / // llm-openai / pii capabilities from a pairing outside it. These // used to be the one lane every pairing got regardless of its // ceiling. // services the SERVICE SLOT (service.go): service:up / service:down / // service:status on the harness lane, and the per-slot inbound // forwards (18100-18199) the host dials to reach a server it built // here — gated as "services" at accept, like inference. // // "" is returned only for an op NOBODY has classified, and that now // means DENY at the gate (capsAllowIn) rather than allow. stepup_* and // pairing_* used to land here; both now carry an explicit ungrantable family. func opFamily(op string) string { switch { // stepup_* is the APPROVAL step of another op, never a capability of its // own, so its real ceiling is the ceiling of the thing being approved and is // resolved per request from the request's kind (stepupOpFamily, stepup.go). // What is returned here is the FLOOR that applies if that resolution is ever // bypassed: a family name no grant can carry, because capsFamilyStepup is // deliberately absent from allCaps. A caller that reaches the generic gate // without the per-kind resolution is therefore refused, not served. case strings.HasPrefix(op, "stepup_"): return capsFamilyStepup // pairing_* is anchor-only and is refused by handleMgmtConnFor before this // is ever consulted. It is classified anyway so that nothing on this lane is // unclassified, and classified into a family no grant can carry, so that if // that earlier refusal is ever moved or removed this fails closed. case strings.HasPrefix(op, "pairing_"): return capsFamilyAnchor // audit_fetch reads this box's own record of what has been asked of it // (mgmt_audit.go) — the same KIND of thing as observe:*, a read of the // machine's own state that changes nothing. It carries a second gate on top // of this one: handleMgmtConnFor answers it only for the anchor pairing on // an anchored box, because the file names every pairing. case strings.HasPrefix(op, "observe:"), strings.HasPrefix(op, "computer:"), op == "uptime", op == mgmtAuditFetchOp: return "observe" case strings.HasPrefix(op, "session:"): return "session" case strings.HasPrefix(op, "dcn_"), strings.HasPrefix(op, "ctx_"), op == "backup_fetch", op == "backup_push", op == "backup_reseal", op == "backup_list": return "dcn" case op == "harness:run", strings.HasPrefix(op, "mode:"): return "harness" // The standing coder grant (coder_grant.go) authorizes mode:coder runs, // so installing or dropping one is the harness family too. case op == "coder_grant_sync", op == "coder_grant_drop": return "harness" // "model:" is NOT a longer spelling of "mode:" above — "model:pull"[:5] is // "model", so the two prefixes cannot collide — and it is deliberately its // own family rather than part of "inference". See modelswap.go's header: // folding it into inference would have handed every pairing already granted // the forwards the power to download gigabytes onto the user's disk and to // delete models from it, which nobody approved when that grant was given. case strings.HasPrefix(op, "model:"): return "models" case op == "egress": return "egress" case op == "inference": return "inference" // device_* (device.go) is answered by the person's OWN bastion — the // approval device — and files or reads an ask there. It authorizes nothing // on the box that answers it: only the person, at that console, signs. case strings.HasPrefix(op, "device_"): return capsFamilyNone // The SERVICE SLOT (service.go): service:up/down/status on the harness lane // and the per-slot inbound forwards, gated as "services". Its own family // rather than "harness", because "harness" only ever meant "run a job here // and clean up", and a server that stays up holding the user's repos is a // different thing to grant. "service:" cannot collide with "session:". case strings.HasPrefix(op, "service:"), op == serviceCapability, op == "service_grant_sync", op == "service_grant_drop": return "services" // op_* (ops.go): the ops the host wrote and asks this machine to run — held, // dry-run in a sandbox, applied under signed terms. Its own family: running // a program the host wrote is neither a folder grant nor a shell. case strings.HasPrefix(op, "op_"): return opsFamily } return "" } // allCaps is the full ceiling — what the anchor pairing itself is held to. var allCaps = []string{"dcn", "egress", "harness", "inference", "models", "observe", "ops", "services", "session"} // anchorOnly is the gate for a host-directed action whose effect is BOX-WIDE // rather than per-pairing: swapping the binary every pairing runs on // (update.go), placing a memory mirror on the machine (mirror.go), and // terminating processes on it (contain.go). No caps family fits — the anchor // user is the machine's admin, and only the anchor host may direct these on an // anchored box. An unanchored LOCAL box gates nothing, as capsAllow does; an // unanchored NETWORKED one refuses these outright (unanchoredNetworkedRefusal // below) — directing a binary swap is the least appropriate thing to serve a // machine that cannot say who is entitled to direct it. An empty self on an // anchored box is refused for the same reason it is there. Returns the refusal // reason, or "" to proceed. // unanchoredNetworkedRefusal is THE gate: a networked box with no anchor serves // nothing. It lives here, in the two functions every lane already consults, and // not in each lane, because the first cut put it only in handleMgmtConnFor and // closed a fifth of what its own commit message claimed. // // Both callers below used to open with `if a == nil { return "" }`, so the // anchor was not merely who approves later pairings — it was the switch that // turned default-deny ON. Before it existed every host paired to a networked box // held every op family AND the box-wide directives. What the mgmt-frame-only // version still served: harness:run (harness.go), // session:open (session.go), egress (egress.go), the inference forwards on // :11434/:18080/:18081 (run.go) and their heartbeat advertisement (agents.go), // self-update (update.go), contain (contain.go), mirror placement (mirror.go), // context-prep (contextprep.go). All of them route through capsAllow or // anchorOnly, so one predicate here closes every one at once. // // The reachable path, and why it is not theoretical: `set-mode networked`, host // A pairs, host B pairs before anyone runs claim-anchor. pairPrecheck only // writes a pairing PENDING when an anchor exists (pair.go), and state "" reads // active (config.go), so B comes straight up with full lanes. // // LOCAL is exempt and must stay so: a local bastion serves one host and cannot // be anchored at all (cmdClaimAnchor refuses outside networked mode), so the // pairing IS the authorization there. Most bastions are local. // // self-update is deliberately INSIDE the refusal. Directing a binary swap is the // most dangerous thing on the list, and an unanchored networked box cannot say // who is entitled to direct it. Nothing is lost: anchoring is done at the // machine as root, so whoever can fix the anchor is already standing there. // SELF-CONTAINED — it reads BOTH conditions itself. The first cut checked only // boxMode() and left the currentAnchor() test to its callers, which was true of // capsAllow and anchorOnly (they call it inside their own `a == nil` branch) and // false of handleMgmtConnFor, which calls it directly. So every networked box // refused everything, anchored or not. Caught by running the suite as an // unprivileged user, which is the run that had been skipped; as root the same // build passed the one test written for it and nothing else was exercised. A // predicate that is only correct at some call sites is a predicate that will // be called wrongly. func unanchoredNetworkedRefusal(what string) string { if boxMode() != modeNetworked || currentAnchor() != nil { return "" } return "refused: " + what + " — this machine is networked but NOT ANCHORED, so it serves " + "no host. Until it is anchored every paired host would hold every capability here. " + "Run `privatae-bastion claim-anchor` on the machine with machine authority to bind " + "it to the host that governs it." } func anchorOnly(self, what string) string { a := currentAnchor() if a == nil { return unanchoredNetworkedRefusal(what) } if self == "" { return "refused: " + what + " — no pairing identity (default-deny on an anchored box)" } if a.AnchorHost == self { return "" } return "refused: " + what + " is directed only by the anchor host (" + a.AnchorHost + ") on an anchored box; " + self + " is a member pairing" } // capsAllow is the ceiling check for one op on one pairing of an anchored // box. The anchor pairing is unconstrained (its user IS the admin). Any other // pairing needs a verified grant whose caps contain the op's family. Returns // the refusal reason, or "" to proceed. An unanchored NETWORKED box refuses // everything. An unanchored LOCAL box gates ONLY what the person at the machine // has switched off — it used to gate nothing at all, and this sentence said // so for longer than it was true, which is how the next reader re-derives a // bug that has been fixed. On an anchored box an EMPTY self (no pairing // identity) is default-deny — it can never be the anchor and holds no grant, // the same rule handleMgmtConnFor applies. func capsAllow(self, op string) string { return capsAllowIn(self, op, opFamily(op)) } // The three family names that are NOT capabilities, and are deliberately absent // from allCaps so that no grant can ever name one — and, because the local // switch (localdeny.go) offers exactly the families a grant can name, absent // from what the person at the machine can switch off too. const ( // capsFamilyStepup / capsFamilyAnchor are FLOORS: the op has a real ceiling // decided elsewhere (per request kind for stepup_*, the anchor rule for // pairing_*), and these are what applies if that decision is bypassed. Being // ungrantable is the point — bypassing the real gate refuses, never serves. capsFamilyStepup = "stepup" capsFamilyAnchor = "anchor" // capsFamilyNone is the opposite: a lane that genuinely authorizes NO // capability on this box, where holding a verified grant at all is the whole // ceiling. It exists because "no family" now means DENY, and without a way // to say "none is correct here" the only way to express it would be to // invent a family, which would then have to be granted to work. capsFamilyNone = "-" ) // capsAllowIn is capsAllow against an EXPLICIT family — the form the one lane // whose family is not a property of its op name needs (stepup_*, gated in // handleMgmtConnFor on the family of the thing being approved). // // DEFAULT-DENY on an unclassified op. capsAllow used to // return "" — allowed — for any op opFamily did not know, which meant a member // pairing holding any verified grant could be asked for it regardless of what // the anchor granted. The comment in handleMgmtConnFor had said so in writing // since the unanchored case was patched; only that one case was closed, and on // an ANCHORED box the hole stood. Nothing about an op having no family says the // pairing may be asked for it; it says nobody decided, and an undecided // permission is not a granted one. // // The anchor pairing returns below the local switch and above the family check, // so an unclassified op does not touch it — its user is the admin of the // machine and is unconstrained by construction. func capsAllowIn(self, op, fam string) string { // An unanchored NETWORKED box refuses everything, and that refusal names the // fix. It comes first because a deny-list message would be true and useless // to someone whose real problem is that nothing governs this box yet. if why := unanchoredNetworkedRefusal("op '" + op + "'"); why != "" { return why } // THE LOCAL SWITCH IS CHECKED BEFORE ANY ANCHOR STATE AT ALL, and that // placement is the whole feature rather than a detail. // // The anchor pairing is otherwise unconstrained — its user IS the machine's // admin. But on a single-person machine the only pairing IS the anchor, so // a switch that exempted it would do nothing at all on exactly the computer // where it was asked for: the local control would be a button with no // effect on the one host that box serves. // // IT WAS BELOW THE `a == nil` RETURN AND THAT MADE IT A NO-OP ON EVERY LOCAL // BASTION — the box the switch is most of the point of. An unanchored box // left here before the list was ever read, so the // page offered the switch, wrote the denial, confirmed "can no longer read // your files" and tagged the card "switched off by you", while every op went // on being served. That is the worst possible version of a security control: // one that reports success. It also broke this page's own stated invariant — // "it cannot disagree with the gate, because it asks the gate" // (uigiven.go:32-41) — because `Held` came from capsAllow and the // switched-off tag came from the deny list, and on a local box those two // sources gave opposite answers. // // It stays deny-only wherever it sits: the only value this branch can // produce is a refusal. It cannot return "" and so cannot admit an op that // anything below would have refused. // // It reads the RESOLVED family, not opFamily(op), and that matters for // exactly one lane: a step-up is gated on the family of the thing being // approved, so a person who has switched "session" off here also stops the // host raising the root-shell approval prompt that session:root needs. Under // opFamily alone that prompt would have carried the ungrantable "stepup" // floor, which is in nobody's deny list, and the switch would have been // silently bypassable by asking for the approval instead of the op. if fam != "" && fam != capsFamilyNone && localDenied(self, fam) { return localDenyRefusal(fam) } a := currentAnchor() if a == nil { // Unanchored and LOCAL: the pairing itself is the authorization, which // is what a local bastion is. Nothing below applies. return "" } if self == "" { return "refused: no pairing identity on this lane (default-deny on an anchored box)" } if a.AnchorHost == self { return "" } caps, ok := pairingCaps(self) if !ok { return "refused: this pairing holds no verified pairing grant from the anchor (default-deny)" } if fam == capsFamilyNone { return "" } if fam == "" { return fmt.Sprintf("refused: op '%s' is in no capability family, so no pairing grant "+ "can name it (default-deny). Give it a family in opFamily (anchor.go).", op) } for _, c := range caps { if c == fam { return "" } } return fmt.Sprintf("refused: op '%s' is family '%s', not in this pairing's caps %v", op, fam, caps) } // ── pairing liveness (last_ok) ───────────────────────────────────────────── // pairingStatePath is /pairing_state.json: last successful // heartbeat per pairing. Box-level, not under pairings/, so loadPairings // never sees it and a pairing's own file is only rewritten by pair/grant. func pairingStatePath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "pairing_state.json"), nil } type pairingState struct { LastOK string `json:"last_ok"` // RFC3339 of the last 2xx heartbeat } func loadPairingStates() map[string]pairingState { out := map[string]pairingState{} p, err := pairingStatePath() if err != nil { return out } raw, err := os.ReadFile(p) if err != nil { return out } _ = json.Unmarshal(raw, &out) return out } // recordPairingOK is called by the heartbeat on every 2xx. Best-effort: a // failure to note the time never fails a beat. func recordPairingOK(host string) { anchorMu.Lock() defer anchorMu.Unlock() st := loadPairingStates() st[host] = pairingState{LastOK: time.Now().UTC().Format(time.RFC3339)} p, err := pairingStatePath() if err != nil { return } if b, err := json.MarshalIndent(st, "", " "); err == nil { _ = atomicWrite(p, b) } } // lastOK returns the last successful heartbeat for host, zero when none. func lastOK(host string) time.Time { if s, ok := loadPairingStates()[host]; ok && s.LastOK != "" { if t, err := time.Parse(time.RFC3339, s.LastOK); err == nil { return t } } return time.Time{} } // ── what the heartbeat carries ───────────────────────────────────────────── // pairingEntry is one pairing on this box as told to the ANCHOR host: name, // handle, state, since. Same privacy line as peers.go — never keys or grants. type pairingEntry struct { HostName string `json:"host_name"` Handle string `json:"handle"` State string `json:"state"` Since string `json:"since,omitempty"` } // pairingsReport lists EVERY pairing on the box, including the one asking. // "since" is first_seen — when the host was first paired here — kept across // --replace, so a pending host re-presenting a token keeps its original age // (the 10-minute unapproved clock does not restart). nil when the directory // cannot be read — the host keeps what it has, as with peers. func pairingsReport() []pairingEntry { cfgs, err := loadPairings() if err != nil { return nil } out := make([]pairingEntry, 0, len(cfgs)) for _, c := range cfgs { if c.HostName == "" { continue } out = append(out, pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName), State: c.stateName(), Since: sinceOf(c)}) } return out } // unapprovedAttempts is every pairing that has sat pending longer than // unapprovedAfter. Goes on EVERY heartbeat, not only the anchor's: a host that // shares the box should also see that someone is trying to join it. // // nil vs EMPTY is the same contract as peers.go, and for the same reason: the // host keeps the stored list on an ABSENT key (COALESCE), so "none" must // travel as an explicit [] or a box that once reported [X] would show X as an // unapproved attempt forever after X was approved or revoked. nil is returned // ONLY when the pairings could not be enumerated — then we say nothing. func unapprovedAttempts(now time.Time) []peerAnnounce { list := pairingsReport() if list == nil { return nil } out := make([]peerAnnounce, 0) for _, e := range list { if e.State != pairingPending || e.Since == "" { continue } since, err := time.Parse(time.RFC3339, e.Since) if err != nil || now.Sub(since) < unapprovedAfter { continue } out = append(out, peerAnnounce{HostName: e.HostName, Handle: e.Handle, State: e.State, Since: e.Since}) } return out } // pairingEnforcing is what the enforcing report adds for one pairing: its // state, the caps ceiling it is held to, and the grant signature the host can // match against what it signed (signatures, never terms — enforcing.go). func pairingEnforcing(self string) map[string]any { out := map[string]any{} cfgs, err := loadPairings() if err != nil { return out } c := pairingFor(cfgs, self) if c == nil { return out } out["pairing_state"] = c.stateName() // The name THIS pairing is known by on this box — what a reach grant must // name as its subject (service_reach.go). A host can hold more than one name // (a legacy one and the current one); this says which one the box enforces. out["pairing_host"] = c.HostName a := currentAnchor() if a == nil { return out } out["anchored"] = true if a.AnchorHost == self { out["pairing_caps"] = allCaps out["is_anchor"] = true return out } caps, ok := pairingCaps(self) if !ok { out["pairing_caps"] = []string{} return out } if caps == nil { caps = []string{} } out["pairing_caps"] = caps if g, err := loadPairingGrant(self); err == nil { out["pairing_grant"] = map[string]any{"signature": g.Signature, "synced_at": g.SyncedAt} } return out } // ── claim-anchor ─────────────────────────────────────────────────────────── // cmdClaimAnchor is `sudo privatae-bastion claim-anchor [--rotate] [--owner ] `. // // Fresh box: redeem the token (a real pairing, active), then sign the anchor // under the box key. Box with ONE pairing that is the token's host: the token // is redeemed as a rotation of that pairing (its port, stores and DCN pin // survive) and it becomes the anchor. Box with a pairing for another host, or // several pairings: refused — which of them the admin meant is not this // command's guess to make, and an anchor next to an unapproved active pairing // would be a hole on day one. // cmdRepinAnchor re-signs anchor.json for the SAME host under a new host_name. // // WHY THIS EXISTS. A host's own name can change under the box — a move of // the Host address to a new zone does exactly that. `pair // --replace` updates the PAIRING to the new name but never touches anchor.json // (writeAnchor is called from claim-anchor alone), so an anchored box goes on // naming its anchor by the old name and refuses every signed record that names // the new one (verifySignedTerms: "record names anchor %q, this box's anchor is // %q"). And --rotate cannot do it: it exists to move the anchor to a DIFFERENT // host and refuses a same-handle claim outright. // // So this is the third case, and it is deliberately the narrowest of the three: // same owner, same handle, same host — only the DNS name it is reached by // changes. It cannot move the anchor anywhere. Moving it still means --rotate, // with the current anchor's consent or its silence. // // Walls: root (same as every other anchor write), an existing anchor to re-pin, // and a live pairing on this box whose handle matches the current anchor's. // The new name is taken from that pairing, never from an argument — nothing // here is typed by hand. // // The pinned verify key is CARRIED OVER, unlike a fresh or rotated anchor. That // is the whole difference: a rotation deliberately drops the pin because the // anchor becomes a different host whose key must be learned again, while here // the host is the same one it always was and dropping its pin would re-open a // TOFU window for no reason. func cmdRepinAnchor() error { if err := requireMachineAuthority("claim-anchor --repin"); err != nil { return err } if boxMode() != modeNetworked { return errors.New("--repin needs networked mode — a local bastion has no anchor") } prev := currentAnchor() if prev == nil { return errors.New("--repin: this box is not anchored — run claim-anchor with a token from the host that should hold it") } // currentAnchor returns the CLAIM; the pinned verify key lives on the file. prevFile, err := loadAnchorFile() if err != nil { return fmt.Errorf("--repin: could not read anchor.json: %w", err) } cfgs, err := loadPairings() if err != nil { return err } cfg := pairingFor(cfgs, handleOf(prev.AnchorHost)) if cfg == nil { return fmt.Errorf("--repin: no pairing on this box belongs to %s — re-pair it first (`privatae-bastion pair --replace `), then re-pin", prev.AnchorHost) } if cfg.HostName == prev.AnchorHost { fmt.Printf("anchor already names %s — nothing to re-pin.\n", prev.AnchorHost) return nil } owner := prev.Owner if oc := currentOwnerClaim(); oc != nil && oc.Owner != "" { owner = oc.Owner } if err := writeAnchorPreservingPin(owner, cfg.HostName, prevFile.VerifyPubkey); err != nil { return err } if _, err := writeOwnerClaim(owner); err != nil { return err } fmt.Printf("anchor re-pinned: %s -> %s (owner %q, pinned key kept)\n", prev.AnchorHost, cfg.HostName, owner) return nil } // pickPairing resolves --host against this box's pairings. It accepts the full // host_name the box reports, or just the handle in front of it, because the // name the user is looking at depends on which surface they read it from — // `8abb770bbfde71d0` in Circles, `8abb770bbfde71d0.host.privatae.ai` in the // claim-anchor error. Refusing the short form would mean refusing the thing the // user most obviously typed. func pickPairing(cfgs []*Config, want string) (*Config, error) { want = strings.TrimSpace(want) if c := pairingFor(cfgs, want); c != nil { return c, nil } if c := pairingFor(cfgs, handleOf(want)); c != nil { return c, nil } for _, c := range cfgs { if strings.EqualFold(c.HostName, want) { return c, nil } } return nil, fmt.Errorf("--host %q is not one of this box's pairings (%s)", want, strings.Join(hostNames(cfgs), ", ")) } func cmdClaimAnchor(token string, rotate bool, owner, host string) error { if err := requireMachineAuthority("claim-anchor"); err != nil { return err } // Before boxMode() below, which reaches configDir -> MkdirAll, and before // loadPairings, which reaches pairingsDir -> MkdirAll. pairWithToken carries // the same guard, so no pairing could be WRITTEN from a forked dir either way // — but both calls above would have CREATED that directory first, and this // file's own test treats creating it as the damage. if err := singletonGuardForPair(); err != nil { return err } if boxMode() != modeNetworked { return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves one host and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo()) } cfgs, err := loadPairings() if err != nil { return err } prev := currentAnchor() if prev != nil && !rotate { return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate ` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24)) } if prev == nil && rotate { return errors.New("--rotate: this box is not anchored yet — run claim-anchor without --rotate") } // --host and a token both answer "which host", so taking both means one of // them is being ignored and the user cannot tell which. Refuse rather than // pick: the token is redeemable and silently discarding it would burn it. if host != "" && token != "" { return errors.New("--host and a token both name the anchor host — pass one. A token names it by itself; --host names a pairing this box already has") } var consent *pairingTerms var cfg *Config // NO TOKEN — the normal path for a box you have already paired. // // The token was the whole reason this command was incoherent: you pair the // machine to your host, then `set-mode networked`, and then the only way to // anchor was to go back to the dashboard, press "Set up a bastion" a SECOND // time and redeem a second pairing token against a machine that is already // paired (the first one is burned at redeem — the host consumes it in the // same UPDATE that validates it). Nothing in the product said so. // // And on a box with exactly one pairing the token proved nothing the pairing // did not already prove. It does not gate root: root on this machine can mint // tokens from their OWN host freely, so the token never stood between them and // the anchor — the one-pairing wall below is what does, and it still stands. // What the token gated was the legitimate owner, who had already proved the // same relationship by pairing. // // So: with exactly one pairing, anchor to that host. Deliberately NOT extended // to --rotate (handled above, still token-only): rotating names a DIFFERENT // host, which is a widen and needs that host to have issued something. if token == "" { if rotate { return errors.New("--rotate needs a token from the host taking the anchor — it names a different host, so that host has to issue it") } // A SHARED MACHINE IS THE NORMAL CASE, not the exception. This is the // machine several people's hosts pair to — that is what networked mode // MEANS — so "more than one pairing" describes the thing the anchor exists // to govern. The first cut of this refused a multi-pairing box outright and // sent the user to fetch a token, which put the second token back exactly // where it had just been removed, on the only box that really needs an // anchor. Hit on the first machine it was tried on, serving three hosts. // // The ambiguity is real: with three pairings the box cannot know which host // should hold the anchor. But the answer to an ambiguous choice is to let // the user name it, not to demand a credential. --host does that, and the // refusal below prints the names to choose from. // // Naming a host is NOT a widen, for the same reason the no-token path is // not: every candidate is already paired to this box. Choosing among hosts // that already have lanes here grants reach to nobody new — and it TIGHTENS, // because from then on every further pairing waits for that host's approval. switch { case host != "": c, err := pickPairing(cfgs, host) if err != nil { return err } cfg = c case len(cfgs) == 1: cfg = cfgs[0] case len(cfgs) == 0: return fmt.Errorf("this box has no pairing yet — pair it to your host first (`privatae-bastion pair `, token from Network → Set up a bastion), then `privatae-bastion claim-anchor` with no token") default: return fmt.Errorf("this box serves %d hosts — say which one should hold the anchor:\n%s\n e.g. `privatae-bastion claim-anchor --host %s`\nThe anchor host approves every pairing made here from then on; the other pairings keep the lanes they already have.", len(cfgs), " "+strings.Join(hostNames(cfgs), "\n "), hostNames(cfgs)[0]) } // REVOKED is not "waiting", it is a decision, and it must not be undone by // a command the user ran to do something else. pair.go holds the same line // for a re-pair (pending again, never straight back to active), // and cfg.active() is false for BOTH pending and revoked, so flipping on // !active() would have quietly reinstated it. if cfg.State == pairingRevoked { return fmt.Errorf("%s is REVOKED on this box — claim-anchor will not reinstate it; re-pair that host first (`privatae-bastion pair --replace `)", cfg.HostName) } // Validate the owner BEFORE mutating anything. claimNameOK runs below for // both paths, and a bad --owner there would abort having already flipped // the pairing active — a half-applied command, and the one state change // this path makes. Resolve and check it here so the flip is the last thing // that happens and nothing is left behind on a refusal. owner = strings.TrimSpace(owner) if owner == "" { if oc := currentOwnerClaim(); oc != nil { owner = oc.Owner } else { owner = handleOf(cfg.HostName) } } if err := claimNameOK(owner); err != nil { return fmt.Errorf("owner: %w", err) } // The anchor never waits on itself — same rule as the forceActive redeem // below, applied to the pairing that is already here. if !cfg.active() { if err := cfg.updateOnDisk(func(d *Config) { d.State = pairingActive }); err != nil { return err } } } else { handle, _, derr := decodeToken(token) if derr != nil { return derr } if rotate { if handleOf(prev.AnchorHost) == handle { return fmt.Errorf("--rotate: %s is already the anchor — to rotate its credentials use `pair --replace`", prev.AnchorHost) } consent, err = rotationPermitted(prev, handle, time.Now()) if err != nil { return err } } else { switch { case len(cfgs) == 0: // fresh box — the token makes the first pairing and it is the anchor case len(cfgs) == 1 && pairingFor(cfgs, handle) != nil: // the sole pairing is the token's host: it becomes the anchor case len(cfgs) == 1: return fmt.Errorf("this box already serves %s; the anchor must be that host — anchor with a token from it, or with no token at all (`privatae-bastion claim-anchor`), or remove that pairing first", cfgs[0].HostName) default: return fmt.Errorf("this box serves %d hosts (%s); an anchor can only be claimed on a box with one pairing or none — remove the others first", len(cfgs), strings.Join(hostNames(cfgs), ", ")) } } // Redeem: a rotation of the pairing when the host is already here, a new // active pairing otherwise. forceActive: the anchor never waits on itself. c, _, perr := pairWithToken(token, pairingFor(cfgs, handle) != nil, true) if perr != nil { return perr } cfg = c } owner = strings.TrimSpace(owner) if owner == "" { if oc := currentOwnerClaim(); oc != nil { owner = oc.Owner } else if prev != nil { owner = prev.Owner } else { owner = handleOf(cfg.HostName) } } if err := claimNameOK(owner); err != nil { return fmt.Errorf("owner: %w", err) } if err := writeAnchor(owner, cfg.HostName); err != nil { return err } // The owner claim is re-signed too: claim-anchor does what claim-owner does. if _, err := writeOwnerClaim(owner); err != nil { return err } if rotate { // The old anchor becomes an ordinary member that the NEW anchor must // approve — its user no longer holds the admin role on this box. Consent // is single-use: remove it so it cannot re-home the box a second time. if old := pairingFor(cfgs, prev.AnchorHost); old != nil && old.HostName != cfg.HostName { // A locked read-modify-write: the daemon may be saving this file's // endpoint at the same moment, and a whole-struct save from this // process's stale copy would lose one side or the other. if err := old.updateOnDisk(func(d *Config) { d.State = pairingPending }); err != nil { return err } } if consent != nil { if p, err := anchorConsentPath(); err == nil { _ = os.Remove(p) } } } // Anchoring is the act that claims the machine, so it records the marker too // (singleton.go). This also means anchoring a fleet of boxes creates the // singleton on each one as it goes, rather than needing a separate pass. _ = maybeWriteMarker() fmt.Printf("anchored to %s (owner %q)\n", cfg.HostName, owner) fmt.Println(" Every host paired from now on waits, pending, until this anchor host approves it.") fmt.Println(" " + restartHint()) return nil } // writeAnchor signs and installs anchor.json under the box key. A fresh anchor // (or a rotated one) starts with NO verify key: the new anchor host's key is // pinned on first contact, never carried over from the previous host. // writeAnchorPreservingPin is writeAnchor with the TOFU pin carried across. // Only --repin uses it: same host, new name, so the key it pinned is still the // right key. Every other path drops the pin on purpose. func writeAnchorPreservingPin(owner, anchorHost, verifyPubkey string) error { if err := writeAnchor(owner, anchorHost); err != nil { return err } if verifyPubkey == "" { return nil } anchorMu.Lock() defer anchorMu.Unlock() f, err := loadAnchorFile() if err != nil || f == nil { return err } f.VerifyPubkey = verifyPubkey return saveAnchorFile(f) } func writeAnchor(owner, anchorHost string) error { if err := claimNameOK(owner); err != nil { return fmt.Errorf("owner: %w", err) } if !validHostName(anchorHost) { return fmt.Errorf("anchor host %q is not a valid host name", anchorHost) } anchorMu.Lock() defer anchorMu.Unlock() pub, priv, err := loadOrCreateOwnerKey() if err != nil { return err } claimedAt := time.Now().UTC().Format(time.RFC3339) sig := ed25519.Sign(priv, anchorSigningInput(owner, anchorHost, claimedAt)) f := &anchorFile{anchorClaim: anchorClaim{ Owner: owner, AnchorHost: anchorHost, AnchorHandle: handleOf(anchorHost), ClaimedAt: claimedAt, PublicKey: base64.StdEncoding.EncodeToString(pub), Signature: base64.StdEncoding.EncodeToString(sig), }} return saveAnchorFile(f) } // rotationPermitted is the second wall for --rotate (root is the first): // either a verified consent record from the current anchor naming the new // host (or any host), or the current anchor silent past anchorDeadAfter. // Returns the consent used (nil on the silence path). func rotationPermitted(prev *anchorClaim, newHandle string, now time.Time) (*pairingTerms, error) { if p, err := anchorConsentPath(); err == nil { if raw, err := os.ReadFile(p); err == nil { var rec struct { TermsCanonical string `json:"terms_canonical"` Signature string `json:"signature"` } if json.Unmarshal(raw, &rec) == nil { t, err := verifyAnchorRecord(rec.TermsCanonical, rec.Signature, "anchor_consent") switch { case err != nil: return nil, fmt.Errorf("anchor consent on disk does not verify: %w", err) case t.Subject != "" && t.Subject != newHandle && handleOf(t.Subject) != newHandle: return nil, fmt.Errorf("anchor consent names %q as the next anchor, the token is for %q", t.Subject, newHandle) } if issued, err := time.Parse(time.RFC3339, t.IssuedAt); err != nil || now.Sub(issued) > consentMaxAge { return nil, errors.New("anchor consent is too old (or has no issued_at) — ask the current anchor for a fresh one") } return t, nil } } } // No consent: the silence path. The reference is the last heartbeat the // anchor pairing got through; before any beat succeeded, the claim time. ref := lastOK(prev.AnchorHost) if ref.IsZero() { if t, err := time.Parse(time.RFC3339, prev.ClaimedAt); err == nil { ref = t } } if ref.IsZero() || now.Sub(ref) < anchorDeadAfter { return nil, fmt.Errorf("--rotate needs the current anchor's consent (a pairing_anchor_consent op from %s writes anchor.consent.json), or %d days without a successful heartbeat from it (last success %s)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24), ref.UTC().Format(time.RFC3339)) } return nil, nil } // sortedCaps normalises a caps list for reporting: sorted, deduplicated. func sortedCaps(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) for _, c := range in { c = strings.TrimSpace(c) if c == "" || seen[c] { continue } seen[c] = true out = append(out, c) } sort.Strings(out) return out }