bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
package main
|
||||
|
||||
// The tray's lane to a system service (approvalpipe.go), driven through
|
||||
// pipeServe: who may use it, what it lists, and that a code approves exactly
|
||||
// the one request that was shown, once, and not by guessing.
|
||||
//
|
||||
// Every value here is invented.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pipeTestHost = "tray.host.example"
|
||||
pipeTestSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of "0123456789012345"
|
||||
pipeTestUser = "S-1-5-21-1111111111-2222222222-3333333333-1001"
|
||||
)
|
||||
|
||||
// thePerson is a client that may use the lane: the person signed in to an
|
||||
// active session, connecting from it.
|
||||
var thePerson = pipeClient{PID: 4242, UserSID: pipeTestUser, UserName: `EXAMPLE\pat`,
|
||||
SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true}
|
||||
|
||||
// pipeBox is one paired Host with a factor enrolled and two pending requests,
|
||||
// A and B, in an isolated config dir.
|
||||
func pipeBox(t *testing.T) (dir, idA, purposeA, idB, purposeB string) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
writePairing(t, pipeTestHost)
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil || len(cfgs) != 1 {
|
||||
t.Fatalf("pairing: %v %d", err, len(cfgs))
|
||||
}
|
||||
dir = cfgs[0].dcnDir()
|
||||
if err := stepupSave(dir, &stepupFile{SecretB32: pipeTestSecret, EnrolledAt: "2026-10-19T00:00:00Z", LastCounter: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The person owns this machine's approvals, as the installer records it.
|
||||
writeApprovalsOwner(t, pipeTestUser)
|
||||
ask := func(root string) (string, string) {
|
||||
d := map[string]any{"kind": "dcn_store_grant", "server": "desk-7", "root": root,
|
||||
"mask": []any{"read", "search"}, "excludes": []any{".ssh"}, "sweep": "hourly", "sensitive": false}
|
||||
p, err := purposeFromDetails(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := stepupNewRequest(dir, p, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id, p
|
||||
}
|
||||
idA, purposeA = ask("/srv/example-a")
|
||||
idB, purposeB = ask("/srv/example-b")
|
||||
return
|
||||
}
|
||||
|
||||
// writeApprovalsOwner records sid as the owner of this machine's approvals,
|
||||
// the file `approvals-owner` writes.
|
||||
func writeApprovalsOwner(t *testing.T, sid string) {
|
||||
t.Helper()
|
||||
p, err := approvalsOwnerPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: "2026-10-19T00:00:00Z"})
|
||||
if err := atomicWrite(p, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// anotherPerson is someone else with an account on the machine, signed in to
|
||||
// an active session of their own: everything pipeClientRefusal asks, and not
|
||||
// the owner.
|
||||
var anotherPerson = pipeClient{PID: 5151, UserSID: "S-1-5-21-1111111111-2222222222-3333333333-1002", UserName: `EXAMPLE\sam`,
|
||||
SessionID: 2, SessionUserSID: "S-1-5-21-1111111111-2222222222-3333333333-1002", SessionActive: true}
|
||||
|
||||
func TestThePipeServesOnlyTheAccountThatOwnsTheApprovals(t *testing.T) {
|
||||
dir, idA, purposeA, _, _ := pipeBox(t)
|
||||
now := time.Now()
|
||||
if why := pipeClientRefusal(anotherPerson); why != "" {
|
||||
t.Fatalf("the fixture must pass the session rules: %s", why)
|
||||
}
|
||||
refusedOwner := func(resp pipeResponse, what string) {
|
||||
t.Helper()
|
||||
if resp.OK || !strings.Contains(resp.Error, "belong to another account") {
|
||||
t.Fatalf("%s by another account must be refused: %+v", what, resp)
|
||||
}
|
||||
}
|
||||
refusedOwner(pipeServe(pipeRequest{Op: "list"}, anotherPerson, now), "list")
|
||||
code := pipeCodeNow()
|
||||
refusedOwner(pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, anotherPerson, now), "approve")
|
||||
refusedOwner(pipeServe(pipeRequest{Op: "deny", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA)}, anotherPerson, now), "deny")
|
||||
if r := stateOf(t, dir, idA); r.State != "pending" {
|
||||
t.Fatalf("a refused account changed the request: %s", r.State)
|
||||
}
|
||||
// Refused before any code was looked at: the owner's code still works.
|
||||
if resp := pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, thePerson, now); !resp.OK {
|
||||
t.Fatalf("the owner's approve: %+v", resp)
|
||||
}
|
||||
|
||||
// No owner recorded, or a record that cannot be read: nobody is served.
|
||||
p, _ := approvalsOwnerPath()
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp := pipeServe(pipeRequest{Op: "list"}, thePerson, now); resp.OK || !strings.Contains(resp.Error, "no account is recorded") {
|
||||
t.Fatalf("with no owner recorded the lane serves nobody: %+v", resp)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(`{"sid": 7`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp := pipeServe(pipeRequest{Op: "list"}, thePerson, now); resp.OK {
|
||||
t.Fatalf("an unreadable owner record must serve nobody: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func pipeCodeNow() string {
|
||||
raw, _ := b32decode(pipeTestSecret)
|
||||
return hotp(raw, time.Now().Unix()/stepupPeriod)
|
||||
}
|
||||
|
||||
func stateOf(t *testing.T, dir, id string) stepupRequest {
|
||||
t.Helper()
|
||||
r, ok := requestsLoad(dir)[id]
|
||||
if !ok {
|
||||
t.Fatalf("request %s is gone", id)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── who may connect ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestThePipeServesOnlyThePersonSignedInToTheirOwnSession(t *testing.T) {
|
||||
if why := pipeClientRefusal(thePerson); why != "" {
|
||||
t.Fatalf("the person at the screen was refused: %s", why)
|
||||
}
|
||||
other := "S-1-5-21-1111111111-2222222222-3333333333-1002"
|
||||
cases := map[string]pipeClient{
|
||||
"unknown account": {PID: 1, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
|
||||
"session 0": {PID: 1, UserSID: pipeTestUser, SessionID: 0, SessionUserSID: pipeTestUser, SessionActive: true},
|
||||
"SYSTEM in a session": {PID: 1, UserSID: "S-1-5-18", SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
|
||||
"a service account": {PID: 1, UserSID: "S-1-5-80-123-456", SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
|
||||
"another account, run as": {PID: 1, UserSID: other, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
|
||||
"nobody signed in to its session": {PID: 1, UserSID: pipeTestUser, SessionID: 2, SessionActive: true},
|
||||
"a disconnected session": {PID: 1, UserSID: pipeTestUser, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: false},
|
||||
}
|
||||
for name, c := range cases {
|
||||
if pipeClientRefusal(c) == "" {
|
||||
t.Errorf("%s: accepted, and must be refused", name)
|
||||
}
|
||||
}
|
||||
|
||||
// And a refused client gets nothing at all: no list, no approval, even
|
||||
// with the right code for a real request.
|
||||
dir, idA, purposeA, _, _ := pipeBox(t)
|
||||
intruder := cases["another account, run as"]
|
||||
if resp := pipeServe(pipeRequest{Op: "list"}, intruder, time.Now()); resp.OK || len(resp.Approvals) != 0 {
|
||||
t.Fatalf("a refused client was shown the list: %+v", resp)
|
||||
}
|
||||
resp := pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: pipeCodeNow()},
|
||||
intruder, time.Now())
|
||||
if resp.OK || stateOf(t, dir, idA).State != "pending" {
|
||||
t.Fatalf("a refused client approved a request: %+v", resp)
|
||||
}
|
||||
if f, _ := stepupLoad(dir); f.LastCounter != -1 {
|
||||
t.Fatal("a refused client's code was checked and burned")
|
||||
}
|
||||
}
|
||||
|
||||
// ── what it lists ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestThePipeListsWhatIsPendingInTheConsolesWordsAndNoSecret(t *testing.T) {
|
||||
dir, idA, purposeA, idB, _ := pipeBox(t)
|
||||
resp := pipeServe(pipeRequest{Op: "list"}, thePerson, time.Now())
|
||||
if !resp.OK || len(resp.Approvals) != 2 {
|
||||
t.Fatalf("list: %+v", resp)
|
||||
}
|
||||
byID := map[string]pipeApproval{}
|
||||
for _, a := range resp.Approvals {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
a, ok := byID[idA]
|
||||
if !ok || byID[idB].ID == "" {
|
||||
t.Fatalf("both pending requests must be listed: %+v", resp.Approvals)
|
||||
}
|
||||
// The same words `privatae-bastion approvals` prints.
|
||||
want := describeRequest(requestsLoad(dir)[idA])
|
||||
if strings.Join(a.Lines, "\n") != strings.TrimSpace(want) {
|
||||
t.Fatalf("the tray's words are not the console's:\n%q\n%q", a.Lines, want)
|
||||
}
|
||||
if a.Purpose != purposeDigest(purposeA) || a.Host != pipeTestHost {
|
||||
t.Fatalf("the card must name its Host and carry its purpose's digest: %+v", a)
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
if strings.Contains(string(raw), pipeTestSecret) {
|
||||
t.Fatal("the TOTP secret crossed the pipe")
|
||||
}
|
||||
// An approval id is what the Host spends; it never crosses either.
|
||||
if _, err := stepupApproveRequest(dir, idB, pipeCodeNow()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = json.Marshal(pipeServe(pipeRequest{Op: "list"}, thePerson, time.Now()))
|
||||
if appr := requestsLoad(dir)[idB].Approval; appr == "" || strings.Contains(string(raw), appr) {
|
||||
t.Fatalf("an approval id crossed the pipe, or none was issued (%q)", appr)
|
||||
}
|
||||
}
|
||||
|
||||
// ── a code approves exactly what was shown ──────────────────────────────────
|
||||
|
||||
func TestThePipeApprovesOneRequestWithACodeBoundToItsPurpose(t *testing.T) {
|
||||
dir, idA, purposeA, idB, purposeB := pipeBox(t)
|
||||
now := time.Now()
|
||||
approve := func(id, purposeSHA, code string) pipeResponse {
|
||||
return pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: id, Purpose: purposeSHA, Code: code}, thePerson, now)
|
||||
}
|
||||
|
||||
if r := approve(idA, purposeDigest(purposeA), ""); r.OK || !strings.Contains(r.Error, "code") {
|
||||
t.Fatalf("no code must not approve: %+v", r)
|
||||
}
|
||||
if r := approve(idA, purposeDigest(purposeA), "000000"); r.OK && pipeCodeNow() != "000000" {
|
||||
t.Fatalf("a wrong code approved: %+v", r)
|
||||
}
|
||||
// A valid code, posted for A with the purpose B's card showed: refused,
|
||||
// and the code is not spent by it.
|
||||
r := approve(idA, purposeDigest(purposeB), pipeCodeNow())
|
||||
if r.OK || !strings.Contains(r.Error, "not the one shown") {
|
||||
t.Fatalf("a code bound to another request's purpose must be refused: %+v", r)
|
||||
}
|
||||
if r := approve(idA, "", pipeCodeNow()); r.OK {
|
||||
t.Fatalf("a code with no purpose must be refused: %+v", r)
|
||||
}
|
||||
if stateOf(t, dir, idA).State != "pending" {
|
||||
t.Fatal("A was approved by a mismatched submission")
|
||||
}
|
||||
if f, _ := stepupLoad(dir); f.LastCounter != -1 {
|
||||
t.Fatal("a refused submission spent the code")
|
||||
}
|
||||
|
||||
// The right card: approved, once.
|
||||
if r := approve(idA, purposeDigest(purposeA), pipeCodeNow()); !r.OK {
|
||||
t.Fatalf("the right code for the request shown must approve it: %+v", r)
|
||||
}
|
||||
a := stateOf(t, dir, idA)
|
||||
if a.State != "approved" || a.Approval == "" {
|
||||
t.Fatalf("A must be approved: %+v", a)
|
||||
}
|
||||
if stateOf(t, dir, idB).State != "pending" {
|
||||
t.Fatal("approving A touched B")
|
||||
}
|
||||
// The approval binds A's purpose and nothing else.
|
||||
if err := stepupCheckApproval(dir, a.Approval, purposeB); err == nil {
|
||||
t.Fatal("A's approval passed for B's purpose")
|
||||
}
|
||||
if err := stepupConsumeApproval(dir, a.Approval, purposeA); err != nil {
|
||||
t.Fatalf("A's approval must pass for A's purpose: %v", err)
|
||||
}
|
||||
// And the code is spent: the same code cannot approve B.
|
||||
if r := approve(idB, purposeDigest(purposeB), pipeCodeNow()); r.OK {
|
||||
t.Fatal("one code approved two requests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePipeDeniesOnlyTheRequestShown(t *testing.T) {
|
||||
dir, idA, purposeA, _, purposeB := pipeBox(t)
|
||||
deny := func(purposeSHA string) pipeResponse {
|
||||
return pipeServe(pipeRequest{Op: "deny", Host: pipeTestHost, ID: idA, Purpose: purposeSHA}, thePerson, time.Now())
|
||||
}
|
||||
if r := deny(purposeDigest(purposeB)); r.OK || stateOf(t, dir, idA).State != "pending" {
|
||||
t.Fatalf("a deny for another card's purpose must be refused: %+v", r)
|
||||
}
|
||||
if r := deny(purposeDigest(purposeA)); !r.OK || stateOf(t, dir, idA).State != "denied" {
|
||||
t.Fatalf("deny: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── no guessing ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestThePipeThrottlesWrongCodesAndLocksUntilAnotherLaneApproves(t *testing.T) {
|
||||
dir, idA, purposeA, idB, purposeB := pipeBox(t)
|
||||
wrong := "000000"
|
||||
if pipeCodeNow() == wrong {
|
||||
wrong = "000001"
|
||||
}
|
||||
t0 := time.Now()
|
||||
try := func(code string, at time.Time) pipeResponse {
|
||||
return pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, thePerson, at)
|
||||
}
|
||||
for i := 0; i < pipeFreeFailures; i++ {
|
||||
if r := try(wrong, t0); r.OK || strings.Contains(r.Error, "try again") {
|
||||
t.Fatalf("attempt %d: the first %d wrong codes are not throttled: %+v", i+1, pipeFreeFailures, r)
|
||||
}
|
||||
}
|
||||
// Now even the RIGHT code is not looked at until pipeSlowEvery has passed.
|
||||
if r := try(pipeCodeNow(), t0.Add(time.Second)); r.OK || !strings.Contains(r.Error, "try again") {
|
||||
t.Fatalf("after %d wrong codes the next attempt must wait: %+v", pipeFreeFailures, r)
|
||||
}
|
||||
if stateOf(t, dir, idA).State != "pending" {
|
||||
t.Fatal("a throttled attempt approved")
|
||||
}
|
||||
if r := try(wrong, t0.Add(pipeSlowEvery+time.Second)); r.OK || strings.Contains(r.Error, "try again") {
|
||||
t.Fatalf("after the wait one attempt is taken: %+v", r)
|
||||
}
|
||||
|
||||
// At pipeLockAfter the lane takes no code at all, whatever the wait...
|
||||
raw, _ := json.Marshal(pipeThrottleFile{Failures: pipeLockAfter, Last: t0.UTC().Format(time.RFC3339)})
|
||||
if err := os.WriteFile(pipeThrottlePath(dir), raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := try(pipeCodeNow(), t0.Add(24*time.Hour)); r.OK || !strings.Contains(r.Error, "Administrator terminal") {
|
||||
t.Fatalf("a locked lane must refuse and say where to approve instead: %+v", r)
|
||||
}
|
||||
// ...until a code is accepted on another lane (here, what `approve` at an
|
||||
// Administrator terminal calls), which re-opens it.
|
||||
if _, err := stepupApproveRequest(dir, idB, pipeCodeNow()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f := pipeThrottleLoad(dir); f.Failures != 0 {
|
||||
t.Fatalf("an accepted code on another lane must reset the count, got %d", f.Failures)
|
||||
}
|
||||
_ = purposeB
|
||||
}
|
||||
|
||||
// ── the pipe's own permissions ──────────────────────────────────────────────
|
||||
|
||||
// TestTheApprovalPipeTakesLocalInteractiveClientsOnly reads the pipe's ACL and
|
||||
// its creation flags. Interactive users may read and write, and may NOT create
|
||||
// an instance of the pipe (FILE_APPEND_DATA, 0x4, is FILE_CREATE_PIPE_INSTANCE
|
||||
// on a pipe): that right would let a client stand up its own end of the name.
|
||||
// Remote clients are refused by the pipe itself. A Windows-only file, so this
|
||||
// reads it rather than runs it.
|
||||
func TestTheApprovalPipeTakesLocalInteractiveClientsOnly(t *testing.T) {
|
||||
m := regexp.MustCompile(`\(A;;(0x[0-9a-fA-F]+);;;IU\)`).FindStringSubmatch(approvalPipeSDDL)
|
||||
if m == nil {
|
||||
t.Fatalf("no interactive-users entry in %s", approvalPipeSDDL)
|
||||
}
|
||||
mask, err := strconv.ParseUint(m[1], 0, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mask&0x4 != 0 {
|
||||
t.Fatalf("interactive users may create pipe instances (mask %#x)", mask)
|
||||
}
|
||||
if mask&0x3 != 0x3 || mask&0x100000 == 0 {
|
||||
t.Fatalf("interactive users cannot read, write and wait on the pipe (mask %#x)", mask)
|
||||
}
|
||||
if strings.Count(approvalPipeSDDL, "(A;") != 3 || !strings.HasPrefix(approvalPipeSDDL, "D:P") {
|
||||
t.Fatalf("the pipe's DACL must be protected and hold exactly SYSTEM, Administrators and interactive users: %s", approvalPipeSDDL)
|
||||
}
|
||||
src, err := os.ReadFile("approvalpipe_windows.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"PIPE_REJECT_REMOTE_CLIENTS", "FILE_FLAG_FIRST_PIPE_INSTANCE", "SECURITY_IDENTIFICATION",
|
||||
"GetNamedPipeClientProcessId", "pipeServerIsService(c.h)"} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Errorf("approvalpipe_windows.go no longer uses %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user