1777 lines
75 KiB
Go
1777 lines
75 KiB
Go
//go:build !windows
|
|
|
|
// Tagged !windows. These tests were ALREADY unix-only — they use
|
|
// helpers that live behind `//go:build !windows` (sessionShellsPath,
|
|
// withConfigDir, withMarkerAt, setupBox) — so this file has never compiled for
|
|
// Windows and `GOOS=windows go vet ./...` has been red on it since it was
|
|
// written. The tag removes no coverage; it states a dependency that was already
|
|
// there so vet can run clean on that target, which is what makes a REAL Windows
|
|
// regression visible instead of being lost in a failure everyone scrolls past.
|
|
// Windows-safe coverage of the machine-authority gate lives in privilege_test.go.
|
|
//
|
|
// IF YOU USE setupBox, driveOp, writePairingState, signTerms, grantTerms OR
|
|
// tMember/tAnchor IN A NEW TEST FILE, that file needs `//go:build !windows` on
|
|
// line 1 as well. buildtags_test.go checks this and prints the exact fix — it
|
|
// exists because the mistake was made twice in quick succession, both times by someone
|
|
// who had run `GOOS=windows go build ./...`, which does NOT compile test files
|
|
// and reports green. `GOOS=windows go vet ./...` is the command that sees it.
|
|
|
|
package main
|
|
|
|
// Anchoring gates: the walls this box enforces on its
|
|
// own, with no host in the loop. Each test is one way a pairing could gain
|
|
// lanes it was not granted, or one way a lost anchor could brick the box.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
tAnchor = "anchor.host.privatae.ai"
|
|
tMember = "member.host.privatae.ai"
|
|
)
|
|
|
|
// setupBox: a networked, anchored box in a temp config dir with a pinned
|
|
// anchor verify key. Returns the anchor host's signing key (what the anchor
|
|
// host's entity_sign key would be).
|
|
func setupBox(t testing.TB) ed25519.PrivateKey {
|
|
t.Helper()
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
p, _ := modePath()
|
|
_ = os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600)
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme IT", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
if err := pinAnchorVerifyKey(base64.StdEncoding.EncodeToString(pub)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return priv
|
|
}
|
|
|
|
func writePairingState(t testing.TB, host, state string) {
|
|
t.Helper()
|
|
pd, _ := pairingsDir()
|
|
b, _ := json.Marshal(Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "fd00:9::a:1", State: state})
|
|
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func loadState(t *testing.T, host string) string {
|
|
t.Helper()
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := pairingFor(cfgs, host)
|
|
if c == nil {
|
|
t.Fatalf("%s not paired", host)
|
|
}
|
|
return c.stateName()
|
|
}
|
|
|
|
// signTerms signs canonical terms (sorted keys, no whitespace — what Go's
|
|
// json.Marshal of a map produces) and returns the two b64 args the ops take.
|
|
func signTerms(t *testing.T, priv ed25519.PrivateKey, terms map[string]any) (string, string) {
|
|
t.Helper()
|
|
canonical, err := json.Marshal(terms)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(canonical),
|
|
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, canonical))
|
|
}
|
|
|
|
func grantTerms(subject string, caps []string, expires string) map[string]any {
|
|
hn, _ := os.Hostname()
|
|
return map[string]any{"anchor": tAnchor, "caps": caps, "expires": expires,
|
|
"issued_at": time.Now().UTC().Format(time.RFC3339), "kind": "pairing_grant",
|
|
"server": hn, "subject": subject, "subject_handle": handleOf(subject)}
|
|
}
|
|
|
|
// driveOp runs one job through the real mgmt frame path as pairing `self`.
|
|
func driveOp(t *testing.T, self, op string, args []string) (string, string, int) {
|
|
t.Helper()
|
|
up := websocket.Upgrader{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ws, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ws.Close()
|
|
handleMgmtConnFor(ws, self, "", "", "")
|
|
}))
|
|
defer srv.Close()
|
|
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
// A write on an anchored box carries the person's approval (writes.go);
|
|
// testApproval (hostops_test.go) signs one from a test device so a test
|
|
// about something else is not refused at the gate. The gate's own tests
|
|
// drive raw jobs and never go through here.
|
|
job, _ := json.Marshal(mgmtJob{Op: op, Args: args, Approval: testApproval(t, op, args)})
|
|
if err := c.WriteMessage(websocket.BinaryMessage, append([]byte("C"), job...)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
var out, errOut string
|
|
for {
|
|
_, data, err := c.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
switch data[0] {
|
|
case 'O':
|
|
out += string(data[1:])
|
|
case 'R':
|
|
errOut += string(data[1:])
|
|
case 'X':
|
|
var code int
|
|
fmt.Sscanf(string(data[1:]), "%d", &code)
|
|
return out, errOut, code
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── mode ───────────────────────────────────────────────────────────────────
|
|
|
|
// LOCAL mode is the whole protection for a single-user machine: one host, and
|
|
// a second token is refused before the redeem so it is not burned. --replace
|
|
// of the one host still works (a rotation is not a second host).
|
|
func TestLocalModeRefusesSecondPairing(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if boxMode() != modeLocal {
|
|
t.Fatalf("a box with no mode file must be LOCAL, got %s", boxMode())
|
|
}
|
|
writePairing(t, tAnchor)
|
|
cfgs, _ := loadPairings()
|
|
|
|
_, _, err := pairPrecheck(cfgs, "other", false, false)
|
|
if err == nil || !contains(err.Error(), "local mode") {
|
|
t.Fatalf("local mode must refuse a second pairing with a plain message, got %v", err)
|
|
}
|
|
if _, _, err := pairPrecheck(cfgs, "anchor", true, false); err != nil {
|
|
t.Fatalf("--replace of the one host must still be allowed in local mode: %v", err)
|
|
}
|
|
if _, st, err := pairPrecheck(nil, "first", false, false); err != nil || st != "" {
|
|
t.Fatalf("the first pairing on a local box is active: state %q err %v", st, err)
|
|
}
|
|
}
|
|
|
|
// Networked + anchored: every pairing after the anchor is written PENDING.
|
|
func TestAnchoredBoxWritesNewPairingsPending(t *testing.T) {
|
|
setupBox(t)
|
|
cfgs, _ := loadPairings()
|
|
_, st, err := pairPrecheck(cfgs, "member", false, false)
|
|
if err != nil || st != pairingPending {
|
|
t.Fatalf("a pairing after the anchor must be pending: state %q err %v", st, err)
|
|
}
|
|
// claim-anchor's own redeem is never pending (forceActive)
|
|
_, st, err = pairPrecheck(nil, "anchor", false, true)
|
|
if err != nil || st != pairingActive {
|
|
t.Fatalf("the anchor's own redeem must be active: state %q err %v", st, err)
|
|
}
|
|
// --replace keeps the current state: a credential rotation is not an approval
|
|
writePairingState(t, tMember, pairingPending)
|
|
cfgs, _ = loadPairings()
|
|
_, st, err = pairPrecheck(cfgs, "member", true, false)
|
|
if err != nil || st != pairingPending {
|
|
t.Fatalf("--replace must keep a pending pairing pending: state %q err %v", st, err)
|
|
}
|
|
}
|
|
|
|
func TestSetModeNetworkedNeedsRoot(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if os.Geteuid() == 0 {
|
|
if err := setBoxMode(modeNetworked); err != nil || boxMode() != modeNetworked {
|
|
t.Fatalf("root must be able to set networked: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err := setBoxMode(modeNetworked); err == nil {
|
|
t.Fatal("networked mode must need root")
|
|
}
|
|
if boxMode() != modeLocal {
|
|
t.Fatal("a refused set-mode must not have written anything")
|
|
}
|
|
}
|
|
|
|
// ── a pending pairing is a file ───────────────────────────────────────────
|
|
|
|
// The guard above everything in runPairing: a pending pairing returns before
|
|
// a device, a listener, a lane or even a directory exists. The dirs are the
|
|
// observable half — contextRoot()/dcnDir() create on read, so if runPairing
|
|
// had reached them the temp config dir would show context/ and dcn/.
|
|
func TestPendingPairingStartsNothing(t *testing.T) {
|
|
setupBox(t)
|
|
cd, _ := configDir()
|
|
cfg := &Config{HostName: tMember, State: pairingPending, BastionTunnelIP: "fd00:9::a:2",
|
|
ContextRoot: filepath.Join(cd, "context", tMember), DCNDir: filepath.Join(cd, "dcn", tMember),
|
|
ListenAddr: "127.0.0.1:0"}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := runPairing(ctx, cfg); err != errPairingNotActive {
|
|
t.Fatalf("runPairing on a pending pairing must return errPairingNotActive, got %v", err)
|
|
}
|
|
for _, d := range []string{cfg.ContextRoot, cfg.DCNDir} {
|
|
if _, err := os.Stat(d); err == nil {
|
|
t.Errorf("a pending pairing created %s — it must create nothing", d)
|
|
}
|
|
}
|
|
if startPairing(cfg) {
|
|
t.Fatal("startPairing must refuse a pending pairing")
|
|
}
|
|
cfg.State = pairingRevoked
|
|
if err := runPairing(ctx, cfg); err != errPairingNotActive {
|
|
t.Fatalf("a revoked pairing must not start either, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ── grant verify ───────────────────────────────────────────────────────────
|
|
|
|
func TestPairingGrantSyncWalls(t *testing.T) {
|
|
priv := setupBox(t)
|
|
writePairingState(t, tMember, pairingPending)
|
|
hn, _ := os.Hostname()
|
|
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
|
|
// good: pending -> active, grant persisted, caps reported
|
|
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe", "dcn"}, future))
|
|
out, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig})
|
|
if code != 0 {
|
|
t.Fatalf("a good grant must be accepted: exit %d, %s", code, errOut)
|
|
}
|
|
if loadState(t, tMember) != pairingActive {
|
|
t.Fatal("grant_sync must flip the pairing active")
|
|
}
|
|
if !contains(out, `"caps":["dcn","observe"]`) {
|
|
t.Fatalf("result must carry the caps: %s", out)
|
|
}
|
|
if caps, ok := pairingCaps(tMember); !ok || len(caps) != 2 {
|
|
t.Fatalf("the persisted grant must verify and carry caps: %v %v", caps, ok)
|
|
}
|
|
|
|
// bad key: a grant from anyone but the pinned anchor host
|
|
_, otherPriv, _ := ed25519.GenerateKey(nil)
|
|
writePairingState(t, "x.host.privatae.ai", pairingPending)
|
|
terms, sig = signTerms(t, otherPriv, grantTerms("x.host.privatae.ai", []string{"observe"}, future))
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "signature") {
|
|
t.Fatalf("a grant under another key must be refused (126): %d %s", code, errOut)
|
|
}
|
|
if loadState(t, "x.host.privatae.ai") != pairingPending {
|
|
t.Fatal("a refused grant must leave the pairing pending")
|
|
}
|
|
|
|
// wrong server: signed for another machine
|
|
wrong := grantTerms("x.host.privatae.ai", []string{"observe"}, future)
|
|
wrong["server"] = hn + "-not"
|
|
terms, sig = signTerms(t, priv, wrong)
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "server") {
|
|
t.Fatalf("a grant for another server must be refused: %d %s", code, errOut)
|
|
}
|
|
|
|
// wrong anchor: names a different anchor host
|
|
wrong = grantTerms("x.host.privatae.ai", []string{"observe"}, future)
|
|
wrong["anchor"] = "someone-else.host.privatae.ai"
|
|
terms, sig = signTerms(t, priv, wrong)
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "anchor") {
|
|
t.Fatalf("a grant naming another anchor must be refused: %d %s", code, errOut)
|
|
}
|
|
|
|
// expired
|
|
past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
|
|
terms, sig = signTerms(t, priv, grantTerms("x.host.privatae.ai", []string{"observe"}, past))
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "expired") {
|
|
t.Fatalf("an expired grant must be refused: %d %s", code, errOut)
|
|
}
|
|
|
|
// not pending: an unknown subject, and a revoked one
|
|
terms, sig = signTerms(t, priv, grantTerms("ghost.host.privatae.ai", []string{"observe"}, future))
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "not paired") {
|
|
t.Fatalf("a grant for an unpaired subject must be refused: %d %s", code, errOut)
|
|
}
|
|
writePairingState(t, "gone.host.privatae.ai", pairingRevoked)
|
|
terms, sig = signTerms(t, priv, grantTerms("gone.host.privatae.ai", []string{"observe"}, future))
|
|
if _, errOut, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 || !contains(errOut, "revoked") {
|
|
t.Fatalf("a grant for a revoked subject must be refused: %d %s", code, errOut)
|
|
}
|
|
|
|
// the anchor itself holds no grant
|
|
terms, sig = signTerms(t, priv, grantTerms(tAnchor, []string{"observe"}, future))
|
|
if _, _, code = driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 {
|
|
t.Fatalf("a grant for the anchor itself must be refused: %d", code)
|
|
}
|
|
}
|
|
|
|
// pairing_* ops answer ONLY on the anchor pairing's connection. A member — or
|
|
// an anonymous caller — asking is refused before any handler runs.
|
|
func TestPairingOpsRefusedOffTheAnchorConnection(t *testing.T) {
|
|
setupBox(t)
|
|
writePairingState(t, tMember, pairingActive)
|
|
for _, self := range []string{tMember, ""} {
|
|
if _, errOut, code := driveOp(t, self, "pairing_list", nil); code != 126 || !contains(errOut, "anchor") {
|
|
t.Fatalf("pairing_list from %q must be refused: %d %s", self, code, errOut)
|
|
}
|
|
}
|
|
out, _, code := driveOp(t, tAnchor, "pairing_list", nil)
|
|
if code != 0 || !contains(out, tMember) || !contains(out, `"anchor_host":"`+tAnchor+`"`) {
|
|
t.Fatalf("pairing_list from the anchor must list pairings + anchor: %d %s", code, out)
|
|
}
|
|
}
|
|
|
|
// ── drop ───────────────────────────────────────────────────────────────────
|
|
|
|
// A revoke marks the pairing revoked, keeps the file, and cancels the running
|
|
// pairing's context at once. The supervisor is driven with a stand-in run so
|
|
// the test observes the cancel without a real tunnel.
|
|
func TestPairingGrantDropTearsDown(t *testing.T) {
|
|
priv := setupBox(t)
|
|
writePairingState(t, tMember, pairingActive)
|
|
hn, _ := os.Hostname()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan struct{})
|
|
supervisor.Lock()
|
|
supervisor.runs = map[string]*pairingRun{tMember: {cancel: cancel, done: done}}
|
|
supervisor.Unlock()
|
|
t.Cleanup(func() {
|
|
supervisor.Lock()
|
|
supervisor.runs = nil
|
|
supervisor.Unlock()
|
|
})
|
|
go func() { <-ctx.Done(); close(done) }()
|
|
|
|
terms, sig := signTerms(t, priv, map[string]any{"anchor": tAnchor, "issued_at": time.Now().UTC().Format(time.RFC3339),
|
|
"kind": "pairing_revoke", "server": hn, "subject": tMember})
|
|
// subject arg must match the signed subject
|
|
if _, _, code := driveOp(t, tAnchor, "pairing_grant_drop", []string{"other.host.privatae.ai", terms, sig}); code != 126 {
|
|
t.Fatalf("a revoke whose arg names another subject must be refused: %d", code)
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatal("a refused revoke must not have torn anything down")
|
|
}
|
|
if _, err := addBackupShare(tMember, t.TempDir(), t.TempDir(), "old host"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, errOut, code := driveOp(t, tAnchor, "pairing_grant_drop", []string{tMember, terms, sig})
|
|
if code != 0 {
|
|
t.Fatalf("a good revoke must be accepted: %d %s", code, errOut)
|
|
}
|
|
if len(sharesFor(tMember)) != 0 || !contains(out, `"backup_shares_dropped":true`) {
|
|
t.Fatalf("a revoked pairing kept the backup folders shared with it: %s", out)
|
|
}
|
|
if ctx.Err() == nil {
|
|
t.Fatal("drop must cancel the pairing's context immediately")
|
|
}
|
|
if !contains(out, `"stopped":true`) {
|
|
t.Fatalf("drop must report it stopped the running pairing: %s", out)
|
|
}
|
|
if loadState(t, tMember) != pairingRevoked {
|
|
t.Fatal("drop must mark the pairing revoked")
|
|
}
|
|
pd, _ := pairingsDir()
|
|
if _, err := os.Stat(filepath.Join(pd, tMember+".json")); err != nil {
|
|
t.Fatal("drop must keep the pairing file")
|
|
}
|
|
if _, ok := pairingCaps(tMember); ok {
|
|
t.Fatal("a revoked pairing must hold no caps")
|
|
}
|
|
// the anchor cannot revoke itself
|
|
terms, sig = signTerms(t, priv, map[string]any{"anchor": tAnchor, "issued_at": time.Now().UTC().Format(time.RFC3339),
|
|
"kind": "pairing_revoke", "server": hn, "subject": tAnchor})
|
|
if _, _, code := driveOp(t, tAnchor, "pairing_grant_drop", []string{tAnchor, terms, sig}); code != 126 {
|
|
t.Fatalf("the anchor revoking itself must be refused: %d", code)
|
|
}
|
|
}
|
|
|
|
// ── caps ceiling ───────────────────────────────────────────────────────────
|
|
|
|
func TestCapsRefusal(t *testing.T) {
|
|
priv := setupBox(t)
|
|
writePairingState(t, tMember, pairingPending)
|
|
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
|
|
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
|
|
t.Fatalf("grant: %d %s", code, errOut)
|
|
}
|
|
|
|
// observe family passes the ceiling and reaches the handler (uptime runs)
|
|
if _, errOut, code := driveOp(t, tMember, "uptime", nil); code == 126 {
|
|
t.Fatalf("an op inside caps must not be refused by the ceiling: %s", errOut)
|
|
}
|
|
// dcn family is outside the ceiling → refused before the handler
|
|
if _, errOut, code := driveOp(t, tMember, "dcn_list", []string{"1", "{}"}); code != 126 || !contains(errOut, "caps") {
|
|
t.Fatalf("an op outside caps must be refused with the caps quoted: %d %s", code, errOut)
|
|
}
|
|
if _, errOut, code := driveOp(t, tMember, "ctx_get", []string{"k"}); code != 126 || !contains(errOut, "caps") {
|
|
t.Fatalf("ctx_* is the dcn family and must be refused: %d %s", code, errOut)
|
|
}
|
|
// stepup_* IS caps-gated, on the family of the thing being
|
|
// approved rather than on a family of its own (stepupOpFamily). With no DCN
|
|
// root configured there is no request store to read a kind from, so this one
|
|
// reaches the handler and is refused for the unconfigured root — a different
|
|
// reason, and the distinction is the point. The gating itself is covered by
|
|
// TestStepupIsGatedOnTheApprovedFamily.
|
|
if _, errOut, _ := driveOp(t, tMember, "stepup_status", []string{"x"}); contains(errOut, "caps") {
|
|
t.Fatalf("stepup_status with no request store must reach the handler: %s", errOut)
|
|
}
|
|
// the anchor pairing is unconstrained
|
|
if _, errOut, code := driveOp(t, tAnchor, "dcn_list", []string{"1", "{}"}); code == 126 && contains(errOut, "caps") {
|
|
t.Fatalf("the anchor pairing must not be caps-gated: %s", errOut)
|
|
}
|
|
// a pairing with no grant on an anchored box is default-deny
|
|
writePairingState(t, "nogrant.host.privatae.ai", pairingActive)
|
|
if why := capsAllow("nogrant.host.privatae.ai", "uptime"); why == "" {
|
|
t.Fatal("a pairing holding no grant must be refused every op")
|
|
}
|
|
// the enforcing report states the ceiling
|
|
rep := pairingEnforcing(tMember)
|
|
if caps, _ := rep["pairing_caps"].([]string); len(caps) != 1 || caps[0] != "observe" {
|
|
t.Fatalf("enforcing must report pairing_caps: %+v", rep)
|
|
}
|
|
if rep["pairing_state"] != pairingActive {
|
|
t.Fatalf("enforcing must report pairing_state: %+v", rep)
|
|
}
|
|
// tampering the grant file authorises nothing: widen the caps inside the
|
|
// canonical bytes and leave the signature — it no longer verifies
|
|
g, err := loadPairingGrant(tMember)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical, _ := base64.StdEncoding.DecodeString(g.TermsCanonical)
|
|
g.TermsCanonical = base64.StdEncoding.EncodeToString([]byte(strings.Replace(string(canonical), `"observe"`, `"session"`, 1)))
|
|
if err := savePairingGrant(tMember, g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := pairingCaps(tMember); ok {
|
|
t.Fatal("a hand-edited grant file must not verify")
|
|
}
|
|
if why := capsAllow(tMember, "uptime"); why == "" {
|
|
t.Fatal("a pairing whose grant no longer verifies must be refused every op")
|
|
}
|
|
}
|
|
|
|
func TestOpFamilies(t *testing.T) {
|
|
for op, want := range map[string]string{
|
|
"observe:sockets": "observe", "computer:windows": "observe", "uptime": "observe",
|
|
"session:open": "session", "dcn_list": "dcn", "ctx_put": "dcn", "backup_fetch": "dcn",
|
|
// ctx_extract runs a PROGRAM, so its family is the one thing standing
|
|
// between a member pairing and an extractor on this machine. opFamily
|
|
// returns "" for a name it does not know and capsAllow then allows —
|
|
// so this row is the guard against a rename opening that door.
|
|
"ctx_extract": "dcn",
|
|
"harness:run": "harness", "mode:lab": "harness", "egress": "egress",
|
|
// Neither of these is grantable, and that is what they are for.
|
|
// capsAllowIn default-denies an op in no family, so leaving either
|
|
// unclassified would refuse it everywhere; classifying them into
|
|
// families absent from allCaps refuses only the pairings that should
|
|
// never reach them. stepup_* has its real ceiling resolved per request
|
|
// (stepupOpFamily); pairing_* is refused by the anchor rule first.
|
|
"stepup_request": capsFamilyStepup, "pairing_list": capsFamilyAnchor,
|
|
} {
|
|
if got := opFamily(op); got != want {
|
|
t.Errorf("opFamily(%q) = %q, want %q", op, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── rotation ───────────────────────────────────────────────────────────────
|
|
|
|
func TestAnchorRotationPaths(t *testing.T) {
|
|
priv := setupBox(t)
|
|
prev := currentAnchor()
|
|
hn, _ := os.Hostname()
|
|
now := time.Now()
|
|
|
|
// fresh anchor, no consent: refused
|
|
if _, err := rotationPermitted(prev, "next", now); err == nil {
|
|
t.Fatal("rotation without consent from a live anchor must be refused")
|
|
}
|
|
// silence path: 15 days after the last successful beat, root alone may rotate
|
|
recordPairingOK(tAnchor)
|
|
if _, err := rotationPermitted(prev, "next", now.Add(15*24*time.Hour)); err != nil {
|
|
t.Fatalf("14 days of silence must permit rotation: %v", err)
|
|
}
|
|
if _, err := rotationPermitted(prev, "next", now.Add(13*24*time.Hour)); err == nil {
|
|
t.Fatal("13 days of silence must NOT permit rotation")
|
|
}
|
|
|
|
// consent path: the anchor's signed word, delivered by the op, names the next anchor
|
|
terms, sig := signTerms(t, priv, map[string]any{"anchor": tAnchor, "issued_at": now.UTC().Format(time.RFC3339),
|
|
"kind": "anchor_consent", "server": hn, "subject": "next.host.privatae.ai"})
|
|
if _, errOut, code := driveOp(t, tMember, "pairing_anchor_consent", []string{terms, sig}); code != 126 {
|
|
t.Fatalf("consent from a non-anchor connection must be refused: %d %s", code, errOut)
|
|
}
|
|
if _, errOut, code := driveOp(t, tAnchor, "pairing_anchor_consent", []string{terms, sig}); code != 0 {
|
|
t.Fatalf("consent from the anchor must be stored: %d %s", code, errOut)
|
|
}
|
|
c, err := rotationPermitted(prev, "next", now)
|
|
if err != nil || c == nil || c.Subject != "next.host.privatae.ai" {
|
|
t.Fatalf("a stored consent naming the token's host must permit rotation: %v %+v", err, c)
|
|
}
|
|
if _, err := rotationPermitted(prev, "someone-else", now); err == nil {
|
|
t.Fatal("consent naming another host must not permit rotation to this one")
|
|
}
|
|
if _, err := rotationPermitted(prev, "next", now.Add(31*24*time.Hour)); err == nil {
|
|
t.Fatal("a consent older than 30 days must not be usable")
|
|
}
|
|
// a consent signed by anyone else is refused at the op
|
|
_, other, _ := ed25519.GenerateKey(nil)
|
|
terms, sig = signTerms(t, other, map[string]any{"anchor": tAnchor, "issued_at": now.UTC().Format(time.RFC3339),
|
|
"kind": "anchor_consent", "server": hn, "subject": "next.host.privatae.ai"})
|
|
if _, _, code := driveOp(t, tAnchor, "pairing_anchor_consent", []string{terms, sig}); code != 126 {
|
|
t.Fatalf("a consent under another key must be refused: %d", code)
|
|
}
|
|
}
|
|
|
|
// claim-anchor's own walls that need no network: root, mode, already anchored.
|
|
func TestClaimAnchorWalls(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
// cmdClaimAnchor runs singletonGuardForPair BEFORE the mode check, and
|
|
// deliberately so (anchor.go:729-736: both later calls would MkdirAll a
|
|
// forked config dir first). XDG_CONFIG_HOME does not reach the machine
|
|
// marker — that path is machine-scoped (/var/lib/privatae/bastion) — so
|
|
// without this the guard reads the REAL marker of whatever bastion is
|
|
// installed here, refuses with "another bastion owns this machine", and
|
|
// the mode assertion below never runs. Only bites as root on a box that
|
|
// HAS a bastion, which is why it passes on a clean builder and fails on
|
|
// any machine that already runs one.
|
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
|
token := "skb_" + base64.RawURLEncoding.EncodeToString([]byte("next.secret"))
|
|
if os.Geteuid() != 0 {
|
|
if err := cmdClaimAnchor(token, false, "", ""); err == nil || !contains(err.Error(), "machine authority") {
|
|
t.Fatalf("claim-anchor must need machine authority: %v", err)
|
|
}
|
|
return
|
|
}
|
|
// root: local mode refuses
|
|
if err := cmdClaimAnchor(token, false, "", ""); err == nil || !contains(err.Error(), "networked") {
|
|
t.Fatalf("claim-anchor in local mode must be refused: %v", err)
|
|
}
|
|
_ = setBoxMode(modeNetworked)
|
|
writePairing(t, tAnchor)
|
|
_ = writeAnchor("Acme", tAnchor)
|
|
if err := cmdClaimAnchor(token, false, "", ""); err == nil || !contains(err.Error(), "already anchored") {
|
|
t.Fatalf("a second claim-anchor without --rotate must be refused: %v", err)
|
|
}
|
|
if err := cmdClaimAnchor(token, true, "", ""); err == nil || !contains(err.Error(), "consent") {
|
|
t.Fatalf("--rotate on a live anchor without consent must be refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// The no-token path: anchoring a box you have ALREADY paired.
|
|
//
|
|
// This is the flow the product actually asks for — pair, set-mode networked,
|
|
// claim-anchor — and until 0.23.0 the third step demanded a second pairing
|
|
// token, minted from the same "Set up a bastion" button, redeemed against a
|
|
// machine that was already paired. Nothing surfaced that, and on a box with one
|
|
// pairing the token proved nothing the pairing had not already proved.
|
|
func TestClaimAnchorWithNoToken(t *testing.T) {
|
|
// Each phase gets its OWN box: a fresh config dir AND a fresh machine marker.
|
|
// They travel together — the marker records which state dir owns the machine,
|
|
// so moving XDG_CONFIG_HOME while leaving the marker behind makes the
|
|
// singleton guard refuse the new dir as a second bastion (which is exactly
|
|
// what it is for).
|
|
freshBox := func(t *testing.T, networked bool) {
|
|
t.Helper()
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
|
if networked {
|
|
if err := setBoxMode(modeNetworked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if os.Geteuid() != 0 {
|
|
freshBox(t, false)
|
|
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "machine authority") {
|
|
t.Fatalf("claim-anchor must need machine authority whether or not a token is given: %v", err)
|
|
}
|
|
t.Skip("the rest needs root: without machine authority claim-anchor stops at the refusal checked above")
|
|
}
|
|
|
|
t.Run("local mode refuses — the token is not what gates this", func(t *testing.T) {
|
|
freshBox(t, false)
|
|
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "networked") {
|
|
t.Fatalf("no-token claim-anchor in local mode must be refused: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("no pairing at all — refused, and told how to get one", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "no pairing yet") {
|
|
t.Fatalf("an unpaired box must refuse and say to pair first: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("--rotate still needs a token: it names a different host", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmdClaimAnchor("", true, "", ""); err == nil || !contains(err.Error(), "--rotate needs a token") {
|
|
t.Fatalf("--rotate with no token must be refused: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("one pairing, no token — anchors to it", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairingState(t, tMember, pairingPending)
|
|
if err := cmdClaimAnchor("", false, "Acme IT", ""); err != nil {
|
|
t.Fatalf("a box with one pairing must anchor with no token: %v", err)
|
|
}
|
|
a := currentAnchor()
|
|
if a == nil || a.AnchorHost != tMember {
|
|
t.Fatalf("the sole pairing must become the anchor: %+v", a)
|
|
}
|
|
if a.Owner != "Acme IT" {
|
|
t.Fatalf("--owner must still be honoured on the no-token path: %+v", a)
|
|
}
|
|
// "the anchor never waits on itself" — the same rule the forceActive
|
|
// redeem applies, and why a pending sole pairing is not left pending.
|
|
if got := loadState(t, tMember); got != pairingActive {
|
|
t.Fatalf("anchoring must flip the sole pairing active, got %q", got)
|
|
}
|
|
// and the owner claim is re-signed, exactly as the token path does
|
|
if oc := currentOwnerClaim(); oc == nil || oc.Owner != "Acme IT" {
|
|
t.Fatalf("claim-anchor must write the owner claim: %+v", oc)
|
|
}
|
|
})
|
|
|
|
t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
|
|
// `pair` anchors a networked box by itself, and an installer that runs
|
|
// claim-anchor afterwards must find that done, not an error.
|
|
freshBox(t, true)
|
|
writePairing(t, tMember)
|
|
if err := writeAnchor("Acme", tMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
|
t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
|
|
}
|
|
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
|
|
t.Fatalf("naming the anchor host itself must succeed: %v", err)
|
|
}
|
|
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
|
|
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
|
|
t.Fatalf("the anchor must be untouched: %+v", a)
|
|
}
|
|
})
|
|
|
|
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
|
|
// A success here would tell an installer the anchor holds while its
|
|
// host has no active lanes on this box.
|
|
for _, st := range []string{pairingPending, pairingRevoked} {
|
|
freshBox(t, true)
|
|
writePairingState(t, tMember, st)
|
|
if err := writeAnchor("Acme", tMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmdClaimAnchor("", false, "", ""); err == nil {
|
|
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
|
|
}
|
|
if got := loadState(t, tMember); got != st {
|
|
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairing(t, tMember)
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme", tMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
|
|
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
|
t.Fatalf("the anchor must be untouched: %+v", a)
|
|
}
|
|
})
|
|
|
|
t.Run("a REVOKED sole pairing is not reinstated", func(t *testing.T) {
|
|
// cfg.active() is false for pending AND revoked, so a bare !active() flip
|
|
// would quietly undo a revocation. pair.go holds the same line for a
|
|
// re-pair: pending again, never straight back to active.
|
|
freshBox(t, true)
|
|
writePairingState(t, tMember, pairingRevoked)
|
|
err := cmdClaimAnchor("", false, "", "")
|
|
if err == nil || !contains(err.Error(), "REVOKED") {
|
|
t.Fatalf("a revoked sole pairing must be refused, not reinstated: %v", err)
|
|
}
|
|
if got := loadState(t, tMember); got != pairingRevoked {
|
|
t.Fatalf("the refusal must leave it revoked, got %q", got)
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a refused claim must leave the box unanchored")
|
|
}
|
|
})
|
|
|
|
t.Run("a bad --owner refuses BEFORE touching the pairing", func(t *testing.T) {
|
|
// The flip is the only state this path writes, so it must be the last
|
|
// thing that happens: a refusal after it leaves a half-applied command.
|
|
freshBox(t, true)
|
|
writePairingState(t, tMember, pairingPending)
|
|
err := cmdClaimAnchor("", false, strings.Repeat("x", 400), "") // claimNameOK caps at 120
|
|
if err == nil || !contains(err.Error(), "owner") {
|
|
t.Fatalf("a bad --owner must be refused: %v", err)
|
|
}
|
|
if got := loadState(t, tMember); got != pairingPending {
|
|
t.Fatalf("a refused claim must not have flipped the pairing, got %q", got)
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a refused claim must leave the box unanchored")
|
|
}
|
|
})
|
|
|
|
// THE SHARED MACHINE — the case networked mode exists for, and the one that
|
|
// failed on the first real box: a real desktop serves three hosts, so the
|
|
// first cut of the no-token path refused it and sent the user back for a token.
|
|
t.Run("several pairings: --host names the anchor, no token", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairing(t, tAnchor)
|
|
writePairing(t, tMember)
|
|
writePairing(t, "third.host.privatae.ai")
|
|
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
|
|
t.Fatalf("--host must anchor a multi-pairing box with no token: %v", err)
|
|
}
|
|
a := currentAnchor()
|
|
if a == nil || a.AnchorHost != tMember {
|
|
t.Fatalf("--host must pick THAT host: %+v", a)
|
|
}
|
|
// The others keep the lanes they already had — anchoring governs what
|
|
// happens NEXT, it does not retroactively suspend existing pairings.
|
|
for _, h := range []string{tAnchor, "third.host.privatae.ai"} {
|
|
if got := loadState(t, h); got != pairingActive {
|
|
t.Fatalf("%s must keep its lanes, got %q", h, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("--host accepts the bare handle, not just the full name", func(t *testing.T) {
|
|
// The error prints full names; Circles shows handles. Whichever the user
|
|
// copied has to work, or the instruction is only half true.
|
|
freshBox(t, true)
|
|
writePairing(t, tAnchor)
|
|
writePairing(t, tMember)
|
|
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
|
|
t.Fatalf("--host must accept the handle alone: %v", err)
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
|
t.Fatalf("the handle must resolve to that pairing: %+v", a)
|
|
}
|
|
})
|
|
|
|
t.Run("--host naming a host this box does not serve is refused", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairing(t, tAnchor)
|
|
writePairing(t, tMember)
|
|
err := cmdClaimAnchor("", false, "", "nobody.host.privatae.ai")
|
|
if err == nil || !contains(err.Error(), "not one of this box's pairings") {
|
|
t.Fatalf("--host must only choose among EXISTING pairings: %v", err)
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a refused claim must leave the box unanchored")
|
|
}
|
|
})
|
|
|
|
t.Run("--host with a token is refused rather than silently ignored", func(t *testing.T) {
|
|
// Both answer "which host". Picking one would discard the other, and
|
|
// discarding the token would BURN it — it is single-use and redeemable.
|
|
freshBox(t, true)
|
|
writePairing(t, tMember)
|
|
tok := "skb_" + base64.RawURLEncoding.EncodeToString([]byte("next.secret"))
|
|
err := cmdClaimAnchor(tok, false, "", tMember)
|
|
if err == nil || !contains(err.Error(), "pass one") {
|
|
t.Fatalf("--host plus a token must be refused: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("two pairings, no token — refused, and told how to choose", func(t *testing.T) {
|
|
freshBox(t, true)
|
|
writePairing(t, tAnchor)
|
|
writePairing(t, tMember)
|
|
err := cmdClaimAnchor("", false, "", "")
|
|
if err == nil || !contains(err.Error(), "--host") {
|
|
t.Fatalf("two pairings with no token must be refused WITH the way out: %v", err)
|
|
}
|
|
// the refusal must NAME them, or the user cannot act on it
|
|
if !contains(err.Error(), tAnchor) || !contains(err.Error(), tMember) {
|
|
t.Fatalf("the ambiguity refusal must name both hosts: %v", err)
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a refused claim must leave the box unanchored")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── the anchor claim itself ────────────────────────────────────────────────
|
|
|
|
func TestAnchorClaimIsSignedByTheBoxKeyAndTamperIsAbsent(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if currentAnchor() != nil {
|
|
t.Fatal("an unanchored box reports no anchor")
|
|
}
|
|
if err := writeAnchor("Acme IT", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := currentAnchor()
|
|
if a == nil || a.AnchorHost != tAnchor || a.AnchorHandle != "anchor" || a.Owner != "Acme IT" {
|
|
t.Fatalf("anchor did not round-trip: %+v", a)
|
|
}
|
|
// same key as the owner claim: one box, one key
|
|
if _, err := writeOwnerClaim("Acme IT"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if oc := currentOwnerClaim(); oc == nil || oc.PublicKey != a.PublicKey {
|
|
t.Fatal("anchor and owner claims must share the box key")
|
|
}
|
|
// hand-edit the anchor host: the signature covers it, so the claim vanishes
|
|
p, _ := anchorPath()
|
|
raw, _ := os.ReadFile(p)
|
|
_ = os.WriteFile(p, []byte(strings.Replace(string(raw), tAnchor, "evil.host.privatae.ai", 1)), 0o600)
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a tampered anchor must read as no anchor")
|
|
}
|
|
}
|
|
|
|
// The verify key is pinned TOFU from the anchor pairing's DCN pin when the
|
|
// anchor file has none, and a different key is refused once one is held.
|
|
func TestAnchorVerifyKeyPinsFromDCNAndRefusesRepin(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
writePairing(t, tAnchor)
|
|
_ = writeAnchor("Acme", tAnchor)
|
|
if anchorVerifyKey() != "" {
|
|
t.Fatal("a fresh anchor has no verify key")
|
|
}
|
|
pub, _, _ := ed25519.GenerateKey(nil)
|
|
pinned := base64.StdEncoding.EncodeToString(pub)
|
|
cfgs, _ := loadPairings()
|
|
ac := pairingFor(cfgs, tAnchor)
|
|
if err := dcnSaveFile(ac.dcnDir(), &dcnGrantFile{VerifyPubkey: pinned, Stores: map[string]dcnGrantRec{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if anchorVerifyKey() != pinned {
|
|
t.Fatal("the anchor verify key must be pinned from the anchor pairing's DCN pin")
|
|
}
|
|
other, _, _ := ed25519.GenerateKey(nil)
|
|
if err := pinAnchorVerifyKey(base64.StdEncoding.EncodeToString(other)); err == nil {
|
|
t.Fatal("a different key must not re-pin")
|
|
}
|
|
if err := pinAnchorVerifyKey(pinned); err != nil {
|
|
t.Fatalf("re-pinning the same key is a no-op: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── heartbeat ──────────────────────────────────────────────────────────────
|
|
|
|
func TestHeartbeatCarriesModeAnchorPairingsAndAttempts(t *testing.T) {
|
|
// local, unanchored: mode rides, nothing else
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
body := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, `"mode":"local"`) {
|
|
t.Fatalf("mode must ride every heartbeat:\n%s", body)
|
|
}
|
|
for _, k := range []string{`"anchor"`, `"pairings"`} {
|
|
if contains(body, k) {
|
|
t.Fatalf("an unanchored box must not carry %s:\n%s", k, body)
|
|
}
|
|
}
|
|
// enumerated and none: an explicit [] (the host COALESCEs an absent key)
|
|
if !contains(body, `"unapproved_pairing_attempts":[]`) {
|
|
t.Fatalf("an enumerated-but-empty attempt set must travel as []:\n%s", body)
|
|
}
|
|
|
|
// anchored, with a pending member
|
|
setupBox(t)
|
|
writePairingState(t, tMember, pairingPending)
|
|
var got struct {
|
|
Mode string `json:"mode"`
|
|
Anchor *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"`
|
|
VerifyPubkey string `json:"verify_pubkey"`
|
|
} `json:"anchor"`
|
|
Pairings []pairingEntry `json:"pairings"`
|
|
Attempts []peerAnnounce `json:"unapproved_pairing_attempts"`
|
|
}
|
|
// the anchor's own beat: full list including itself and the pending member
|
|
body = buildHeartbeatBody(tAnchor, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Mode != modeNetworked {
|
|
t.Fatalf("mode = %q", got.Mode)
|
|
}
|
|
if got.Anchor == nil || got.Anchor.AnchorHost != tAnchor || got.Anchor.Signature == "" || got.Anchor.PublicKey == "" {
|
|
t.Fatalf("anchor must ride with all six fields: %+v", got.Anchor)
|
|
}
|
|
if got.Anchor.VerifyPubkey != "" {
|
|
t.Fatal("the pinned verify key must stay on the box")
|
|
}
|
|
if len(got.Pairings) != 2 {
|
|
t.Fatalf("the anchor's beat must list every pairing including itself: %+v", got.Pairings)
|
|
}
|
|
states := map[string]string{}
|
|
for _, p := range got.Pairings {
|
|
states[p.HostName] = p.State
|
|
if p.Since == "" || p.Handle == "" {
|
|
t.Errorf("pairing entry incomplete: %+v", p)
|
|
}
|
|
}
|
|
if states[tAnchor] != pairingActive || states[tMember] != pairingPending {
|
|
t.Fatalf("states wrong: %v", states)
|
|
}
|
|
// a fresh pending pairing is not yet an unapproved attempt (10 min grace) —
|
|
// and "none" is an explicit [], never an absent key
|
|
if got.Attempts == nil || len(got.Attempts) != 0 || !contains(body, `"unapproved_pairing_attempts":[]`) {
|
|
t.Fatalf("a pairing pending for seconds is not an attempt yet, and none must travel as []: %+v", got.Attempts)
|
|
}
|
|
|
|
// a member's beat: anchor yes, pairings list no
|
|
got.Pairings, got.Anchor = nil, nil
|
|
body = buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
_ = json.Unmarshal([]byte(body), &got)
|
|
if got.Anchor == nil {
|
|
t.Fatal("the anchor claim rides every pairing's beat")
|
|
}
|
|
if got.Pairings != nil {
|
|
t.Fatal("the pairings list is for the anchor host only")
|
|
}
|
|
|
|
// age the pending file past 10 minutes: every beat reports the attempt
|
|
pd, _ := pairingsDir()
|
|
old := time.Now().Add(-11 * time.Minute)
|
|
_ = os.Chtimes(filepath.Join(pd, tMember+".json"), old, old)
|
|
got.Attempts = nil
|
|
body = buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
_ = json.Unmarshal([]byte(body), &got)
|
|
if len(got.Attempts) != 1 || got.Attempts[0].HostName != tMember || got.Attempts[0].Since == "" {
|
|
t.Fatalf("a pairing pending past 10 minutes must be reported on every beat: %+v", got.Attempts)
|
|
}
|
|
// approve it: the next beat must RETRACT with [], or the host keeps X forever
|
|
writePairingState(t, tMember, pairingActive)
|
|
body = buildHeartbeatBody(tAnchor, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, `"unapproved_pairing_attempts":[]`) {
|
|
t.Fatalf("after approval the attempt list must travel as [] so the host can clear it:\n%s", body)
|
|
}
|
|
// the one case that is genuinely absent: the pairings cannot be enumerated
|
|
if os.Geteuid() != 0 {
|
|
pd, _ := pairingsDir()
|
|
if err := os.Chmod(pd, 0o000); err == nil {
|
|
t.Cleanup(func() { _ = os.Chmod(pd, 0o700) })
|
|
if unapprovedAttempts(time.Now()) != nil {
|
|
t.Fatal("an unreadable pairings dir must yield nil (key absent), never []")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The enforcing report a running pairing sends gains its state and caps.
|
|
func TestEnforcingReportCarriesPairingCaps(t *testing.T) {
|
|
setupBox(t)
|
|
rep := buildEnforcingReport("", nil)
|
|
for k, v := range pairingEnforcing(tAnchor) {
|
|
rep[k] = v
|
|
}
|
|
raw := buildHeartbeatBody(tAnchor, "box", "linux", "test", "pk", "fp", "", "", false, nil, rep, nil)
|
|
var body struct {
|
|
Enforcing struct {
|
|
State string `json:"pairing_state"`
|
|
Caps []string `json:"pairing_caps"`
|
|
IsAnchor bool `json:"is_anchor"`
|
|
MgmtOps []string `json:"mgmt_ops"`
|
|
} `json:"enforcing"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Enforcing.State != pairingActive || !body.Enforcing.IsAnchor || len(body.Enforcing.Caps) != len(allCaps) {
|
|
t.Fatalf("the anchor's enforcing report must say active, is_anchor, every cap: %+v", body.Enforcing)
|
|
}
|
|
has := false
|
|
for _, op := range body.Enforcing.MgmtOps {
|
|
has = has || op == "pairing_grant_sync"
|
|
}
|
|
if !has {
|
|
t.Fatal("the op vocabulary must include the pairing ops")
|
|
}
|
|
}
|
|
|
|
// loadPairings must never read a grant file as a pairing.
|
|
func TestGrantFileIsNotAPairing(t *testing.T) {
|
|
setupBox(t)
|
|
if err := savePairingGrant(tMember, &pairingGrantFile{TermsCanonical: "x", Signature: "y"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
t.Fatalf("a grant file broke loadPairings: %v", err)
|
|
}
|
|
if len(cfgs) != 1 || cfgs[0].HostName != tAnchor {
|
|
t.Fatalf("the grant file must be skipped: %v", hostNames(cfgs))
|
|
}
|
|
}
|
|
|
|
// ── claim-anchor --repin ────────────────────────────────────────────────────
|
|
// A host's own name can change under the box (a Host address move does
|
|
// exactly that). `pair --replace` updates the pairing but never anchor.json, and
|
|
// --rotate refuses a same-handle claim outright, so without --repin an
|
|
// anchored box names its anchor by a name that no longer exists and rejects
|
|
// every signed record naming the real one.
|
|
|
|
// Writes mode.json DIRECTLY rather than calling setBoxMode, which requires
|
|
// machine authority — the same reason setupBox does. These four tests were
|
|
// failing for every non-root run and nobody saw it, because the only machine
|
|
// that runs them is a builder running as root and there is no CI here.
|
|
// needsMachineAuthority marks a test that drives a command REQUIRING machine
|
|
// authority (not merely setting up state that does). Off-root the command
|
|
// correctly refuses, so the test cannot assert its success path.
|
|
//
|
|
// An explicit, reasoned skip — not the silent one it replaced, where the
|
|
// whole networked half of a gate test vanished off-root and the run still
|
|
// reported green. Here the skip names what is not covered and why, and the
|
|
// builder runs as root so the path IS exercised on every real run.
|
|
func needsMachineAuthority(t *testing.T) {
|
|
t.Helper()
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("drives a command that requires machine authority; the refusal path is covered by TestRepinRefusesWhenNotAnchored")
|
|
}
|
|
}
|
|
|
|
func repinBox(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
|
p, err := modePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if boxMode() != modeNetworked {
|
|
t.Fatalf("box must read networked, got %s", boxMode())
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestRepinRefusesWhenNotAnchored(t *testing.T) {
|
|
repinBox(t)
|
|
if err := cmdRepinAnchor(); err == nil {
|
|
t.Fatal("re-pinned an unanchored box")
|
|
}
|
|
}
|
|
|
|
func TestRepinRefusesWhenTheAnchorHasNoPairingHere(t *testing.T) {
|
|
needsMachineAuthority(t)
|
|
repinBox(t)
|
|
if err := writeAnchor("the owner", "acme.host.privatae.ai"); err != nil {
|
|
t.Fatalf("writeAnchor: %v", err)
|
|
}
|
|
err := cmdRepinAnchor()
|
|
if err == nil {
|
|
t.Fatal("re-pinned with no pairing for the anchor's handle")
|
|
}
|
|
if !strings.Contains(err.Error(), "re-pair it first") {
|
|
t.Fatalf("want the re-pair-first hint, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRepinIsANoOpWhenTheNameHasNotChanged(t *testing.T) {
|
|
needsMachineAuthority(t)
|
|
repinBox(t)
|
|
if err := writeAnchor("the owner", "acme.host.privatae.ai"); err != nil {
|
|
t.Fatalf("writeAnchor: %v", err)
|
|
}
|
|
c := &Config{HostName: "acme.host.privatae.ai", ListenAddr: "127.0.0.1:7878"}
|
|
if err := c.save(); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
if err := cmdRepinAnchor(); err != nil {
|
|
t.Fatalf("no-op re-pin should succeed, got %v", err)
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != "acme.host.privatae.ai" {
|
|
t.Fatalf("anchor changed on a no-op: %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestRepinMovesTheAnchorToTheNewNameAndKeepsThePin(t *testing.T) {
|
|
needsMachineAuthority(t)
|
|
repinBox(t)
|
|
if err := writeAnchor("the owner", "acme.host.privatae.ai"); err != nil {
|
|
t.Fatalf("writeAnchor: %v", err)
|
|
}
|
|
// Pin a verify key, as a live box would have after its first grant sync.
|
|
f, err := loadAnchorFile()
|
|
if err != nil {
|
|
t.Fatalf("loadAnchorFile: %v", err)
|
|
}
|
|
f.VerifyPubkey = "PINNED-KEY-B64"
|
|
if err := saveAnchorFile(f); err != nil {
|
|
t.Fatalf("saveAnchorFile: %v", err)
|
|
}
|
|
// The re-pair has already happened: the pairing now carries the NEW name.
|
|
c := &Config{HostName: "acme.host.privatae.ai", ListenAddr: "127.0.0.1:7878"}
|
|
if err := c.save(); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
if err := cmdRepinAnchor(); err != nil {
|
|
t.Fatalf("repin: %v", err)
|
|
}
|
|
|
|
a := currentAnchor()
|
|
if a == nil {
|
|
t.Fatal("anchor did not verify after re-pin")
|
|
}
|
|
if a.AnchorHost != "acme.host.privatae.ai" {
|
|
t.Fatalf("anchor_host = %q, want the new name", a.AnchorHost)
|
|
}
|
|
if a.AnchorHandle != "acme" {
|
|
t.Fatalf("anchor_handle = %q, want acme", a.AnchorHandle)
|
|
}
|
|
if a.Owner != "the owner" {
|
|
t.Fatalf("owner = %q, want the owner preserved", a.Owner)
|
|
}
|
|
// The pin is the whole difference from a rotation: same host, so the key it
|
|
// pinned is still the right key and dropping it would re-open TOFU.
|
|
after, err := loadAnchorFile()
|
|
if err != nil {
|
|
t.Fatalf("loadAnchorFile after: %v", err)
|
|
}
|
|
if after.VerifyPubkey != "PINNED-KEY-B64" {
|
|
t.Fatalf("verify pin = %q, want it carried across", after.VerifyPubkey)
|
|
}
|
|
}
|
|
|
|
// ── inference family + anchor-only directives ───────────────────────────────
|
|
|
|
// The three inbound forwards (ollama, llm, pii) were the one lane every pairing
|
|
// got regardless of its ceiling. They are now the `inference` family: a member
|
|
// outside it is refused at accept, and its heartbeat stops advertising them.
|
|
func TestInferenceLaneIsCapsGated(t *testing.T) {
|
|
priv := setupBox(t)
|
|
writePairingState(t, tMember, pairingPending)
|
|
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
|
|
// observe only: the forwards refuse, the anchor is unconstrained
|
|
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
|
|
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
|
|
t.Fatalf("grant: %d %s", code, errOut)
|
|
}
|
|
if why := capsAllow(tMember, "inference"); why == "" || !contains(why, "inference") {
|
|
t.Fatalf("a member outside the inference family must be refused the forwards, got %q", why)
|
|
}
|
|
if why := capsAllow(tAnchor, "inference"); why != "" {
|
|
t.Fatalf("the anchor pairing must reach the forwards: %s", why)
|
|
}
|
|
refuse := inferenceRefuser(tMember, "ollama")
|
|
a, b := net.Pipe()
|
|
defer b.Close()
|
|
if !refuse(a) {
|
|
t.Fatal("the refuser must close a member connection outside the family")
|
|
}
|
|
if _, err := a.Write([]byte("x")); err == nil {
|
|
t.Fatal("the refused connection must be closed")
|
|
}
|
|
|
|
// the heartbeat for that member withholds embed / llm-openai / pii even
|
|
// with an OpenAI-compatible server declared and answering
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"qwen"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("PRIVATAE_LLM_ADDR", strings.TrimPrefix(srv.URL, "http://"))
|
|
t.Setenv("PRIVATAE_PII_ADDR", strings.TrimPrefix(srv.URL, "http://"))
|
|
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
|
|
var hb struct {
|
|
Caps []string `json:"capabilities"`
|
|
LLMPort int `json:"llm_port"`
|
|
PiiPort *int `json:"pii_port"`
|
|
}
|
|
body := buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if err := json.Unmarshal([]byte(body), &hb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range hb.Caps {
|
|
if c == "embed" || c == "llm-openai" || c == "pii" {
|
|
t.Fatalf("a member outside the inference family must not advertise %s: %v", c, hb.Caps)
|
|
}
|
|
}
|
|
if hb.LLMPort != 0 || hb.PiiPort != nil {
|
|
t.Fatalf("no forward ports for a member outside the family: llm_port=%d pii_port=%v", hb.LLMPort, hb.PiiPort)
|
|
}
|
|
// the anchor's heartbeat still advertises the declared server
|
|
body = buildHeartbeatBody(tAnchor, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, `"llm-openai"`) || !contains(body, `"pii"`) {
|
|
t.Fatalf("the anchor pairing must keep advertising its inference lanes:\n%s", body)
|
|
}
|
|
|
|
// re-signed with inference: the same member now passes, with no restart
|
|
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"observe", "inference"}, future))
|
|
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
|
|
t.Fatalf("re-grant: %d %s", code, errOut)
|
|
}
|
|
if why := capsAllow(tMember, "inference"); why != "" {
|
|
t.Fatalf("a member granted inference must reach the forwards: %s", why)
|
|
}
|
|
c, d := net.Pipe()
|
|
defer c.Close()
|
|
defer d.Close()
|
|
if refuse(c) {
|
|
t.Fatal("the same refuser must follow the re-signed grant without a restart")
|
|
}
|
|
// the enforcing report names the family; the anchor's ceiling is EVERY
|
|
// family. Counted against allCaps rather than a literal: adding a family is
|
|
// a deliberate change to make, but it should not fail an unrelated test
|
|
// about the inference lane — which is exactly what the literal 6 did when
|
|
// "models" was added.
|
|
rep := pairingEnforcing(tMember)
|
|
if caps, _ := rep["pairing_caps"].([]string); len(caps) != 2 || caps[1] != "inference" {
|
|
t.Fatalf("enforcing must report the inference cap: %+v", rep)
|
|
}
|
|
if caps, _ := pairingEnforcing(tAnchor)["pairing_caps"].([]string); len(caps) != len(allCaps) {
|
|
t.Fatalf("the anchor's ceiling is every family (%v): %v", allCaps, caps)
|
|
}
|
|
}
|
|
|
|
// Update, contain and mirror change the whole machine, not one pairing's
|
|
// lanes. On an anchored box only the anchor host directs them.
|
|
func TestBoxWideDirectivesAreAnchorOnly(t *testing.T) {
|
|
// unanchored: nothing gated (the common case)
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if why := anchorOnly(tMember, "self-update"); why != "" {
|
|
t.Fatalf("an unanchored box gates nothing: %s", why)
|
|
}
|
|
if why := anchorOnly("", "self-update"); why != "" {
|
|
t.Fatalf("an unanchored box gates nothing, even with no identity: %s", why)
|
|
}
|
|
|
|
priv := setupBox(t)
|
|
writePairingState(t, tMember, pairingPending)
|
|
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
terms, sig := signTerms(t, priv, grantTerms(tMember, allCaps, future))
|
|
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
|
|
t.Fatalf("grant: %d %s", code, errOut)
|
|
}
|
|
// even a member holding EVERY family is not the anchor
|
|
if why := anchorOnly(tMember, "self-update"); why == "" || !contains(why, tAnchor) {
|
|
t.Fatalf("a member pairing must be refused a box-wide directive and told who may: %q", why)
|
|
}
|
|
if why := anchorOnly("", "contain"); why == "" {
|
|
t.Fatal("no identity on an anchored box is default-deny")
|
|
}
|
|
if why := anchorOnly(tAnchor, "mirror placement"); why != "" {
|
|
t.Fatalf("the anchor must not be refused: %s", why)
|
|
}
|
|
|
|
// contain: a member's directive kills nothing, the anchor's reaches the guard
|
|
prevObserved := observedAgents
|
|
observedAgents = func() ([]agentObs, error) {
|
|
return []agentObs{{PID: 4242, Kind: kindAgent, Signature: "test-agent"}}, nil
|
|
}
|
|
processTree = func() map[int]int { return map[int]int{} }
|
|
killed := []int{}
|
|
killProcess = func(pid int) error { killed = append(killed, pid); return nil }
|
|
t.Cleanup(func() { observedAgents = prevObserved; processTree = processParents })
|
|
maybeContain(tMember, "", testDirective(t, directiveContain+":4242", []string{"4242"}, `{"contain":[4242]}`))
|
|
if len(killed) != 0 {
|
|
t.Fatalf("a member host must not terminate processes on an anchored box: %v", killed)
|
|
}
|
|
// The anchor's directive on the anchor's word alone is refused too now
|
|
// (writes.go directiveGate) — it reaches the guard with the person's
|
|
// approval bound to these pids.
|
|
maybeContain(tAnchor, "", []byte(`{"contain":[4242]}`))
|
|
if len(killed) != 0 {
|
|
t.Fatalf("the anchor's directive without an approval must not reach the guard: %v", killed)
|
|
}
|
|
maybeContain(tAnchor, "", testDirective(t, directiveContain+":4242", []string{"4242"}, `{"contain":[4242]}`))
|
|
if len(killed) != 1 || killed[0] != 4242 {
|
|
t.Fatalf("the anchor host's approved directive must reach the guard: %v", killed)
|
|
}
|
|
|
|
// self-update: a member's directive never fetches the manifest; the
|
|
// anchor's does (and finds a version it did not ask for, so no swap).
|
|
// The target is older than the version this box runs, so the anchor's
|
|
// directive needs an approval (a newer one would not:
|
|
// claims_update_forward_test.go).
|
|
freshVersionState(t)
|
|
clientVersion = "100.0.0"
|
|
fetches := 0
|
|
man := httptest.NewServer(signedStub(releaseKindBastion, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fetches++
|
|
_, _ = w.Write([]byte(`{"version":"0.0.0-test","binaries":{}}`))
|
|
})))
|
|
defer man.Close()
|
|
oldURL := manifestURL
|
|
manifestURL = man.URL + "/version.json"
|
|
t.Cleanup(func() { manifestURL = oldURL })
|
|
maybeSelfUpdate(tMember, "", testDirective(t, directiveSelfUpdate, []string{"99.0.0"}, `{"update":{"version":"99.0.0"}}`))
|
|
if fetches != 0 {
|
|
t.Fatal("a member's update directive must be refused before any fetch")
|
|
}
|
|
maybeSelfUpdate(tAnchor, "", []byte(`{"update":{"version":"99.0.0"}}`))
|
|
if fetches != 0 {
|
|
t.Fatal("the anchor's update directive without an approval must be refused before any fetch")
|
|
}
|
|
maybeSelfUpdate(tAnchor, "", testDirective(t, directiveSelfUpdate, []string{"99.0.0"}, `{"update":{"version":"99.0.0"}}`))
|
|
if fetches != 1 {
|
|
t.Fatalf("the anchor's approved directive must reach the manifest fetch, got %d fetches", fetches)
|
|
}
|
|
|
|
// mirror: a member's enable is reported as failed with the reason, and no
|
|
// manager starts
|
|
cfg := &Config{HostName: tMember}
|
|
maybeMirror([]byte(`{"mirror":{"enabled":true,"db_image":{"known":true,"tag":"x"}}}`), cfg, "", nil, nil)
|
|
rep := currentMirrorReport(tMember)
|
|
if rep == nil || rep.State != "failed" || !contains(rep.Error, "anchor") {
|
|
t.Fatalf("a member's mirror placement must be reported refused: %+v", rep)
|
|
}
|
|
mirrorMu.Lock()
|
|
_, running := mirrorManagers[tMember]
|
|
mirrorMu.Unlock()
|
|
if running {
|
|
t.Fatal("no mirror manager may start for a member's directive")
|
|
}
|
|
// the host's disable clears the refusal, so the beat does not carry
|
|
// "failed" forever
|
|
maybeMirror([]byte(`{"mirror":{"enabled":false,"reason":"not_configured_here"}}`), cfg, "", nil, nil)
|
|
if rep := currentMirrorReport(tMember); rep != nil { // absent reads as nil: no field on the beat
|
|
t.Fatalf("a disable after a refused placement must clear the report: %+v", rep)
|
|
}
|
|
}
|
|
|
|
// A networked box with no anchor serves NOTHING — on every lane, not just the
|
|
// management frame.
|
|
//
|
|
// The first cut of this put the predicate only in handleMgmtConnFor and closed
|
|
// about a fifth of what its own commit message claimed: harness:run, session:
|
|
// open, egress, the inference forwards, self-update, contain, mirror placement
|
|
// and context-prep all stayed open, because each of them gates through
|
|
// capsAllow or anchorOnly and BOTH returned allow when unanchored. So this
|
|
// drives those two functions directly — they are what every lane consults.
|
|
//
|
|
// Sets mode.json DIRECTLY rather than via setBoxMode, which needs root: the
|
|
// first version skipped the whole networked half off-root and reported green
|
|
// with the gate unexercised, and there is no CI here to catch that.
|
|
func TestNetworkedUnanchoredBoxServesNothing(t *testing.T) {
|
|
networkedBox := func(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
p, err := modePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if boxMode() != modeNetworked {
|
|
t.Fatalf("box must read networked, got %s", boxMode())
|
|
}
|
|
}
|
|
|
|
// LOCAL is untouched — it cannot be anchored at all, so gating it would take
|
|
// every local bastion dark for a state it can never reach (and most
|
|
// bastions are local). This half needs no root either way.
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if boxMode() != modeLocal {
|
|
t.Fatalf("a box with no mode file must be local, got %s", boxMode())
|
|
}
|
|
for _, op := range []string{"uptime", "dcn_list", "harness:run", "session:open", "egress"} {
|
|
if why := capsAllow(tMember, op); why != "" {
|
|
t.Fatalf("a LOCAL bastion must keep %s: %s", op, why)
|
|
}
|
|
}
|
|
for _, what := range []string{"self-update", "contain", "mirror placement"} {
|
|
if why := anchorOnly(tMember, what); why != "" {
|
|
t.Fatalf("a LOCAL bastion must keep %s: %s", what, why)
|
|
}
|
|
}
|
|
|
|
// NETWORKED + unanchored: EVERY lane refuses, each naming the way out.
|
|
// These are the real gate calls each lane makes, not op names invented here:
|
|
// harness.go capsAllow(self,"harness:run") · session.go capsAllow(self,"session:open")
|
|
// egress.go capsAllow(self,"egress") · run.go/agents.go capsAllow(self,"inference")
|
|
// update.go anchorOnly(self,"self-update")· contain.go anchorOnly(self,"contain")
|
|
// mirror.go anchorOnly(self,"mirror placement")
|
|
networkedBox(t)
|
|
for _, op := range []string{"uptime", "dcn_list", "ctx_get", "harness:run", "session:open", "egress", "inference"} {
|
|
why := capsAllow(tMember, op)
|
|
if why == "" {
|
|
t.Fatalf("capsAllow must refuse %q on a networked unanchored box", op)
|
|
}
|
|
if !contains(why, "NOT ANCHORED") || !contains(why, "claim-anchor") {
|
|
t.Fatalf("the %q refusal must say why and name the fix: %s", op, why)
|
|
}
|
|
}
|
|
for _, what := range []string{"self-update", "contain", "mirror placement"} {
|
|
why := anchorOnly(tMember, what)
|
|
if why == "" {
|
|
t.Fatalf("anchorOnly must refuse %q on a networked unanchored box", what)
|
|
}
|
|
if !contains(why, "claim-anchor") {
|
|
t.Fatalf("the %q refusal must name the fix: %s", what, why)
|
|
}
|
|
}
|
|
// An op in NO family must not slip through: capsAllow returns "" for an
|
|
// unknown family on an anchored box, so the unanchored refusal has to come
|
|
// first or "not in any family" would read as "allowed".
|
|
if why := capsAllow(tMember, "some_op_in_no_family"); why == "" {
|
|
t.Fatal("an op in no family must still be refused on a networked unanchored box")
|
|
}
|
|
|
|
// And the mgmt frame itself, end to end through the real lane.
|
|
for _, op := range []string{"uptime", "dcn_list", "pairing_grant_sync"} {
|
|
_, errOut, code := driveOp(t, tMember, op, nil)
|
|
if code != 126 || !contains(errOut, "NOT ANCHORED") {
|
|
t.Fatalf("mgmt op %q must be refused as unanchored: %d %s", op, code, errOut)
|
|
}
|
|
}
|
|
|
|
// Once anchored the lanes answer again under the normal caps rules: the
|
|
// anchor host is unconstrained, a member with no grant is default-denied.
|
|
if err := writeAnchor("Acme IT", tMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, op := range []string{"uptime", "harness:run", "egress"} {
|
|
if why := capsAllow(tMember, op); why != "" {
|
|
t.Fatalf("the anchor host must be served %s again: %s", op, why)
|
|
}
|
|
}
|
|
if why := capsAllow("stranger.host.privatae.ai", "harness:run"); !contains(why, "no verified pairing grant") {
|
|
t.Fatalf("a member with no grant must be default-denied, not unanchored-refused: %q", why)
|
|
}
|
|
// And the MGMT FRAME itself, after anchoring — the direct call site, which
|
|
// this test drove only while unanchored. A review nit: its anchored half went
|
|
// through capsAllow/anchorOnly and left the frame's own path covered only by
|
|
// pre-existing tests, so the one call site that does NOT delegate was the one
|
|
// this test never re-checked. That is exactly where the predicate bug lived.
|
|
if _, errOut, code := driveOp(t, tMember, "uptime", nil); code == 126 && contains(errOut, "NOT ANCHORED") {
|
|
t.Fatalf("the mgmt frame must answer an anchored box again: %s", errOut)
|
|
}
|
|
}
|
|
|
|
// TWO SAFETY PROPERTIES of refusing everything on a networked unanchored box.
|
|
// Both were open questions that argument could not settle, so they
|
|
// are answered here by driving the code.
|
|
//
|
|
// 1. A BOX CANNOT BE STRANDED. currentAnchor() returns nil for a MISSING file
|
|
// and equally for a corrupt or tampered one, so a disk fault or a bad write
|
|
// flips a working networked box into refusing every lane. That is the right
|
|
// direction to fail, but only if it is recoverable: re-anchoring must work
|
|
// from the box itself, with no lane and no host involved.
|
|
//
|
|
// 2. IT STAYS VISIBLE. The refusal is worth nothing if the machine simply goes
|
|
// quiet — an absent box and a refusing box would look identical from the
|
|
// host, which is the failure shape whose only symptom is absence. The
|
|
// heartbeat is a separate lane (an HTTP POST) and must keep reporting, so
|
|
// the host can see mode=networked with no anchor and say why.
|
|
func TestUnanchoredNetworkedBoxIsRecoverableAndStillVisible(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
p, err := modePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme IT", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if capsAllow(tAnchor, "uptime") != "" {
|
|
t.Fatal("a healthy anchored box must serve its anchor host")
|
|
}
|
|
|
|
// Corrupt the anchor the way a bad write would — the signature covers the
|
|
// host name, so an edited file reads as NO anchor (anchorVerified).
|
|
ap, err := anchorPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.ReadFile(ap)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(ap, []byte(strings.Replace(string(raw), tAnchor, "evil.host.privatae.ai", 1)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a tampered anchor must read as no anchor")
|
|
}
|
|
if why := capsAllow(tAnchor, "uptime"); !contains(why, "NOT ANCHORED") {
|
|
t.Fatalf("a corrupt anchor must close the lanes, not leave them open: %q", why)
|
|
}
|
|
|
|
// PROPERTY 2: the heartbeat still goes out, and still says what it is.
|
|
// buildHeartbeatBody takes no lane and consults no gate for mode — an
|
|
// unanchored networked box reports mode=networked and no anchor, which is
|
|
// exactly what the host needs to explain the refusal to the user.
|
|
body := buildHeartbeatBody(tAnchor, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, `"mode":"networked"`) {
|
|
t.Fatalf("an unanchored networked box must still report its mode:\n%s", body)
|
|
}
|
|
if contains(body, `"anchor"`) {
|
|
t.Fatalf("a box with no verified anchor must not claim one:\n%s", body)
|
|
}
|
|
|
|
// PROPERTY 1: re-anchoring works from the box, with no lane and no host.
|
|
// currentAnchor() is nil, so claim-anchor does NOT hit "already anchored" —
|
|
// the corrupt file cannot wedge the box permanently.
|
|
if os.Geteuid() != 0 {
|
|
t.Log("recovery half needs machine authority; the refusal path above is covered")
|
|
return
|
|
}
|
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
|
if err := cmdClaimAnchor("", false, "Acme IT", tAnchor); err != nil {
|
|
t.Fatalf("a box with a corrupt anchor must be re-anchorable in place: %v", err)
|
|
}
|
|
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
|
|
t.Fatalf("re-anchor did not take: %+v", a)
|
|
}
|
|
if why := capsAllow(tAnchor, "uptime"); why != "" {
|
|
t.Fatalf("the lanes must come back after re-anchoring: %s", why)
|
|
}
|
|
}
|
|
|
|
// THE ANCHOR CANNOT BE CLEARED BY ANYTHING REACHABLE FROM OUTSIDE THE MACHINE.
|
|
//
|
|
// This became a security invariant in 0.24.0 rather than a curiosity. Before it,
|
|
// an unanchored networked box served everything; now it serves nothing — so any
|
|
// inbound path that could clear an anchor would be a remote denial of service
|
|
// against the whole machine, and a very quiet one.
|
|
//
|
|
// Audited every writer of anchor.json: writeAnchor (cmdClaimAnchor, root CLI),
|
|
// writeAnchorPreservingPin (cmdRepinAnchor, root CLI), and two saveAnchorFile
|
|
// calls that only ADD a verify key to an anchor already loaded AND verified.
|
|
// os.Remove is never pointed at the anchor path — the one removal in this file
|
|
// is the single-use rotation CONSENT file, which is a different thing.
|
|
//
|
|
// So the only ways to become unanchored are root at the machine or disk
|
|
// corruption, both of which need the access that also fixes it.
|
|
func TestNothingInboundCanClearTheAnchor(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
p, err := modePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// pinAnchorVerifyKey is the one anchor-file writer an inbound op reaches
|
|
// (the anchor's mgmt lane TOFUs its key). On an UNANCHORED box it must
|
|
// refuse outright — it must never be able to manufacture an anchor either.
|
|
pub, _, _ := ed25519.GenerateKey(nil)
|
|
key := base64.StdEncoding.EncodeToString(pub)
|
|
if err := pinAnchorVerifyKey(key); err == nil {
|
|
t.Fatal("pinning a verify key on an unanchored box must be refused, not create one")
|
|
}
|
|
if currentAnchor() != nil {
|
|
t.Fatal("a refused pin must not have written an anchor")
|
|
}
|
|
|
|
// On an anchored box it may pin, but must leave the CLAIM untouched.
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme IT", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := currentAnchor()
|
|
if before == nil {
|
|
t.Fatal("setup: box should be anchored")
|
|
}
|
|
if err := pinAnchorVerifyKey(key); err != nil {
|
|
t.Fatalf("pinning on an anchored box should work: %v", err)
|
|
}
|
|
after := currentAnchor()
|
|
if after == nil {
|
|
t.Fatal("pinning a verify key must NEVER clear the anchor")
|
|
}
|
|
if after.AnchorHost != before.AnchorHost || after.Owner != before.Owner {
|
|
t.Fatalf("pinning must not change who the anchor is: %+v -> %+v", before, after)
|
|
}
|
|
// And a second, different key is refused rather than silently re-homing it.
|
|
pub2, _, _ := ed25519.GenerateKey(nil)
|
|
if err := pinAnchorVerifyKey(base64.StdEncoding.EncodeToString(pub2)); err == nil {
|
|
t.Fatal("a different verify key must be refused once one is held")
|
|
}
|
|
if currentAnchor() == nil {
|
|
t.Fatal("a refused re-pin must not have cleared the anchor")
|
|
}
|
|
}
|
|
|
|
// `mode` rides EVERY heartbeat, whatever the box's state.
|
|
//
|
|
// The host side leans on this: it treats a beat CARRYING mode as
|
|
// authoritative about its anchor too, so absent-anchor means unanchored rather
|
|
// than "too old to know". If mode were ever conditional, a box could have its
|
|
// anchor wrongly cleared on the host. agents.go sets it unconditionally; this
|
|
// pins that so the coupling is stated on the side that depends on it.
|
|
func TestModeRidesEveryHeartbeat(t *testing.T) {
|
|
for _, tc := range []struct{ name, want string }{
|
|
{"local unanchored", `"mode":"local"`},
|
|
{"networked unanchored", `"mode":"networked"`},
|
|
{"networked anchored", `"mode":"networked"`},
|
|
} {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if tc.name != "local unanchored" {
|
|
p, err := modePath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if tc.name == "networked anchored" {
|
|
writePairing(t, tAnchor)
|
|
if err := writeAnchor("Acme IT", tAnchor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
body := buildHeartbeatBody(tAnchor, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, tc.want) {
|
|
t.Fatalf("%s: mode must ride the beat (%s):\n%s", tc.name, tc.want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// EVERY OP IN THE VOCABULARY IS IN A CAPS FAMILY, OR IS DELIBERATELY EXCEPTED.
|
|
//
|
|
// The hole found reviewing 0.24.0, and it is about the NEXT op, not any
|
|
// op that exists today. capsAllow returns "" — allow — when opFamily(op) is ""
|
|
// (anchor.go), so on an ANCHORED box an op whose name matches no family prefix
|
|
// is served to any pairing holding any grant at all, whatever its caps say. The
|
|
// unanchored-networked case is covered because handleMgmtConnFor refuses before
|
|
// capsAllow is reached; the anchored case is not covered by anything.
|
|
//
|
|
// TestOpFamilies pins thirteen ops by hand, so it can only ever check the ops
|
|
// somebody remembered to add to it. This walks the REAL vocabulary — the same
|
|
// list the enforcing report publishes to the host — so an op added to
|
|
// handlerOps, p8AllowedOps or sessionOps has to be classified or explicitly
|
|
// excepted, and cannot arrive quietly exempt from the ceiling.
|
|
//
|
|
// The exceptions are narrow and each has a reason:
|
|
//
|
|
// stepup_* — the approval flow itself. Caps-gating it would mean needing an
|
|
// approval to ask for an approval (TestCapsRefusal pins this).
|
|
// pairing_* — answered only on the anchor host's pairing, refused for
|
|
// everyone else before capsAllow is consulted (mgmt.go).
|
|
func TestEveryMgmtOpIsClassifiedOrExcepted(t *testing.T) {
|
|
// The exception list is now EMPTY: every op on this lane carries a family,
|
|
// because capsAllowIn default-denies one that does not, and
|
|
// TestEveryOpOnTheMgmtLaneHasAFamily is the stronger form of this check.
|
|
// Kept as a seam rather than deleted — the next op that genuinely needs no
|
|
// family goes here with its reason, and the vacuity check below makes sure
|
|
// an empty list still proves something.
|
|
excepted := func(op string) bool { return false }
|
|
vocab := mgmtOpVocabulary()
|
|
if len(vocab) == 0 {
|
|
t.Fatal("the op vocabulary is empty — this test would assert nothing")
|
|
}
|
|
var unclassified []string
|
|
for _, op := range vocab {
|
|
if opFamily(op) == "" && !excepted(op) {
|
|
unclassified = append(unclassified, op)
|
|
}
|
|
}
|
|
if len(unclassified) > 0 {
|
|
t.Fatalf("these ops are in no caps family and are not on the exception list, so on an\n"+
|
|
"ANCHORED box capsAllow serves them to any pairing holding any grant:\n %s\n"+
|
|
"Give each one a family in opFamily, or add it to the exception list here with\n"+
|
|
"the reason it needs none.", strings.Join(unclassified, "\n "))
|
|
}
|
|
// The exception list must not rot into a blanket: prove it still refuses
|
|
// something. A bare unknown op has no family and is not excepted, so if this
|
|
// stopped failing the check above would be vacuous.
|
|
if opFamily("some_future_op") != "" || excepted("some_future_op") {
|
|
t.Fatal("the classification check has gone vacuous — an unknown op must be neither")
|
|
}
|
|
}
|
|
|
|
// A REFUSED DIRECTIVE REACHES THE HOST, not just this box's log.
|
|
//
|
|
// This was first set aside as needing a new heartbeat field and host-side
|
|
// handling. It does not: the enforcing report already rides every beat and is
|
|
// REPLACED each beat host-side, so a field there is live by construction.
|
|
// Setting it aside was the wrong call — the whole point of this change is that
|
|
// a refusal is LOUD, and self-update and contain were refusing into a log on
|
|
// the far side of the tunnel.
|
|
func TestARefusedDirectiveIsReportedToTheHost(t *testing.T) {
|
|
// The recorder is package-level state — one process, one box — so a sibling
|
|
// test that drives any refusal leaves one behind. Start from an empty slot.
|
|
resetDirectiveRefusal(t)
|
|
|
|
// Nothing refused yet: the field is ABSENT, never "refused: none".
|
|
if r := directiveRefusalReport(); r != nil {
|
|
t.Fatalf("a box that has refused nothing must report nothing: %v", r)
|
|
}
|
|
rep := buildEnforcingReport("", nil)
|
|
if _, ok := rep["last_directive_refusal"]; ok {
|
|
t.Fatal("the enforcing report must omit the field when there is nothing to say")
|
|
}
|
|
|
|
// A refusal shows up, carrying WHAT and WHY — the why is the operator's
|
|
// only clue, so an empty one would make the field decorative.
|
|
recordDirectiveRefusal("self-update", "refused: this machine is networked but NOT ANCHORED")
|
|
rep = buildEnforcingReport("", nil)
|
|
got, ok := rep["last_directive_refusal"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("the refusal must reach the enforcing report: %v", rep["last_directive_refusal"])
|
|
}
|
|
if got["what"] != "self-update" {
|
|
t.Fatalf("the report must name the directive: %v", got)
|
|
}
|
|
if w, _ := got["why"].(string); !contains(w, "NOT ANCHORED") {
|
|
t.Fatalf("the report must carry the reason: %v", got)
|
|
}
|
|
if at, _ := got["at"].(string); at == "" {
|
|
t.Fatalf("the report must carry when, or the host cannot tell stale from live: %v", got)
|
|
}
|
|
|
|
// A LATER SUCCESS CLEARS IT. Without this the host shows a machine refusing
|
|
// something it now does, for ever — which is worse than silence, because it
|
|
// is confidently wrong rather than merely missing.
|
|
clearDirectiveRefusal("self-update")
|
|
if r := directiveRefusalReport(); r != nil {
|
|
t.Fatalf("a resolved refusal must stop being reported: %v", r)
|
|
}
|
|
|
|
// Clearing a DIFFERENT directive must not wipe a live one — otherwise a
|
|
// successful contain would hide a still-refused update.
|
|
recordDirectiveRefusal("self-update", "still refused")
|
|
clearDirectiveRefusal("contain")
|
|
if r := directiveRefusalReport(); r == nil || r["what"] != "self-update" {
|
|
t.Fatalf("clearing one directive must not clear another: %v", r)
|
|
}
|
|
}
|