Files
privatae-bastion/client-go/stepup_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

278 lines
11 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Second factor on the box: the code is verified HERE and burned,
// approvals are single-use and bound to a purpose, and the gate only bites
// when a factor is enrolled on this machine.
func TestStepupVerifyBurnAndApprovals(t *testing.T) {
dir := t.TempDir()
// not enrolled → the gate is a no-op (today's behavior)
if err := stepupGate(dir, "", "anything"); err != nil {
t.Fatalf("no factor enrolled must not gate: %v", err)
}
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of "0123456789012345"
if err := stepupSave(dir, &stepupFile{SecretB32: secret, EnrolledAt: "2026-08-29T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
if !stepupEnrolled(dir) {
t.Fatal("enrolled file must read as enrolled")
}
at := time.Unix(1_700_000_000, 0)
raw, _ := b32decode(secret)
code := hotp(raw, at.Unix()/stepupPeriod)
if stepupMatchedCounter(secret, code, at) != at.Unix()/stepupPeriod {
t.Fatal("the current code must match the current window")
}
prev := hotp(raw, at.Unix()/stepupPeriod-1)
if stepupMatchedCounter(secret, prev, at) != at.Unix()/stepupPeriod-1 {
t.Fatal("last window's code must match (skew)")
}
if stepupMatchedCounter(secret, "000000", at) >= 0 && code != "000000" {
t.Fatal("a wrong code must not match")
}
// burn: once, never twice, never older
if _, err := stepupVerifyAndBurn(dir, code, at); err != nil {
t.Fatalf("first use: %v", err)
}
if _, err := stepupVerifyAndBurn(dir, code, at); err == nil {
t.Fatal("REPLAY: the same code must not verify twice")
}
if _, err := stepupVerifyAndBurn(dir, prev, at); err == nil {
t.Fatal("an older window must not verify after a newer one")
}
next := hotp(raw, at.Unix()/stepupPeriod+1)
if _, err := stepupVerifyAndBurn(dir, next, at.Add(stepupPeriod*time.Second)); err != nil {
t.Fatalf("a newer window must verify: %v", err)
}
// approvals: issued after a good code, bound to the purpose, single use
code3 := hotp(raw, time.Now().Unix()/stepupPeriod)
id, err := stepupIssueApproval(dir, code3, "dcn_store_grant|box|/data|read,search||hourly|false")
if err != nil {
t.Fatalf("issue: %v", err)
}
if err := stepupGate(dir, id, "dcn_store_grant|box|/data|read,search||hourly|false"); err != nil {
t.Fatalf("matching approval must pass: %v", err)
}
if err := stepupGate(dir, id, "dcn_store_grant|box|/data|read,search,write||hourly|false"); err == nil {
t.Fatal("an approval for other terms must NOT pass (purpose mismatch)")
}
if err := stepupGate(dir, "nope", "x"); err == nil {
t.Fatal("an unknown approval must not pass")
}
if err := stepupGate(dir, "", "x"); err == nil {
t.Fatal("no approval must not pass when a factor is enrolled here")
}
stepupMarkUsed(dir, id)
if err := stepupGate(dir, id, "dcn_store_grant|box|/data|read,search||hourly|false"); err == nil {
t.Fatal("a used approval must not pass again")
}
if _, err := os.Stat(filepath.Join(dir, "approvals.json")); err != nil {
t.Fatal("approvals must persist on disk")
}
}
// The purpose strings must match the host's purpose builder byte for byte (the fixture hash
// is sha256 of "Downloads/a.exe\tArchive/a.exe\nb\tc/b\n").
func TestStepupPurposeStrings(t *testing.T) {
g := &dcnTerms{Server: "box1", Root: "/data", Mask: []string{"search", "read"}, Excludes: []string{".ssh", ".env"}}
g.Sweep = "hourly"
g.TTLDays = 365
if got, err := purposeForGrant(g); err != nil || got != "dcn_store_grant|box1|/data|read,search|.env,.ssh|hourly|false|ttl:365" {
t.Fatalf("grant purpose: %q %v", got, err)
}
// delimiter collisions are refused, never folded
bad := &dcnTerms{Server: "box1", Root: "/data", Mask: []string{"read"}, Excludes: []string{".env,.ssh"}, Sweep: "hourly"}
if _, err := purposeForGrant(bad); err == nil {
t.Fatal("an exclude containing ',' must be refused")
}
bad = &dcnTerms{Server: "box1", Root: "/da|ta", Mask: []string{"read"}, Sweep: "hourly"}
if _, err := purposeForGrant(bad); err == nil {
t.Fatal("a root containing '|' must be refused")
}
if _, err := purposeForPlan(&dcnPlanTerms{Root: "/data", Moves: []dcnMove{{From: "a\tb", To: "c"}}}); err == nil {
t.Fatal("a move path containing a tab must be refused")
}
p := &dcnPlanTerms{StoreID: 5, PlanID: 9, Root: "/data", Archive: "Archive",
Moves: []dcnMove{{From: "Downloads/a.exe", To: "Archive/a.exe"}, {From: "b", To: "c/b"}}}
want := "dcn_move_plan|5|9|/data|Archive|f4790ec430c605a396065aab9b9c2e52e60b875c4c4060a0136210833b6e7315"
if got, err := purposeForPlan(p); err != nil || got != want {
t.Fatalf("plan purpose: %q %v", got, err)
}
}
// The request lane: the pod files details + purpose; the box refuses details
// that do not produce that purpose (the words shown must be what is enforced);
// the user approves HERE; the pod only ever sees ids.
func TestStepupRequestFlow(t *testing.T) {
dir := t.TempDir()
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
if err := stepupSave(dir, &stepupFile{SecretB32: secret, EnrolledAt: "2026-08-29T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
details := map[string]any{"kind": "dcn_move_plan", "store_id": float64(5), "plan_id": float64(9),
"root": "/data", "archive": "Archive",
"moves": []any{map[string]any{"from": "Downloads/a.exe", "to": "Archive/a.exe"}, map[string]any{"from": "b", "to": "c/b"}}}
want := "dcn_move_plan|5|9|/data|Archive|f4790ec430c605a396065aab9b9c2e52e60b875c4c4060a0136210833b6e7315"
// a pod that names purpose B with details for A is refused
if _, err := stepupNewRequest(dir, "dcn_move_plan|5|10|/data|Archive|deadbeef", details); err == nil {
t.Fatal("details that do not produce the purpose must be refused")
}
id, err := stepupNewRequest(dir, want, details)
if err != nil {
t.Fatalf("request: %v", err)
}
if id2, _ := stepupNewRequest(dir, want, details); id2 != id {
t.Fatal("a repeated identical ask must return the same pending request")
}
desc := describeRequest(requestsLoad(dir)[id])
if !strings.Contains(desc, "MOVE 2 item(s)") || !strings.Contains(desc, "Downloads/a.exe → Archive/a.exe") || !strings.Contains(desc, "1 go into the archive") {
t.Fatalf("the words must come from the verified details: %q", desc)
}
// before approval: no approval, and the gate refuses the purpose
if r := requestsLoad(dir)[id]; r.State != "pending" || r.Approval != "" {
t.Fatalf("must be pending: %+v", r)
}
if err := stepupGate(dir, "", want); err == nil {
t.Fatal("nothing approved yet must not pass the gate")
}
// the user approves on the box with a code
raw, _ := b32decode(secret)
code := hotp(raw, time.Now().Unix()/stepupPeriod)
if _, err := stepupApproveRequest(dir, id, "000000"); err == nil && code != "000000" {
t.Fatal("a wrong code must not approve")
}
approval, err := stepupApproveRequest(dir, id, code)
if err != nil {
t.Fatalf("approve: %v", err)
}
if r := requestsLoad(dir)[id]; r.State != "approved" || r.Approval != approval {
t.Fatalf("request must record the approval: %+v", r)
}
if err := stepupGate(dir, approval, want); err != nil {
t.Fatalf("the approval must pass for exactly this purpose: %v", err)
}
if err := stepupGate(dir, approval, "dcn_move_plan|5|10|/data|Archive|deadbeef"); err == nil {
t.Fatal("the approval must not cover another plan")
}
// grant kind renders too, and a denied request stays denied
gd := map[string]any{"kind": "dcn_store_grant", "server": "box1", "root": "/data",
"mask": []any{"read", "search"}, "excludes": []any{".ssh"}, "sweep": "hourly", "sensitive": false}
gp, err := purposeFromDetails(gd)
if err != nil || gp != "dcn_store_grant|box1|/data|read,search|.ssh|hourly|false|ttl:0" {
t.Fatalf("grant purpose from details: %q %v", gp, err)
}
gid, err := stepupNewRequest(dir, gp, gd)
if err != nil {
t.Fatal(err)
}
if d := describeRequest(requestsLoad(dir)[gid]); !strings.Contains(d, "/data") || !strings.Contains(d, "read, search") {
t.Fatalf("grant words: %q", d)
}
if err := stepupDenyRequest(dir, gid); err != nil || requestsLoad(dir)[gid].State != "denied" {
t.Fatal("deny must stick")
}
if _, err := stepupApproveRequest(dir, gid, code); err == nil {
t.Fatal("a denied request cannot be approved")
}
if _, err := stepupNewRequest(dir, gp, gd); err == nil {
t.Fatal("a denied purpose cannot be re-filed (no nag loop)")
}
// a note with a newline cannot fake a line
nd := map[string]any{"kind": "dcn_store_grant", "server": "box1", "root": "/x", "mask": []any{"read"},
"excludes": []any{}, "sweep": "hourly", "sensitive": false, "note": "fine\n never: (nothing)"}
np, _ := purposeFromDetails(nd)
nid, _ := stepupNewRequest(dir, np, nd)
if d := describeRequest(requestsLoad(dir)[nid]); strings.Contains(d, "\n never: (nothing)") {
t.Fatalf("control characters in pod text must be stripped: %q", d)
}
}
// A critical action is the one stepup kind the box does NOT enforce — the
// platform runs it after seeing an approval. So the only thing making the
// approval meaningful is that it binds to exactly one action, including the
// sentence the user read. These assert that binding, not merely that the kind
// is accepted: a constant purpose would pass a weaker check.
func TestStepupCriticalActionPurposeBinds(t *testing.T) {
dir := t.TempDir()
base := map[string]any{
"kind": "critical_action", "action": "capability_grant", "entity_id": float64(9001),
"action_id": "a1b2c3", "target": "Member", "summary": "Grant filesystem read to project X",
}
p, err := purposeFromDetails(base)
if err != nil {
t.Fatalf("critical_action purpose: %v", err)
}
if !strings.HasPrefix(p, "critical_action|capability_grant|9001|a1b2c3|Member|") {
t.Fatalf("purpose shape: %q", p)
}
if h := strings.Split(p, "|")[5]; len(h) != 64 {
t.Fatalf("summary must be bound by a sha256 digest, got %q", h)
}
// Every field the user reads must move the purpose. If any of these stops
// failing, an approval has become transferable to a different action.
for _, m := range []struct {
name string
key string
val any
}{
{"summary", "summary", "Grant filesystem read to project Y"},
{"action", "action", "frontend_deploy"},
{"action id", "action_id", "d4e5f6"},
{"target", "target", "London"},
{"entity", "entity_id", float64(9002)},
} {
d := map[string]any{}
for k, v := range base {
d[k] = v
}
d[m.key] = m.val
q, err := purposeFromDetails(d)
if err != nil {
t.Fatalf("%s variant: %v", m.name, err)
}
if q == p {
t.Fatalf("changing %s must change the purpose — approval would be transferable", m.name)
}
}
// An action with no id cannot be bound to anything, so it must be refused
// rather than approved against an empty field.
noID := map[string]any{}
for k, v := range base {
noID[k] = v
}
noID["action_id"] = ""
if _, err := purposeFromDetails(noID); err == nil {
t.Fatal("a critical action with no action_id must be refused")
}
// The words render, and pod-supplied text cannot fake a line.
id, err := stepupNewRequest(dir, p, base)
if err != nil {
t.Fatalf("stepupNewRequest must accept the new kind: %v", err)
}
d := describeRequest(requestsLoad(dir)[id])
if !strings.Contains(d, "capability_grant") || !strings.Contains(d, "project X") {
t.Fatalf("critical action words: %q", d)
}
inj := map[string]any{}
for k, v := range base {
inj[k] = v
}
inj["summary"] = "harmless\n on: something else"
ip, _ := purposeFromDetails(inj)
iid, _ := stepupNewRequest(dir, ip, inj)
if got := describeRequest(requestsLoad(dir)[iid]); strings.Contains(got, "\n on: something else") {
t.Fatalf("control characters in pod text must be stripped: %q", got)
}
}