Files
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

800 lines
38 KiB
Go

//go:build !windows
package main
// ops_test.go — rung 6: the op format, the
// two terms, the store, and the executor's mutation suite. The unprivileged
// tests cover the manifest, params, bounds, digest, terms and the upper-dir
// reader; the sandbox itself runs where the daemon would (root, Linux,
// overlay) and is skipped elsewhere with the reason.
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const opTestManifest = `name: write-hello
version: 1.0.0
summary: Write hello.txt under the test folder.
target: machine
privilege: root
interpreter: /bin/sh
time_limit: 30
params:
name: {type: string, max_len: 32}
count: {type: integer, min: 1, max: 5}
kind: {type: enum, values: [a, b]}
effects:
files: ["%s/"]
modes: [dry_run, apply, verify]
undo: none
`
// opTestRun is the op's program: the params arrive as JSON on stdin, the
// mode as --mode; it writes one file under the effects folder (dry_run and
// apply alike — the sandbox is what makes the dry run harmless), and verify
// checks it is there. EXTRA lets a test make it misbehave.
const opTestRun = `#!/bin/sh
read -r params
mode="$2"
case "$mode" in
dry_run|apply)
mkdir -p "%s"
printf 'hello %%s\n' "$params" > "%s/hello.txt"
%s
;;
verify)
test -f "%s/hello.txt"
;;
esac
`
func opFixture(effectsRoot, extra string) []opFile {
man := fmt.Sprintf(opTestManifest, effectsRoot)
run := fmt.Sprintf(opTestRun, effectsRoot, effectsRoot, extra, effectsRoot)
return []opFile{
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte(run))},
}
}
// opTestBox: an anchored box whose pairing dir pins a signing key we hold.
func opTestBox(t *testing.T) (dcn string, priv ed25519.PrivateKey) {
t.Helper()
setupBox(t)
dcn = t.TempDir()
pub, priv, _ := ed25519.GenerateKey(nil)
if err := dcnSaveFile(dcn, &dcnGrantFile{Stores: map[string]dcnGrantRec{},
VerifyPubkey: base64.StdEncoding.EncodeToString(pub)}); err != nil {
t.Fatal(err)
}
return dcn, priv
}
func opTerm(kind, sha, params, bounds string, uses int, ttl time.Duration, privilege string) map[string]any {
hn, _ := os.Hostname()
m := map[string]any{"kind": kind, "entity": 9001, "op": "write-hello", "version": "1.0.0", "sha256": sha,
"server": hn, "privilege": privilege, "expires": time.Now().Add(ttl).UTC().Format(time.RFC3339),
"uses": uses, "nonce": "0123456789abcdef0123456789abcdef"}
if kind == opKindRun {
m["params"] = params
} else {
m["bounds"] = bounds
}
return m
}
func bundleB64(files []opFile) string {
raw, _ := json.Marshal(files)
return base64.StdEncoding.EncodeToString(raw)
}
// ── the manifest ────────────────────────────────────────────────────────────
func TestOpManifestRefusesWhatRungSixDoesNotCarry(t *testing.T) {
ownRecord, _ := configDirPath()
good := fmt.Sprintf(opTestManifest, "/srv/optest")
if _, err := parseOpManifest([]byte(good)); err != nil {
t.Fatalf("the fixture must parse: %v", err)
}
bad := map[string]string{
"a name with capitals": strings.Replace(good, "name: write-hello", "name: WriteHello", 1),
"no version shape": strings.Replace(good, "version: 1.0.0", "version: v1", 1),
"an interpreter of its own": strings.Replace(good, "interpreter: /bin/sh", "interpreter: /usr/bin/perl", 1),
"a time limit past the box": strings.Replace(good, "time_limit: 30", "time_limit: 6000", 1),
"a relative effect": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["srv/optest/"]`, 1),
"the root as an effect": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/"]`, 1),
"an effect under /proc": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/proc/sys/kernel"]`, 1),
"a placeholder in effects": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/srv/{name}/"]`, 1),
"a unit effect": strings.Replace(good, "effects:\n", "effects:\n units: [nginx]\n", 1),
"no verify mode": strings.Replace(good, "modes: [dry_run, apply, verify]", "modes: [dry_run, apply]", 1),
"undo unstated": strings.Replace(good, "undo: none", "undo: maybe", 1),
"a host-target op": strings.Replace(good, "target: machine", "target: host", 1),
"an integer with no bounds": strings.Replace(good, "count: {type: integer, min: 1, max: 5}", "count: {type: integer}", 1),
"a string with no max": strings.Replace(good, "name: {type: string, max_len: 32}", "name: {type: string}", 1),
"the box's own record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/"]`, 1),
"a glob into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/dcn/*"]`, 1),
"a folder holding the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(ownRecord)+`/"]`, 1),
"a glob into the record (json)": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/*.json"]`, 1),
"a glob over that folder": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(ownRecord)+`/*"]`, 1),
"a glob over its parent": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(filepath.Dir(ownRecord))+`/*/*"]`, 1),
"a glob naming the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-*"]`, 1),
"a glob through the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/*/observe.kubeconfig"]`, 1),
"a ? into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio?"]`, 1),
"a class into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio[n]/x"]`, 1),
"a class holding a slash": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastion[/]x"]`, 1),
"a glob under /var/lib/privatae": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/var/lib/privatae/*/x"]`, 1),
"an escaped letter": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio\\n"]`, 1),
"an escaped slash": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastion\\/observe.kubeconfig"]`, 1),
"an escape past /proc": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/pro\\c/sys"]`, 1),
}
// A glob beside the record is fine: a `*` never crosses a `/`, so
// /etc/*.conf names nothing inside /etc/privatae-bastion — and the folder
// itself is refused at run time and masked in the sandbox.
// A class that would match a `/` under a whole-path filepath.Match is
// harmless here: effectCovers matches segment by segment with equal
// counts, so these can name only a sibling of the record, never inside
// it — and they parse.
// (/var/lib/*/state used to stand here; it could name /var/lib/rancher/state,
// a credential store, and is refused as sensitive now — ops_view_test.go.)
for _, ok := range []string{`/etc/*.conf`, `/opt/*/state`, `/etc/privatae-bastion[^a]observe.kubeconfig`,
`/etc/privatae-bastion[.-0]observe.kubeconfig`, `/var/lib/privatae/bastion[^x]*`} {
if _, err := parseOpManifest([]byte(strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ok+`"]`, 1))); err != nil {
t.Errorf("a glob beside the record is allowed: %s: %v", ok, err)
}
}
for _, folder := range []string{`/etc/`, `/var/lib/`, `/var/lib/privatae/`} {
if _, err := parseOpManifest([]byte(strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+folder+`"]`, 1))); err == nil {
t.Errorf("a folder holding this machine's record is refused: %s", folder)
}
}
for name, m := range bad {
if _, err := parseOpManifest([]byte(m)); err == nil {
t.Errorf("%s must be refused", name)
}
}
}
func TestOpParamsAndBoundsAreChecked(t *testing.T) {
m, _ := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, "/srv/optest")))
ok := `{"name":"x","count":3,"kind":"a"}`
if _, err := checkParams(m, ok); err != nil {
t.Fatalf("good params: %v", err)
}
for name, p := range map[string]string{
"missing": `{"name":"x","count":3}`,
"undeclared": `{"name":"x","count":3,"kind":"a","extra":1}`,
"out of range": `{"name":"x","count":9,"kind":"a"}`,
"not an int": `{"name":"x","count":2.5,"kind":"a"}`,
"bad enum": `{"name":"x","count":3,"kind":"z"}`,
"string too big": `{"name":"` + strings.Repeat("y", 40) + `","count":3,"kind":"a"}`,
"not an object": `[1,2]`,
} {
if _, err := checkParams(m, p); err == nil {
t.Errorf("%s must be refused", name)
}
}
// A path root written with a trailing slash accepts the bare root.
pm := &opManifest{Params: map[string]opParam{"dest": {Type: "path", Root: "/srv/data/"}}}
if _, err := checkParams(pm, `{"dest":"/srv/data"}`); err != nil {
t.Fatalf("the root itself is under the root: %v", err)
}
if _, err := checkParams(pm, `{"dest":"/srv/datax"}`); err == nil {
t.Fatal("a sibling is not under the root")
}
vals, _ := checkParams(m, ok)
b, err := parseBounds(m, `{"count":{"min":1,"max":2},"kind":{"values":["a"]},"name":{"values":["x"]}}`)
if err != nil {
t.Fatal(err)
}
if err := checkBounds(m, b, vals); err == nil {
t.Fatal("count 3 is outside the approved 1..2")
}
vals2, _ := checkParams(m, `{"name":"x","count":2,"kind":"a"}`)
if err := checkBounds(m, b, vals2); err != nil {
t.Fatalf("inside the bounds: %v", err)
}
if _, err := parseBounds(m, `{"nothing":{"min":1}}`); err == nil {
t.Fatal("a bound naming no param is refused")
}
// The card shows the bounds as sent, so every field must be one the box
// enforces for that param's type, inside the op's own bound.
for name, bad := range map[string]string{
"a field nobody reads": `{"count":{"min":1,"max":2,"root":"/x"}}`,
"values on an integer": `{"count":{"values":["1"]}}`,
"min/max on an enum": `{"kind":{"min":1}}`,
"wider than the op": `{"count":{"min":0,"max":9}}`,
"a value the op lacks": `{"kind":{"values":["z"]}}`,
"a string past max_len": `{"name":{"values":["` + strings.Repeat("y", 40) + `"]}}`,
"an empty values list": `{"kind":{"values":[]}}`,
"an integer bound with nothing": `{"count":{}}`,
} {
if _, err := parseBounds(m, bad); err == nil {
t.Errorf("%s must be refused", name)
}
}
}
func TestOpDigestIsTheFramedSHA256(t *testing.T) {
files := []opFile{
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("b"))},
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte("a"))},
}
got, err := opDirDigest(files)
if err != nil {
t.Fatal(err)
}
// Pinned against nac_ops.op_dir_digest on the pod (the same vector).
if got != "3392b4eb3a6c228079e011fed9e829b250d93a5ce2ff1ed5b4a3f1e8a0cd774d" {
t.Fatalf("fixed vector: %s", got)
}
files[0].B64 = base64.StdEncoding.EncodeToString([]byte("c"))
if got2, _ := opDirDigest(files); got2 == got {
t.Fatal("one changed byte is a different op")
}
if err := opBundleOK([]opFile{{Path: "../x", B64: "YQ=="}, {Path: "op.yaml", B64: "YQ=="}, {Path: "run", B64: "YQ=="}}); err == nil {
t.Fatal("a path that escapes is refused")
}
if err := opBundleOK([]opFile{{Path: "op.yaml", B64: "YQ=="}}); err == nil {
t.Fatal("run is required")
}
}
func TestEffectCovers(t *testing.T) {
eff := []string{"/etc/app/", "/var/log/app/*.log", "/etc/one.conf"}
for p, want := range map[string]bool{
"/etc/app/x.conf": true,
"/etc/app/sub/y": true,
"/etc/app": true,
"/etc/application/x": false,
"/var/log/app/a.log": true,
"/var/log/app/sub/a.log": false,
"/etc/one.conf": true,
"/etc/one.conf.bak": false,
"/": false,
} {
if effectCovers(eff, p) != want {
t.Errorf("%s: want %v", p, want)
}
}
// A class never spans a /: the glob and the path are matched segment by
// segment with equal counts (the gate uses the same definition).
for p, want := range map[string]bool{
"/etc/x/y": false, // [^a] and [+-0] match a / under filepath.Match; never across segments
"/etc/xby": true,
"/etc/x-y": true,
"/etc/xay": false,
"/etc/x/": false,
"/etc/x_yz": false,
} {
if effectCovers([]string{"/etc/x[^a]y", "/etc/x[+-0]y"}, p) != want {
t.Errorf("class: %s: want %v", p, want)
}
}
if effectCovers([]string{"/var/log/*/*.log"}, "/var/log/app/a.log") != true || effectCovers([]string{"/var/log/*/*.log"}, "/var/log/a.log") != false {
t.Error("a glob covers only paths with the same number of segments")
}
}
func TestOpRunStatusReadsTheCommitNotTheVerify(t *testing.T) {
dcn, _ := opTestBox(t)
_ = os.MkdirAll(opsRunsDir(dcn), 0o700)
write := func(id, nonce, kind, mode, errText string, commit []string) {
raw, _ := json.Marshal(opRunRecord{ID: id, At: time.Now().UTC().Format(time.RFC3339), Kind: kind, Op: "x", SHA256: strings.Repeat("a", 64),
Mode: mode, Nonce: nonce, Error: errText, Commit: commit, Report: &opReport{ChangesSHA256: strings.Repeat("c", 64)}})
_ = os.WriteFile(filepath.Join(opsRunsDir(dcn), id+".json"), raw, 0o600)
}
write("r1", "n-verify", opKindRun, opModeApply, "verify: the sandbox could not start", []string{"created /srv/x"})
write("r2", "n-commit", opKindRun, opModeApply, "commit: disk full (1 change(s) landed before it)", []string{"created /srv/x"})
write("r3", "n-grant", opKindGrant, opModeApply, "", []string{"created /srv/y"})
for nonce, want := range map[string]bool{"n-verify": true, "n-commit": false} {
st, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_run_status", Args: []string{nonce}}, "", dcn)
var status map[string]any
_ = json.Unmarshal([]byte(st), &status)
if code != 0 || status["found"] != true || status["applied"] != want {
t.Fatalf("%s: applied=%v wanted %v: %s", nonce, status["applied"], want, st)
}
}
st, _, _ := driveJob(t, tAnchor, mgmtJob{Op: "op_run_status", Args: []string{"n-grant"}}, "", dcn)
var status map[string]any
_ = json.Unmarshal([]byte(st), &status)
if status["found"] != false {
t.Fatalf("a run under a grant carries the grant's nonce and never answers here: %s", st)
}
}
// ── the terms: the mutation suite ───────────────────────────────────────────
func TestOpTermsMutationsAreEachRefused(t *testing.T) {
dcn, priv := opTestBox(t)
files := opFixture("/srv/optest", "")
sha, _ := opDirDigest(files)
good := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
terms, sig := signTerms(t, priv, good)
if _, err := verifyOpTerms(dcn, terms, sig, opKindRun); err != nil {
t.Fatalf("good terms verify: %v", err)
}
hn, _ := os.Hostname()
mut := func(f func(m map[string]any)) []string {
m := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
f(m)
return signTermsPair(t, priv, m)
}
cases := map[string]struct {
terms, sig string
kind string
want string
}{
"expired": {"", "", opKindRun, "expired"},
"open-ended": {"", "", opKindRun, "no expiry"},
"too long-lived": {"", "", opKindRun, "longer than"},
"two uses": {"", "", opKindRun, "one use"},
"other server": {"", "", opKindRun, "this box is"},
"malformed sha256": {"", "", opKindRun, "malformed sha256"},
"no nonce": {"", "", opKindRun, "no nonce"},
"wrong kind": {"", "", opKindGrant, "not \"op_grant\""},
"root grant": {"", "", opKindGrant, "never reusable"},
"grant no bounds": {"", "", opKindGrant, "carries its bounds"},
"grant too many uses": {"", "", opKindGrant, "uses 1.."},
}
set := func(name string, pair []string) { c := cases[name]; c.terms, c.sig = pair[0], pair[1]; cases[name] = c }
set("expired", mut(func(m map[string]any) { m["expires"] = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) }))
set("open-ended", mut(func(m map[string]any) { m["expires"] = "" }))
set("too long-lived", mut(func(m map[string]any) { m["expires"] = time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) }))
set("two uses", mut(func(m map[string]any) { m["uses"] = 2 }))
set("other server", mut(func(m map[string]any) { m["server"] = hn + "-other" }))
set("malformed sha256", mut(func(m map[string]any) { m["sha256"] = strings.Repeat("z", 64) }))
set("no nonce", mut(func(m map[string]any) { m["nonce"] = "" }))
set("wrong kind", mut(func(m map[string]any) {}))
set("root grant", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", `{}`, 5, time.Hour, "root")))
set("grant no bounds", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", "", 5, time.Hour, "user")))
set("grant too many uses", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", `{}`, 5000, time.Hour, "user")))
for name, c := range cases {
_, err := verifyOpTerms(dcn, c.terms, c.sig, c.kind)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want refusal containing %q, got %v", name, c.want, err)
}
}
// Signed by another key.
_, other, _ := ed25519.GenerateKey(nil)
ot, os_ := signTerms(t, other, good)
if _, err := verifyOpTerms(dcn, ot, os_, opKindRun); err == nil || !strings.Contains(err.Error(), "does not verify") {
t.Fatalf("another key: %v", err)
}
// Tampered after signing.
raw, _ := base64.StdEncoding.DecodeString(terms)
tampered := base64.StdEncoding.EncodeToString([]byte(strings.Replace(string(raw), `"uses":1`, `"uses":2`, 1)))
if _, err := verifyOpTerms(dcn, tampered, sig, opKindRun); err == nil {
t.Fatal("tampered terms must not verify")
}
}
// ── sync, and the grant's install spends the approval ───────────────────────
func TestOpSyncHoldsTheOpAndInstallsAGrantOnce(t *testing.T) {
dcn, priv := opTestBox(t)
enrollFactor(t, dcn)
files := opFixture("/srv/optest", "")
sha, _ := opDirDigest(files)
// The bundle must hash to the sha256 the terms name.
wrong := signTermsPair(t, priv, opTerm(opKindRun, strings.Repeat("0", 64), `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root"))
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: []string{wrong[0], wrong[1], bundleB64(files)}}, "", dcn); code != 126 || !strings.Contains(errOut, "does not hash") {
t.Fatalf("a bundle that does not hash to the terms' sha256 is refused: %d %q", code, errOut)
}
run := signTermsPair(t, priv, opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root"))
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: []string{run[0], run[1], bundleB64(files)}}, "", dcn)
if code != 0 || !strings.Contains(out, `"held"`) {
t.Fatalf("op_sync with op_run terms holds the op: %d %s %s", code, out, errOut)
}
if _, err := opLoad(dcn, sha); err != nil {
t.Fatalf("held and re-hashed: %v", err)
}
// A grant installs under the person's approval — spent once.
gt := opTerm(opKindGrant, sha, "", `{"count":{"min":1,"max":2}}`, 3, time.Hour, "user")
// The fixture is privilege root; a grant is user only, and the manifest must agree.
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(files))}, "", dcn); code != 126 || !strings.Contains(errOut, "disagree") {
t.Fatalf("a user grant for a root manifest is refused: %d %q", code, errOut)
}
ufiles := []opFile{
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"), "privilege: root", "privilege: user", 1)))},
files[1],
}
usha, _ := opDirDigest(ufiles)
gt["sha256"] = usha
purpose, err := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: usha,
Bounds: gt["bounds"].(string), Expires: gt["expires"].(string), Uses: 3})
if err != nil {
t.Fatal(err)
}
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 126 || !strings.Contains(errOut, "approval") {
t.Fatalf("a grant without the person's approval is refused: %d %q", code, errOut)
}
raw, _ := b32decode(tSecret)
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
if err != nil {
t.Fatal(err)
}
gt["approval"] = approval
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn)
if code != 0 || !strings.Contains(out, `"grant"`) {
t.Fatalf("an approved grant installs: %d %s %s", code, out, errOut)
}
if _, rec, err := opsHeldGrant(dcn, gt["nonce"].(string)); err != nil || rec.UsesLeft != 3 {
t.Fatalf("held with its uses: %v %v", err, rec)
}
// Dropped at the console, it cannot come back under the same approval.
if ok, _ := opsDropGrant(dcn, gt["nonce"].(string), "test"); !ok {
t.Fatal("drop")
}
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 126 || !strings.Contains(errOut, "already used") {
t.Fatalf("a dropped grant is not re-installed under a spent approval: %d %q", code, errOut)
}
// The device signs op kinds (they name the machine) and the card reads.
words := describeRequest(stepupRequest{Details: map[string]any{"kind": opKindGrant, "op": "write-hello", "version": "1.0.0",
"server": gt["server"], "privilege": "user", "bounds": gt["bounds"], "uses": float64(3), "expires": gt["expires"],
"sha256": usha, "summary": "Write hello.txt.", "effects": []any{"/srv/optest/"}}})
for _, want := range []string{"FROM NOW ON", "write-hello 1.0.0", "up to 3 runs", "/srv/optest/", "identity: sha256"} {
if !strings.Contains(words, want) {
t.Fatalf("the card must say %q: %s", want, words)
}
}
if p, err := purposeFromDetails(map[string]any{"kind": opKindGrant, "op": "write-hello", "version": "1.0.0",
"server": gt["server"], "privilege": "user", "bounds": gt["bounds"], "uses": float64(3), "expires": gt["expires"], "sha256": usha}); err != nil || p != purpose {
t.Fatalf("the details derive the same purpose the box enforces: %v %s", err, p)
}
}
func signTermsPair(t *testing.T, priv ed25519.PrivateKey, m map[string]any) []string {
t.Helper()
a, b := signTerms(t, priv, m)
return []string{a, b}
}
func TestOffLimitsCarryBothSpellingsAndRefuseWhenUnreadable(t *testing.T) {
realDir := t.TempDir()
link := filepath.Join(t.TempDir(), "cfg-link")
if err := os.Symlink(realDir, link); err != nil {
t.Skip("no symlinks here")
}
t.Setenv("XDG_CONFIG_HOME", link)
offs, err := opOffLimits()
if err != nil {
t.Fatal(err)
}
want := map[string]bool{filepath.Join(link, "privatae-bastion"): false, filepath.Join(realDir, "privatae-bastion"): false}
for _, o := range offs {
if _, ok := want[o]; ok {
want[o] = true
}
}
for p, seen := range want {
if !seen {
t.Fatalf("the record is protected as spelled and as it resolves; missing %s in %v", p, offs)
}
}
// The other key material this daemon keeps on disk is in the set too.
for _, p := range []string{"/var/lib/privatae/freeagent", "/etc/privatae-bastion", "/var/lib/privatae/bastion"} {
found := false
for _, o := range offs {
if o == p {
found = true
}
}
if !found {
t.Fatalf("%s is off limits; got %v", p, offs)
}
}
// A pairing file that does not parse: the record cannot be enumerated,
// and every op is refused rather than one DCN dir left unprotected.
pd, _ := pairingsDir()
_ = os.MkdirAll(pd, 0o700)
_ = os.WriteFile(filepath.Join(pd, "broken.json"), []byte("{not json"), 0o600)
if _, err := opOffLimits(); err == nil {
t.Fatal("unreadable pairings refuse ops")
}
if err := effectOK("/srv/x/"); err == nil {
t.Fatal("an effect cannot be judged when the record cannot be enumerated")
}
}
// ── the sandbox itself: root, Linux, overlay ────────────────────────────────
func requireSandbox(t *testing.T) {
t.Helper()
if ok, why := opSandboxAvailable(); !ok {
t.Skip("no sandbox here: " + why)
}
// The fixtures' effects are folders under here; the folder itself must
// exist so creating it is not a change outside the effects.
if err := os.MkdirAll(opTestRoot, 0o755); err != nil {
t.Fatal(err)
}
}
const opTestRoot = "/srv/privatae-optest"
// opProbeRun is a python3 op that tries what the sandbox must deny: a dial
// to the host's sshd and a write under /proc. It prints a marker for each
// one that succeeds.
const opProbeRun = `#!/usr/bin/python3
import socket, sys
sys.stdin.readline()
try:
socket.create_connection(("127.0.0.1", 22), 1).close(); print("NET-OPEN")
except Exception as e:
print("net:", e)
try:
open("/proc/sys/kernel/hostname", "w").write("x"); print("PROC-WRITTEN")
except Exception as e:
print("proc:", e)
`
func TestOpThatWritesOutsideItsEffectsIsRefusedBeforeAnyoneIsAsked(t *testing.T) {
requireSandbox(t)
dcn, priv := opTestBox(t)
root := filepath.Join(opTestRoot, fmt.Sprintf("e%d", time.Now().UnixNano()))
outside := root + "-outside"
t.Cleanup(func() { _ = os.RemoveAll(root); _ = os.RemoveAll(outside) })
cases := map[string]string{
"a file outside the effects": fmt.Sprintf(`mkdir -p %s; echo x > %s/leak`, outside, outside),
"a symlink inside them": fmt.Sprintf(`ln -s /etc/passwd %s/link`, root),
"a hardlink inside them": fmt.Sprintf(`ln %s/hello.txt %s/hard`, root, root),
"a write to /etc": `echo x >> /etc/hostname`,
}
for name, extra := range cases {
files := opFixture(root, extra)
sha, _ := opDirDigest(files)
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
pair := signTermsPair(t, priv, run)
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
t.Fatalf("%s: sync: %s", name, errOut)
}
_, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
if code != 126 || !strings.Contains(errOut, "refused") {
t.Errorf("%s: the dry run must be refused: %d %q", name, code, errOut)
}
if _, err := os.Stat(outside + "/leak"); err == nil {
t.Errorf("%s: nothing lands from a dry run", name)
}
_ = os.RemoveAll(root)
}
// The sandbox has no network and a masked /proc: an op that dials the
// host's sshd or writes there fails inside, and nothing reaches out.
if c, err := net.DialTimeout("tcp", "127.0.0.1:22", time.Second); err != nil {
t.Logf("no sshd on loopback here; the network half of the probe is not exercised: %v", err)
} else {
c.Close()
}
man := strings.Replace(fmt.Sprintf(opTestManifest, root), "interpreter: /bin/sh", "interpreter: python3", 1)
files := []opFile{
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte(opProbeRun))},
}
sha, _ := opDirDigest(files)
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
pair := signTermsPair(t, priv, run)
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
t.Fatalf("probe sync: %s", errOut)
}
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
if code != 0 {
t.Fatalf("the probe op changes nothing and must run: %d %s", code, errOut)
}
if strings.Contains(out, "NET-OPEN") || strings.Contains(out, "PROC-WRITTEN") {
t.Fatalf("the sandbox must have no network and no writable /proc: %s", out)
}
if !strings.Contains(out, "net:") || !strings.Contains(out, "proc:") {
t.Fatalf("both probes must have run and been refused: %s", out)
}
}
func TestOpTimeLimitAndGrantRuns(t *testing.T) {
requireSandbox(t)
dcn, priv := opTestBox(t)
enrollFactor(t, dcn)
root := filepath.Join(opTestRoot, fmt.Sprintf("g%d", time.Now().UnixNano()))
t.Cleanup(func() { _ = os.RemoveAll(root) })
// Time limit: an op that sleeps past it is killed and refused.
slow := opFixture(root, "sleep 5")
slow[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, root), "time_limit: 30", "time_limit: 1", 1)))
sha, _ := opDirDigest(slow)
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
pair := signTermsPair(t, priv, run)
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(slow))}, "", dcn)
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 || !strings.Contains(errOut, "time limit") {
t.Fatalf("past the time limit: %d %q", code, errOut)
}
// A grant (user): runs inside its bounds, spends uses on apply, refuses outside them.
// Its last line writes /dev/null, which a user-privilege op must be able
// to do (the sandbox's device nodes are 0666, past the umask).
ufiles := opFixture(root, "echo x > /dev/null")
ufiles[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, root), "privilege: root", "privilege: user", 1)))
usha, _ := opDirDigest(ufiles)
gt := opTerm(opKindGrant, usha, "", `{"count":{"min":1,"max":2}}`, 2, time.Hour, "user")
purpose, _ := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: usha, Bounds: gt["bounds"].(string), Expires: gt["expires"].(string), Uses: 2})
raw, _ := b32decode(tSecret)
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
if err != nil {
t.Fatal(err)
}
gt["approval"] = approval
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 0 {
t.Fatalf("grant sync: %s", errOut)
}
nonce := gt["nonce"].(string)
// The user-privilege op writes under a root the session user may not own:
// give it a folder that user can write.
_ = os.MkdirAll(root, 0o777)
_ = os.Chmod(root, 0o777) // past the umask
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":3,"kind":"a"}`}}, "", dcn); code != 126 || !strings.Contains(errOut, "approved range") {
t.Fatalf("outside the bounds: %d %q", code, errOut)
}
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn); code != 0 {
t.Fatalf("inside the bounds: %d %s", code, errOut)
}
if _, rec, _ := opsHeldGrant(dcn, nonce); rec == nil || rec.UsesLeft != 1 {
t.Fatalf("one use spent: %+v", rec)
}
driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn)
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn); code != 126 || !strings.Contains(errOut, "no uses left") {
t.Fatalf("uses exhausted: %d %q", code, errOut)
}
if got, err := os.ReadFile(root + "/hello.txt"); err != nil || !strings.Contains(string(got), "count") {
t.Fatalf("the grant's applies landed: %v", err)
}
}
// ── refusals: a strike for the host, never for the box's own state ─────────
func TestOpRefusalsCountAsStrikesExceptTheBoxsOwnState(t *testing.T) {
requireSandbox(t)
dcn, priv := opTestBox(t)
root := filepath.Join(opTestRoot, fmt.Sprintf("s%d", time.Now().UnixNano()))
outside := root + "-outside"
t.Cleanup(func() { _ = os.RemoveAll(root); _ = os.RemoveAll(outside) })
files := opFixture(root, fmt.Sprintf(`mkdir -p %s; echo x > %s/leak`, outside, outside))
sha, _ := opDirDigest(files)
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
pair := signTermsPair(t, priv, run)
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
before := len(quarantineLoad().Pairing[tAnchor].refusalsNow())
if _, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 {
t.Fatal("refused")
}
if after := len(quarantineLoad().Pairing[tAnchor].refusalsNow()); after != before+1 {
t.Fatalf("a run outside its effects is a strike against the host: %d -> %d", before, after)
}
// The box's own state is never the host's strike.
if refusalCounts("op_dry_run", "op_dry_run refused: "+noSandboxHere+": no overlay") {
t.Fatal("a box that cannot sandbox is not the host's fault")
}
if !refusalCounts("op_apply", "op_apply refused: this op_run was applied already") {
t.Fatal("a second apply of the same op_run is the host's strike")
}
}
func (e *quarantineEntry) refusalsNow() []string {
if e == nil {
return nil
}
return e.Refusals
}
func TestOpRemovingAFolderLandsExactlyThat(t *testing.T) {
requireSandbox(t)
dcn, priv := opTestBox(t)
root := filepath.Join(opTestRoot, fmt.Sprintf("d%d", time.Now().UnixNano()))
t.Cleanup(func() { _ = os.RemoveAll(root) })
_ = os.MkdirAll(filepath.Join(root, "old", "deeper"), 0o755)
_ = os.WriteFile(filepath.Join(root, "old", "a.txt"), []byte("a\n"), 0o644)
_ = os.WriteFile(filepath.Join(root, "old", "deeper", "b.txt"), []byte("b\n"), 0o644)
_ = os.WriteFile(filepath.Join(root, "keep.txt"), []byte("keep\n"), 0o644)
files := opFixture(root, fmt.Sprintf(`rm -rf %s/old; rm -f %s/keep.txt`, root, root))
sha, _ := opDirDigest(files)
params := `{"name":"x","count":1,"kind":"a"}`
run := opTerm(opKindRun, sha, params, "", 1, time.Hour, "root")
pair := signTermsPair(t, priv, run)
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
if code != 0 {
t.Fatalf("dry run: %s", errOut)
}
var rep opReport
_ = json.Unmarshal([]byte(out), &rep)
kinds := map[string]string{}
notes := map[string]string{}
for _, c := range rep.Changes {
kinds[c.Path] = c.Kind
notes[c.Path] = c.Note
}
if kinds[root+"/old"] != "removed_dir" || !strings.Contains(notes[root+"/old"], "3 entries") || kinds[root+"/keep.txt"] != "removed" || kinds[root+"/hello.txt"] != "created" {
t.Fatalf("the dry run names the removed folder (with what it holds), the removed file and the created one: %+v", rep.Changes)
}
if _, err := os.Stat(filepath.Join(root, "old", "a.txt")); err != nil {
t.Fatal("a dry run removes nothing")
}
pub, _ := deviceInit()
_ = pinDeviceKey(pub)
run["changes_sha256"] = rep.ChangesSHA256
purpose, _ := purposeForOpRun(&opTerms{Server: run["server"].(string), Op: "write-hello", SHA256: sha, Params: params, Expires: run["expires"].(string), Changes: rep.ChangesSHA256})
run["approval"], _ = deviceSign(purpose, time.Minute)
pair = signTermsPair(t, priv, run)
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_apply", Args: pair}, "", dcn)
if code != 0 {
t.Fatalf("apply: %s", errOut)
}
_ = json.Unmarshal([]byte(out), &rep)
if _, err := os.Stat(filepath.Join(root, "old")); err == nil {
t.Fatal("the folder the person saw removed is removed")
}
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err == nil {
t.Fatal("the file the person saw removed is removed")
}
if got, _ := os.ReadFile(filepath.Join(root, "hello.txt")); !strings.HasPrefix(string(got), "hello ") {
t.Fatal("the created file landed")
}
if len(rep.Committed) != 3 || rep.VerifyError != "" {
t.Fatalf("three changes committed, verified: %+v", rep)
}
}
// The record is masked INSIDE the sandbox: an op cannot read the keys, and
// cannot remove or write the folder, whatever this side's path checks miss.
func TestOpSandboxMasksTheMachinesOwnRecord(t *testing.T) {
requireSandbox(t)
cfgHome := filepath.Join(opTestRoot, fmt.Sprintf("cfg%d", time.Now().UnixNano()))
t.Cleanup(func() { _ = os.RemoveAll(cfgHome) })
t.Setenv("XDG_CONFIG_HOME", cfgHome)
record := filepath.Join(cfgHome, "privatae-bastion")
_ = os.MkdirAll(record, 0o700)
_ = os.WriteFile(filepath.Join(record, "owner.json"), []byte("SECRET-KEY-MATERIAL"), 0o600)
root := filepath.Join(opTestRoot, fmt.Sprintf("m%d", time.Now().UnixNano()))
t.Cleanup(func() { _ = os.RemoveAll(root) })
opDir := filepath.Join(opTestRoot, fmt.Sprintf("op%d", time.Now().UnixNano()))
t.Cleanup(func() { _ = os.RemoveAll(opDir) })
_ = os.MkdirAll(opDir, 0o755)
extra := fmt.Sprintf(`cat %s/owner.json; ls -A %s | wc -l; echo x > %s/planted 2>/dev/null && echo WROTE; rm -rf %s 2>/dev/null && echo RM-OK; test -e %s/owner.json && echo STILL-THERE-INSIDE; true`,
record, record, record, record, record)
for _, f := range opFixture(root, extra) {
raw, _ := base64.StdEncoding.DecodeString(f.B64)
_ = os.WriteFile(filepath.Join(opDir, f.Path), raw, os.FileMode(f.Mode))
}
m, err := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, root)))
if err != nil {
t.Fatal(err)
}
_ = os.MkdirAll(opTestRoot, 0o755)
rep, err := opRunSandboxed(opDir, m, opModeDryRun, `{"name":"x","count":1,"kind":"a"}`)
if rep != nil {
defer rep.cleanup()
}
if err != nil {
t.Fatalf("the op itself succeeds (its probes fail quietly): %v", err)
}
// Since the closed view (ops_view.go) the record is not in the op's view
// at all: nothing under it can be read or written, and `rm -rf` of a path
// that is not there succeeds without touching the real one (checked below).
if strings.Contains(rep.Output, "SECRET-KEY-MATERIAL") || strings.Contains(rep.Output, "WROTE") {
t.Fatalf("the record must be unreadable and unwritable inside the sandbox: %q", rep.Output)
}
if strings.Contains(rep.Output, "STILL-THERE-INSIDE") {
t.Fatalf("the mask hides the record's contents: %q", rep.Output)
}
for _, c := range rep.Changes {
if strings.HasPrefix(c.Path, cfgHome) {
t.Fatalf("nothing under the record is a change: %+v", c)
}
}
if raw, _ := os.ReadFile(filepath.Join(record, "owner.json")); string(raw) != "SECRET-KEY-MATERIAL" {
t.Fatal("the real record is untouched")
}
}