574 lines
24 KiB
Go
574 lines
24 KiB
Go
//go:build linux
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// planBox makes this box both the person's approval device and a machine
|
|
// that has pinned it, with the device holding this machine's key: the setup
|
|
// a real device and a real machine reach through device-init, pin-device and
|
|
// pin-machine. dcn is the pairing dir the asks come in on (its factor checks
|
|
// the code).
|
|
func planBox(t *testing.T) (dcn string) {
|
|
t.Helper()
|
|
dcn, _ = opTestBox(t)
|
|
pub, err := deviceInit()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pinDeviceKey(pub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line := machineKeyLine()
|
|
f := strings.Fields(line)
|
|
if len(f) != 4 || f[1] != "pin-machine" {
|
|
t.Fatalf("the machine prints the pin-machine line: %q", line)
|
|
}
|
|
if err := pinMachineKey(f[2], f[3]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The code is checked against the factor on the device's OWN pairing
|
|
// (deviceFactorDir), whatever the ask said.
|
|
fdir, err := deviceFactorDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
enrollFactor(t, fdir)
|
|
return dcn
|
|
}
|
|
|
|
func planCode(t *testing.T) string { return planCodeAt(0) }
|
|
|
|
// planCodeAt is the code `windows` periods from now: a code is spent once, so
|
|
// a second root plan in the same period takes the next one (inside the drift
|
|
// the check allows).
|
|
func planCodeAt(windows int64) string {
|
|
raw, _ := b32decode(tSecret)
|
|
return hotp(raw, time.Now().Unix()/stepupPeriod+windows)
|
|
}
|
|
|
|
func planHost() string { h, _ := os.Hostname(); return h }
|
|
|
|
var planDraftSeq int
|
|
|
|
// planDraft is a two-line ship on this machine: a build (program only) and a
|
|
// roll that takes the build's digest. Each draft differs (the device takes
|
|
// the same draft twice as the same ask).
|
|
func planDraft(root bool) map[string]any {
|
|
planDraftSeq++
|
|
hn := planHost()
|
|
sha1, sha2 := strings.Repeat("a", 64), strings.Repeat("b", 64)
|
|
roll := map[string]any{"n": 2, "step": "roll", "machine": hn, "op": "bridge-roll", "op_sha256": sha2,
|
|
"params": `{}`, "inputs": map[string]any{"digest": "build.digest"}, "undo": true}
|
|
if root {
|
|
roll["verbs"] = []any{map[string]any{"verb": "unit.restart", "args": map[string]any{"unit": "x.service"}}}
|
|
}
|
|
return map[string]any{"kind": shipPlanKind, "plan": map[string]any{
|
|
"pipeline": "bridge", "commit": strings.Repeat("c", 40),
|
|
"expires": time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339),
|
|
"lines": []any{
|
|
map[string]any{"n": 1, "step": "build", "machine": hn, "op": "bridge-build", "op_sha256": sha1, "params": `{"x":"` + strconv.Itoa(planDraftSeq) + `"}`},
|
|
roll,
|
|
}}}
|
|
}
|
|
|
|
// signPlan files a draft on the device and approves it, returning the signed
|
|
// envelope as a machine receives it (base64 of the envelope's JSON).
|
|
func signPlan(t *testing.T, dcn string, details map[string]any, code string) (string, *shipPlan) {
|
|
t.Helper()
|
|
id, err := deviceNewPlanRequest(details)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env, err := deviceApproveWithCode(id, code)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var e shipPlanEnvelope
|
|
if err := json.Unmarshal([]byte(env), &e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _, err := planOpen(e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString([]byte(env)), p
|
|
}
|
|
|
|
func TestTheDeviceSignsOnlyAPlanWhoseMachinesItPinned(t *testing.T) {
|
|
dcn := planBox(t)
|
|
// A machine the device never pinned: refused at the ask.
|
|
d := planDraft(false)
|
|
d["plan"].(map[string]any)["lines"].([]any)[0].(map[string]any)["machine"] = "stranger-1"
|
|
if _, err := deviceNewPlanRequest(d); err == nil || !strings.Contains(err.Error(), "has not pinned stranger-1") {
|
|
t.Fatalf("an unpinned machine: %v", err)
|
|
}
|
|
// A pinned one: the device fills in its OWN key for the machine, never
|
|
// one the pod sent, and the words name every step.
|
|
d = planDraft(false)
|
|
d["plan"].(map[string]any)["machines"] = map[string]any{planHost(): base64.StdEncoding.EncodeToString(make([]byte, 32))}
|
|
id, err := deviceNewPlanRequest(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
words := deviceRequestsLoad()[id].Words
|
|
for _, want := range []string{"Ship bridge at commit cccccccccccc", "1. build on " + planHost(), "2. roll on " + planHost(),
|
|
"digest = the digest step build builds", "may be undone"} {
|
|
if !strings.Contains(words, want) {
|
|
t.Errorf("the words say %q:\n%s", want, words)
|
|
}
|
|
}
|
|
env, err := deviceApproveWithCode(id, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var e shipPlanEnvelope
|
|
_ = json.Unmarshal([]byte(env), &e)
|
|
p, _, err := planOpen(e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
own, _, _ := loadOrCreateOwnerKey()
|
|
if p.Machines[planHost()] != base64.StdEncoding.EncodeToString(own) {
|
|
t.Fatal("the plan carries the key the device pinned, not the pod's")
|
|
}
|
|
// Versions only go up.
|
|
_, p2 := signPlan(t, dcn, planDraft(false), "")
|
|
if p2.Version != p.Version+1 {
|
|
t.Fatalf("the next plan for the pipeline is version %d, got %d", p.Version+1, p2.Version)
|
|
}
|
|
}
|
|
|
|
func TestARootPlanNeedsTheCodeOnTheDevice(t *testing.T) {
|
|
planBox(t)
|
|
id, err := deviceNewPlanRequest(planDraft(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !deviceRequestsLoad()[id].NeedsCode {
|
|
t.Fatal("a plan with verbs needs the code")
|
|
}
|
|
if _, err := deviceApproveWithCode(id, ""); err == nil || !strings.Contains(err.Error(), "with your code") {
|
|
t.Fatalf("no code: %v", err)
|
|
}
|
|
if _, err := deviceApproveWithCode(id, "000000"); err == nil || !strings.Contains(err.Error(), "code") {
|
|
t.Fatalf("a wrong code: %v", err)
|
|
}
|
|
env, err := deviceApproveWithCode(id, planCode(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var e shipPlanEnvelope
|
|
_ = json.Unmarshal([]byte(env), &e)
|
|
p, _, err := planOpen(e)
|
|
if err != nil || !p.Root || !p.CodeGiven {
|
|
t.Fatalf("signed, root, with the code: %+v %v", p, err)
|
|
}
|
|
// "Approve all shown" never signs a root plan.
|
|
id2, _ := deviceNewPlanRequest(planDraft(true))
|
|
if p, _ := deviceShownPath(); p != "" {
|
|
raw, _ := json.Marshal([]string{id2})
|
|
_ = atomicWrite(p, raw)
|
|
}
|
|
if err := cmdDeviceApprove("all"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deviceRequestsLoad()[id2].State != "pending" {
|
|
t.Fatal("approve-all signed a root plan")
|
|
}
|
|
}
|
|
|
|
// The phase 2 gate: each of these is refused by the machine.
|
|
func TestAMachineRefusesEveryPlanItShouldNot(t *testing.T) {
|
|
dcn := planBox(t)
|
|
b64, p := signPlan(t, dcn, planDraft(false), "")
|
|
envOf := func(b64 string) shipPlanEnvelope {
|
|
raw, _ := base64.StdEncoding.DecodeString(b64)
|
|
var e shipPlanEnvelope
|
|
_ = json.Unmarshal(raw, &e)
|
|
return e
|
|
}
|
|
reseal := func(mut func(*shipPlan)) string {
|
|
e := envOf(b64)
|
|
body, _ := base64.StdEncoding.DecodeString(e.Payload)
|
|
var q shipPlan
|
|
_ = json.Unmarshal(body, &q)
|
|
mut(&q)
|
|
nb, _ := json.Marshal(q)
|
|
e.Payload = base64.StdEncoding.EncodeToString(nb) // the old signature, new bytes
|
|
raw, _ := json.Marshal(e)
|
|
return base64.StdEncoding.EncodeToString(raw)
|
|
}
|
|
// A tampered line: the device's signature no longer covers it.
|
|
for name, bad := range map[string]string{
|
|
"a changed op": reseal(func(q *shipPlan) { q.Lines[0].OpSHA256 = strings.Repeat("f", 64) }),
|
|
"changed params": reseal(func(q *shipPlan) { q.Lines[0].Params = `{"x":"changed"}` }),
|
|
"another commit": reseal(func(q *shipPlan) { q.Commit = strings.Repeat("d", 40) }),
|
|
"a longer life": reseal(func(q *shipPlan) { q.Expires = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) }),
|
|
"root claimed code": reseal(func(q *shipPlan) { q.CodeGiven = true }),
|
|
} {
|
|
if _, err := planRunLine(dcn, bad, "1", "", nil); err == nil || !strings.Contains(err.Error(), "signature does not verify") {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
// The wrong machine: a line for another host.
|
|
d := planDraft(false)
|
|
lines := d["plan"].(map[string]any)["lines"].([]any)
|
|
lines[0].(map[string]any)["machine"] = "elsewhere-2"
|
|
pub2, _, _ := ed25519.GenerateKey(nil)
|
|
_ = pinMachineKey("elsewhere-2", base64.StdEncoding.EncodeToString(pub2))
|
|
wrong, _ := signPlan(t, dcn, d, "")
|
|
if _, err := planRunLine(dcn, wrong, "1", "", nil); err == nil || !strings.Contains(err.Error(), "is for elsewhere-2") {
|
|
t.Errorf("a line for another machine: %v", err)
|
|
}
|
|
// A key other than this machine's own: the device pinned someone else
|
|
// under this machine's name.
|
|
_ = unpinMachineKey(planHost())
|
|
_ = pinMachineKey(planHost(), base64.StdEncoding.EncodeToString(pub2))
|
|
impostor, _ := signPlan(t, dcn, planDraft(false), "")
|
|
if _, err := planRunLine(dcn, impostor, "1", "", nil); err == nil || !strings.Contains(err.Error(), "another key for this machine") {
|
|
t.Errorf("a key that differs from this machine's: %v", err)
|
|
}
|
|
_ = unpinMachineKey(planHost())
|
|
own := strings.Fields(machineKeyLine())[3]
|
|
_ = pinMachineKey(planHost(), own)
|
|
// A device this machine never pinned.
|
|
if err := unpinDeviceKey(p.Device); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := planRunLine(dcn, b64, "1", "", nil); err == nil || !strings.Contains(err.Error(), "has not pinned") {
|
|
t.Errorf("an unpinned device: %v", err)
|
|
}
|
|
_ = pinDeviceKey(p.Device)
|
|
// A missing receipt, and a foreign one: line 2 takes build's digest.
|
|
if _, err := planRunLine(dcn, b64, "2", "", nil); err == nil || !strings.Contains(err.Error(), "no successful receipt of it came") {
|
|
t.Errorf("a missing receipt: %v", err)
|
|
}
|
|
_, sha, _ := planOpen(envOf(b64))
|
|
good := lineReceipt{Plan: p.Nonce, PlanSHA256: sha, Pipeline: p.Pipeline, Commit: p.Commit, N: 1, Step: "build",
|
|
Machine: planHost(), OK: true, Images: []opResultImage{{Image: "forge.example.com/acme/web",
|
|
Digest: "sha256:" + strings.Repeat("1", 64), Source: "https://forge.example.com/acme/app.git", Commit: p.Commit}}}
|
|
fpub, fpriv, _ := ed25519.GenerateKey(nil)
|
|
foreign := good
|
|
foreign.V, foreign.Key = 1, base64.StdEncoding.EncodeToString(fpub)
|
|
fraw, _ := json.Marshal(foreign)
|
|
fs := opSignedReceipt{PayloadType: lineReceiptPayloadType, Payload: base64.StdEncoding.EncodeToString(fraw),
|
|
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(fpriv, dssePAE(lineReceiptPayloadType, fraw)))}
|
|
if _, err := lineReceiptVerify(fs, p, sha); err == nil || !strings.Contains(err.Error(), "does not name") {
|
|
t.Errorf("a foreign receipt: %v", err)
|
|
}
|
|
// A receipt of another plan, and one of a failed build, feed nothing.
|
|
_, p3 := signPlan(t, dcn, planDraft(false), "")
|
|
otherPlan := good
|
|
otherPlan.Plan = p3.Nonce
|
|
s3, _ := lineReceiptSign(otherPlan)
|
|
if _, err := lineReceiptVerify(s3, p, sha); err == nil || !strings.Contains(err.Error(), "another plan") {
|
|
t.Errorf("another plan's receipt: %v", err)
|
|
}
|
|
failedBuild := good
|
|
failedBuild.OK = false
|
|
s4, _ := lineReceiptSign(failedBuild)
|
|
if _, _, err := planInputs(p, sha, &p.Lines[1], []opSignedReceipt{s4}); err == nil || !strings.Contains(err.Error(), "succeeded") {
|
|
t.Errorf("a failed build's receipt: %v", err)
|
|
}
|
|
s5, _ := lineReceiptSign(good)
|
|
params, _, err := planInputs(p, sha, &p.Lines[1], []opSignedReceipt{s5})
|
|
if err != nil || !strings.Contains(params, "sha256:"+strings.Repeat("1", 64)) {
|
|
t.Fatalf("the build's receipt fills the roll's digest: %q %v", params, err)
|
|
}
|
|
// An expired plan.
|
|
d = planDraft(false)
|
|
d["plan"].(map[string]any)["expires"] = time.Now().Add(2 * time.Second).UTC().Format(time.RFC3339)
|
|
soon, _ := signPlan(t, dcn, d, "")
|
|
time.Sleep(3 * time.Second)
|
|
if _, err := planRunLine(dcn, soon, "1", "", nil); err == nil || !strings.Contains(err.Error(), "expired") {
|
|
t.Errorf("an expired plan: %v", err)
|
|
}
|
|
// A replayed line: spent once.
|
|
if err := planSpend(p, "1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := planSpend(p, "1"); err == nil || !strings.Contains(err.Error(), "ran already") {
|
|
t.Errorf("a replayed line: %v", err)
|
|
}
|
|
// An older plan than one this machine ran for the pipeline.
|
|
_ = planRecordVersion(p.Pipeline, p3.Version)
|
|
if _, err := planLineFor(p, 1); err == nil || !strings.Contains(err.Error(), "is older") {
|
|
t.Errorf("an older version: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnUndoNeedsAFailedLaterLineOfThePlan(t *testing.T) {
|
|
dcn := planBox(t)
|
|
b64, p := signPlan(t, dcn, planDraft(false), "")
|
|
raw, _ := base64.StdEncoding.DecodeString(b64)
|
|
var e shipPlanEnvelope
|
|
_ = json.Unmarshal(raw, &e)
|
|
_, sha, _ := planOpen(e)
|
|
rec := func(n int, ok bool, verr string) string {
|
|
s, _ := lineReceiptSign(lineReceipt{Plan: p.Nonce, PlanSHA256: sha, Pipeline: p.Pipeline, Commit: p.Commit,
|
|
N: n, Step: p.Lines[n-1].Step, Machine: planHost(), OK: ok, VerifyError: verr, Attempt: 1})
|
|
b, _ := json.Marshal(s)
|
|
return base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
// Line 1 is not marked undoable in this plan; line 2 is.
|
|
if _, err := planUndoLine(b64, "1", rec(2, false, "")); err == nil || !strings.Contains(err.Error(), "not one the plan lets be undone") {
|
|
t.Errorf("an undo the plan does not allow: %v", err)
|
|
}
|
|
// An earlier line's failure, or any success, starts no undo of line 2.
|
|
for name, r := range map[string]string{"an earlier failure": rec(1, false, ""), "a success": rec(1, true, ""),
|
|
"its own success": rec(2, true, "")} {
|
|
if _, err := planUndoLine(b64, "2", r); err == nil || !strings.Contains(err.Error(), "LATER line") {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
// Its own failure may take back what it did — but it never ran here.
|
|
if _, err := planUndoLine(b64, "2", rec(2, false, "")); err == nil || !strings.Contains(err.Error(), "no record of running line 2") {
|
|
t.Errorf("its own failure, on a machine that never ran it: %v", err)
|
|
}
|
|
// A three-line plan: line 3's failed check undoes line 2, once, and only
|
|
// to what this machine recorded before line 2 ran.
|
|
d := planDraft(false)
|
|
lines := d["plan"].(map[string]any)["lines"].([]any)
|
|
lines = append(lines, map[string]any{"n": 3, "step": "check", "machine": planHost(), "op": "bridge-check",
|
|
"op_sha256": strings.Repeat("e", 64), "params": `{}`})
|
|
d["plan"].(map[string]any)["lines"] = lines
|
|
b64, p = signPlan(t, dcn, d, "")
|
|
raw, _ = base64.StdEncoding.DecodeString(b64)
|
|
_ = json.Unmarshal(raw, &e)
|
|
_, sha, _ = planOpen(e)
|
|
if _, err := planUndoLine(b64, "2", rec(3, true, "")); err == nil || !strings.Contains(err.Error(), "LATER line") {
|
|
t.Errorf("a later line that passed: %v", err)
|
|
}
|
|
// A later line whose APPLY failed (a bad credential the pod handed can
|
|
// do that) is not a failed check, and starts nothing.
|
|
if _, err := planUndoLine(b64, "2", rec(3, false, "")); err == nil || !strings.Contains(err.Error(), "LATER line") {
|
|
t.Errorf("a later line whose apply failed: %v", err)
|
|
}
|
|
if _, err := planUndoLine(b64, "2", rec(3, true, "verify exited 1")); err == nil || !strings.Contains(err.Error(), "no record of running line 2") {
|
|
t.Errorf("a line this machine never ran: %v", err)
|
|
}
|
|
path, _ := planRunPath(p.Nonce, 2)
|
|
body, _ := json.Marshal(planRunRecord{Plan: p.Nonce, N: 2, Attempt: 1, State: planStatePassed})
|
|
_ = os.WriteFile(path, body, 0o600)
|
|
out, err := planUndoLine(b64, "2", rec(3, true, "verify exited 1"))
|
|
if err != nil || out["ok"] != true {
|
|
t.Fatalf("a failed check of a later line undoes it: %v %v", out, err)
|
|
}
|
|
if _, err := planUndoLine(b64, "2", rec(3, true, "logs.check failed")); err == nil || !strings.Contains(err.Error(), "undone here already") {
|
|
t.Errorf("an undo is spent once: %v", err)
|
|
}
|
|
}
|
|
|
|
// The device's words and the plan's form: nothing a pod writes can redraw,
|
|
// hide or reorder what the person reads, and a line shows only verbs this
|
|
// bastion runs with their own arguments.
|
|
func TestAPlanCarriesNothingThatCouldHideWhatThePersonReads(t *testing.T) {
|
|
planBox(t)
|
|
mut := func(f func(l map[string]any)) map[string]any {
|
|
d := planDraft(false)
|
|
f(d["plan"].(map[string]any)["lines"].([]any)[1].(map[string]any))
|
|
return d
|
|
}
|
|
for name, d := range map[string]map[string]any{
|
|
"an escape in an input's image": mut(func(l map[string]any) {
|
|
l["inputs"] = map[string]any{"digest": "build.digest:x\x1b[2A\x1b[2K\r"}
|
|
}),
|
|
"a line break in params": mut(func(l map[string]any) { l["params"] = "{\"a\":\"x\n (FAKE)\"}" }),
|
|
"a direction override in params": mut(func(l map[string]any) { l["params"] = "{\"a\":\"\u202egnp.exe\"}" }),
|
|
"a key twice in params": mut(func(l map[string]any) { l["params"] = `{"a":"1","a":"2"}` }),
|
|
"a verb this bastion does not run": mut(func(l map[string]any) {
|
|
l["verbs"] = []any{map[string]any{"verb": "shell.run\x1b[2A", "args": map[string]any{}}}
|
|
}),
|
|
"an argument name the verb does not take": mut(func(l map[string]any) {
|
|
l["verbs"] = []any{map[string]any{"verb": "unit.restart", "args": map[string]any{"unit": "x.service", "note\x1b[1A": "y"}}}
|
|
}),
|
|
"a control character in a verb's value": mut(func(l map[string]any) {
|
|
l["verbs"] = []any{map[string]any{"verb": "unit.restart", "args": map[string]any{"unit": "x.service\r"}}}
|
|
}),
|
|
} {
|
|
if _, err := deviceNewPlanRequest(d); err == nil {
|
|
t.Errorf("%s: filed", name)
|
|
}
|
|
}
|
|
// C1 too: U+009B is a whole escape sequence's opener on its own, U+0085 a line break.
|
|
if got := wordsSafe("a\x1b[2Ab\u202ec\nd\u009b2Ae\u0085f"); strings.ContainsAny(got, "\x1b\u202e\n\u009b\u0085") {
|
|
t.Fatalf("the words carry no control or direction character: %q", got)
|
|
}
|
|
// An input that names its image reads right.
|
|
d := planDraft(false)
|
|
d["plan"].(map[string]any)["lines"].([]any)[1].(map[string]any)["inputs"] = map[string]any{"digest": "build.digest:forge.example.com/acme/web"}
|
|
id, err := deviceNewPlanRequest(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w := deviceRequestsLoad()[id].Words; !strings.Contains(w, "digest = the digest of forge.example.com/acme/web that step build builds") {
|
|
t.Fatalf("the words: %s", w)
|
|
}
|
|
}
|
|
|
|
// A line runs only after every line before it succeeded, and runs at most
|
|
// as many times as the plan allows.
|
|
func TestLinesRunInOrderAndOnlyAsOftenAsThePlanAllows(t *testing.T) {
|
|
dcn := planBox(t)
|
|
d := planDraft(false)
|
|
lines := d["plan"].(map[string]any)["lines"].([]any)
|
|
lines = append(lines, map[string]any{"n": 3, "step": "check", "machine": planHost(), "op": "bridge-check",
|
|
"op_sha256": strings.Repeat("e", 64), "params": `{}`, "attempts": 2})
|
|
d["plan"].(map[string]any)["lines"] = lines
|
|
b64, p := signPlan(t, dcn, d, "")
|
|
raw, _ := base64.StdEncoding.DecodeString(b64)
|
|
var e shipPlanEnvelope
|
|
_ = json.Unmarshal(raw, &e)
|
|
_, sha, _ := planOpen(e)
|
|
sign := func(n int, ok bool) opSignedReceipt {
|
|
s, _ := lineReceiptSign(lineReceipt{Plan: p.Nonce, PlanSHA256: sha, Pipeline: p.Pipeline, Commit: p.Commit,
|
|
N: n, Step: p.Lines[n-1].Step, Machine: planHost(), OK: ok})
|
|
return s
|
|
}
|
|
enc := func(rs ...opSignedReceipt) string {
|
|
b, _ := json.Marshal(rs)
|
|
return base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
// The check before the build and the roll: refused, nothing spent.
|
|
if _, err := planRunLine(dcn, b64, "3", "", nil); err == nil || !strings.Contains(err.Error(), "runs only after line 1") {
|
|
t.Fatalf("a line before the ones before it: %v", err)
|
|
}
|
|
if _, err := planRunLine(dcn, b64, "3", enc(sign(1, true)), nil); err == nil || !strings.Contains(err.Error(), "runs only after line 2") {
|
|
t.Fatalf("a line after only some of the ones before it: %v", err)
|
|
}
|
|
if _, err := planRunLine(dcn, b64, "3", enc(sign(1, true), sign(2, false)), nil); err == nil || !strings.Contains(err.Error(), "runs only after line 2") {
|
|
t.Fatalf("a line after one that failed: %v", err)
|
|
}
|
|
// Attempts: line 3 may run twice, and no more.
|
|
l := &p.Lines[2]
|
|
for want := 1; want <= 2; want++ {
|
|
if k, err := planSpendAttempt(p, l); err != nil || k != want {
|
|
t.Fatalf("attempt %d: %d %v", want, k, err)
|
|
}
|
|
}
|
|
if _, err := planSpendAttempt(p, l); err == nil || !strings.Contains(err.Error(), "all it may") {
|
|
t.Fatalf("a third attempt: %v", err)
|
|
}
|
|
}
|
|
|
|
// signPlanRaw signs any plan with this device's key, as a device that was
|
|
// changed (or a forged one holding the key) could: the machine's own checks
|
|
// must hold even then.
|
|
func signPlanRaw(t *testing.T, p *shipPlan) string {
|
|
t.Helper()
|
|
f, err := deviceKeyLoad()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
priv, _ := base64.StdEncoding.DecodeString(f.Priv)
|
|
p.Device = f.Pub
|
|
body, _ := json.Marshal(p)
|
|
env := shipPlanEnvelope{PayloadType: shipPlanPayloadType, Payload: base64.StdEncoding.EncodeToString(body),
|
|
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(priv), dssePAE(shipPlanPayloadType, body)))}
|
|
raw, _ := json.Marshal(env)
|
|
return base64.StdEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
// A root line runs only from a plan that says the code was given, whatever
|
|
// signed it; and a line runs only with the verbs its held op declares.
|
|
func TestARootLineNeedsTheCodeAndTheOpsOwnVerbs(t *testing.T) {
|
|
dcn := planBox(t)
|
|
own := strings.Fields(machineKeyLine())[3]
|
|
p := &shipPlan{V: shipPlanVersion, Kind: shipPlanKind, Pipeline: "bridge", Version: 1, Commit: strings.Repeat("c", 40),
|
|
Nonce: strings.Repeat("9", 32), Expires: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), Root: true,
|
|
Machines: map[string]string{planHost(): own},
|
|
Lines: []shipPlanLine{{N: 1, Step: "roll", Machine: planHost(), Op: "bridge-roll", OpSHA256: strings.Repeat("b", 64),
|
|
Params: `{}`, Verbs: []opVerbLine{{Verb: "unit.restart", Args: map[string]string{"unit": "x.service"}}}}}}
|
|
if _, err := planRunLine(dcn, signPlanRaw(t, p), "1", "", nil); err == nil || !strings.Contains(err.Error(), "approved with your code") {
|
|
t.Fatalf("a root line without the code: %v", err)
|
|
}
|
|
// The verbs a line shows must be exactly the held op's.
|
|
man := `name: bridge-roll
|
|
version: 1.0.0
|
|
summary: Restart the bridge.
|
|
kind: action
|
|
target: machine
|
|
privilege: root
|
|
interpreter: /bin/sh
|
|
time_limit: 30
|
|
params: {}
|
|
verbs:
|
|
- {verb: unit.restart, unit: ceigas-governance-bridge.service}
|
|
modes: [dry_run, apply, verify]
|
|
undo: none
|
|
`
|
|
m, err := parseOpManifest([]byte(man))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l := shipPlanLine{N: 1, Step: "roll", Op: "bridge-roll", Params: `{}`}
|
|
l.Verbs = planVerbsForShow(m, &l)
|
|
if err := planLineMatchesOp(&l, m, `{}`); err != nil {
|
|
t.Fatalf("the op's own verbs: %v", err)
|
|
}
|
|
l.Verbs = []opVerbLine{{Verb: "unit.restart", Args: map[string]string{"unit": "something-harmless.service"}}}
|
|
if err := planLineMatchesOp(&l, m, `{}`); err == nil || !strings.Contains(err.Error(), "other verbs") {
|
|
t.Fatalf("a line that shows other verbs than the op runs: %v", err)
|
|
}
|
|
l.Verbs = nil
|
|
if err := planLineMatchesOp(&l, m, `{}`); err == nil {
|
|
t.Fatal("a line that shows no verbs for a root op")
|
|
}
|
|
}
|
|
|
|
// The approvals page: a root plan's card asks for the code, and "approve all
|
|
// shown" leaves it waiting, even when its id is posted.
|
|
func TestThePageAsksTheCodeForARootPlanAndNeverApprovesItWithAll(t *testing.T) {
|
|
planBox(t)
|
|
root, err := deviceNewPlanRequest(planDraft(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := deviceNewPlanRequest(planDraft(false))
|
|
b, _ := deviceNewPlanRequest(planDraft(false))
|
|
u := uiTestServer(t)
|
|
h := u.handler()
|
|
cookie := uiSignedIn(t, u)
|
|
body := uiGet(t, h, "/fragments/approvals", cookie).Body.String()
|
|
// The card's approve form: the one posting to /device/approve with its id.
|
|
asked := false
|
|
for _, f := range strings.Split(body, `<form hx-post="/device/approve" `)[1:] {
|
|
f = f[:strings.Index(f, "</form>")]
|
|
if strings.Contains(f, `value="`+root+`"`) {
|
|
asked = strings.Contains(f, `name="code"`)
|
|
} else if strings.Contains(f, `name="code"`) {
|
|
t.Error("a plan that needs no code is asked for one")
|
|
}
|
|
}
|
|
if !asked {
|
|
t.Fatal("the root plan's card asks for the code")
|
|
}
|
|
all := body[strings.Index(body, `class="approve-all"`):]
|
|
all = all[:strings.Index(all, "</form>")]
|
|
if strings.Contains(all, root) || !strings.Contains(all, a) || !strings.Contains(all, b) || !strings.Contains(all, "Approve all 2 shown") {
|
|
t.Fatalf("approve-all holds the two plans that need no code, not the root one: %q", all)
|
|
}
|
|
w := uiPost(t, h, "/device/approve-shown", url.Values{"id": {a, b, root}}, cookie)
|
|
if !strings.Contains(w.Body.String(), "wait for your code") || deviceStateOf(root) != "pending" {
|
|
t.Fatalf("a root plan posted to approve-all stays waiting: %q", w.Body.String())
|
|
}
|
|
if w := uiPost(t, h, "/device/approve", url.Values{"id": {root}}, cookie); !strings.Contains(w.Body.String(), "Not approved") {
|
|
t.Fatalf("approved without the code: %q", w.Body.String())
|
|
}
|
|
if w := uiPost(t, h, "/device/approve", url.Values{"id": {root}, "code": {planCode(t)}}, cookie); !strings.Contains(w.Body.String(), "Approved") ||
|
|
deviceStateOf(root) != "approved" {
|
|
t.Fatalf("approved with the code: %q", w.Body.String())
|
|
}
|
|
}
|