757 lines
29 KiB
Go
757 lines
29 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.
|
|
|
|
package main
|
|
|
|
// Session lane walls, testable without a live relay: the wire framing round-trip
|
|
// (with a fake pty, and with a real one running /bin/cat on Linux), the grant
|
|
// refusal shape (exit 126 like an ungranted mgmt op), resize, the idle timer, the
|
|
// transcript, and root refused without a step-up approval.
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// fakeProc is a sessionProc that echoes input back as output, records resizes and
|
|
// ends when killed (or when its input pipe closes).
|
|
type fakeProc struct {
|
|
pr, pw *os.File
|
|
mu sync.Mutex
|
|
resizes []sessionResize
|
|
killed bool
|
|
done chan struct{}
|
|
waitOnce sync.Once
|
|
}
|
|
|
|
func newFakeProc() *fakeProc {
|
|
pr, pw, _ := os.Pipe()
|
|
return &fakeProc{pr: pr, pw: pw, done: make(chan struct{})}
|
|
}
|
|
|
|
func (f *fakeProc) Read(b []byte) (int, error) { return f.pr.Read(b) }
|
|
func (f *fakeProc) Write(b []byte) (int, error) { return f.pw.Write(b) }
|
|
func (f *fakeProc) Resize(c, r int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.resizes = append(f.resizes, sessionResize{Cols: c, Rows: r})
|
|
return nil
|
|
}
|
|
func (f *fakeProc) Wait() int {
|
|
f.waitOnce.Do(func() { <-f.done })
|
|
return 0
|
|
}
|
|
func (f *fakeProc) Kill() {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.killed {
|
|
return
|
|
}
|
|
f.killed = true
|
|
_ = f.pw.Close()
|
|
_ = f.pr.Close()
|
|
close(f.done)
|
|
}
|
|
func (f *fakeProc) User() string { return "fake" }
|
|
func (f *fakeProc) Shell() string { return "/bin/fake" }
|
|
|
|
// sessionTestEnv writes a pinned entity key + one signed grant and returns the env
|
|
// plus a signer for more grants.
|
|
func sessionTestEnv(t *testing.T, ops, deny []string) (sessionEnv, string, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
dcn := filepath.Join(dir, "dcn")
|
|
ctx := filepath.Join(dir, "ctx")
|
|
_ = os.MkdirAll(dcn, 0o700)
|
|
_ = os.MkdirAll(ctx, 0o700)
|
|
if err := dcnSaveFile(dcn, &dcnGrantFile{VerifyPubkey: base64.StdEncoding.EncodeToString(pub),
|
|
Stores: map[string]dcnGrantRec{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
terms, _ := json.Marshal(map[string]any{"entity": 9001, "server": "box1", "ops": ops, "deny": deny,
|
|
"intent": nil, "expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
|
env := sessionEnv{CtxRoot: ctx, DcnDir: dcn, HostName: "h.example", Server: "box1"}
|
|
return env, base64.StdEncoding.EncodeToString(terms), base64.StdEncoding.EncodeToString(ed25519.Sign(priv, terms))
|
|
}
|
|
|
|
// sessionDial stands up the box side (handleSessionConn on an upgraded socket) and
|
|
// returns the host side, already connected.
|
|
func sessionDial(t *testing.T, env sessionEnv) *websocket.Conn {
|
|
t.Helper()
|
|
return sessionDialAs(t, env, "")
|
|
}
|
|
|
|
// sessionDialAs is sessionDial with this box's own name given to the handler
|
|
// (what an anchored box passes: its hostname, for the per-session approval's
|
|
// purpose).
|
|
func sessionDialAs(t *testing.T, env sessionEnv, self string) *websocket.Conn {
|
|
t.Helper()
|
|
up := websocket.Upgrader{}
|
|
done := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer close(done)
|
|
c, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
_, first, err := c.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
handleSessionConn(c, first, self, env)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
// Cleanups run last-registered first: the host hangs up, then the handler
|
|
// is waited for, so nothing the session writes (its transcript, its audit
|
|
// line) lands after the test's dirs are removed.
|
|
t.Cleanup(func() {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("the session handler did not finish after the host hung up")
|
|
}
|
|
})
|
|
t.Cleanup(func() { _ = ws.Close() })
|
|
return ws
|
|
}
|
|
|
|
func sendSpec(t *testing.T, ws *websocket.Conn, spec sessionSpec) {
|
|
t.Helper()
|
|
b, _ := json.Marshal(spec)
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, append([]byte{'S'}, b...)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// readUntil collects frames until one with tag `until` arrives (or timeout); returns
|
|
// every frame seen keyed by tag, concatenated.
|
|
func readUntil(t *testing.T, ws *websocket.Conn, until byte, d time.Duration) map[byte][]byte {
|
|
t.Helper()
|
|
got := map[byte][]byte{}
|
|
_ = ws.SetReadDeadline(time.Now().Add(d))
|
|
for {
|
|
_, data, err := ws.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read (waiting for %c): %v — got so far %q", until, err, got)
|
|
}
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
got[data[0]] = append(got[data[0]], data[1:]...)
|
|
if data[0] == until {
|
|
return got
|
|
}
|
|
}
|
|
}
|
|
|
|
func withFake(t *testing.T) *fakeProc {
|
|
t.Helper()
|
|
fp := newFakeProc()
|
|
old := sessionSpawn
|
|
sessionSpawn = func(op, shell string, cols, rows int) (sessionProc, error) {
|
|
fp.Resize(cols, rows) // record the initial window like the real spawner applies it
|
|
return fp, nil
|
|
}
|
|
t.Cleanup(func() { sessionSpawn = old; fp.Kill() })
|
|
return fp
|
|
}
|
|
|
|
func TestSessionFramingResizeAndTranscript(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
fp := withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, Cols: 100, Rows: 30, GrantB64: terms, SigB64: sig, SessionID: "sess-0001"})
|
|
|
|
// I -> pty; the fake echoes it as O.
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, []byte("Ihello\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readUntil(t, ws, 'O', 3*time.Second)
|
|
if string(got['O']) != "hello\n" {
|
|
t.Fatalf("O frame: %q", got['O'])
|
|
}
|
|
// R -> resize.
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, []byte(`R{"cols":132,"rows":43}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
fp.mu.Lock()
|
|
n := len(fp.resizes)
|
|
fp.mu.Unlock()
|
|
if n >= 2 || time.Now().After(deadline) {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
fp.mu.Lock()
|
|
rz := append([]sessionResize(nil), fp.resizes...)
|
|
fp.mu.Unlock()
|
|
if len(rz) != 2 || rz[0] != (sessionResize{100, 30}) || rz[1] != (sessionResize{132, 43}) {
|
|
t.Fatalf("resizes: %+v", rz)
|
|
}
|
|
// Shell exits (pty EOF) -> X with its code.
|
|
fp.Kill()
|
|
got = readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "0" {
|
|
t.Fatalf("X frame: %q", got['X'])
|
|
}
|
|
// Transcript: 0600, start + output + stop lines.
|
|
p := filepath.Join(env.CtxRoot, "sessions", "sess-0001.log")
|
|
st, err := os.Stat(p)
|
|
if err != nil {
|
|
t.Fatalf("transcript missing: %v", err)
|
|
}
|
|
if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("transcript mode %o, want 0600", st.Mode().Perm())
|
|
}
|
|
raw, _ := os.ReadFile(p)
|
|
s := string(raw)
|
|
for _, want := range []string{"== session sess-0001 op=session:open user=fake", "hello\n", "== session sess-0001 stop=", "exit=0 reason=exit"} {
|
|
if !strings.Contains(s, want) {
|
|
t.Fatalf("transcript lacks %q:\n%s", want, s)
|
|
}
|
|
}
|
|
// Audit: start + stop rows in the pairing's audit.jsonl.
|
|
araw, _ := os.ReadFile(filepath.Join(env.DcnDir, "audit.jsonl"))
|
|
if !strings.Contains(string(araw), `"event":"start"`) || !strings.Contains(string(araw), `"event":"stop"`) {
|
|
t.Fatalf("audit lacks start/stop:\n%s", araw)
|
|
}
|
|
}
|
|
|
|
func TestSessionRefusedWithoutGrantIs126(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ops, deny []string
|
|
mangle func(*sessionSpec)
|
|
want string
|
|
}{
|
|
{"op not granted", []string{"uptime"}, nil, nil, "not in your grant"},
|
|
{"op denied", []string{sessionOpOpen}, []string{sessionOpOpen}, nil, "denied by your grant"},
|
|
{"deny all", []string{sessionOpOpen}, []string{"*"}, nil, "denied by your grant"},
|
|
{"bad signature", []string{sessionOpOpen}, nil, func(s *sessionSpec) { s.SigB64 = base64.StdEncoding.EncodeToString(make([]byte, 64)) }, "does not verify"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, c.ops, c.deny)
|
|
withFake(t)
|
|
ws := sessionDial(t, env)
|
|
spec := sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "sess-refuse"}
|
|
if c.mangle != nil {
|
|
c.mangle(&spec)
|
|
}
|
|
sendSpec(t, ws, spec)
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "126" || !strings.Contains(string(got['E']), c.want) {
|
|
t.Fatalf("want X 126 + E ~%q, got X=%q E=%q", c.want, got['X'], got['E'])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-refuse.log")); err == nil {
|
|
t.Fatal("a refused session must not leave a transcript")
|
|
}
|
|
araw, _ := os.ReadFile(filepath.Join(env.DcnDir, "audit.jsonl"))
|
|
if !strings.Contains(string(araw), `"event":"refused"`) {
|
|
t.Fatalf("refusal not audited:\n%s", araw)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 0.39.0 (rung 8): a terminal is the person's
|
|
// own act and no longer needs the host's signed management grant. On a local
|
|
// box the pairing's caps are the authorization; a frame with no grant opens.
|
|
// A grant that IS presented is still verified (the refusals above), and the
|
|
// key the frame offers is still pinned — the same pin, by the same rules.
|
|
func TestSessionOpensWithoutAGrantOnALocalBox(t *testing.T) {
|
|
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
fp := withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, SessionID: "sess-nogrant"})
|
|
// The fake shell echoes keyboard bytes as output: an O frame back means
|
|
// the session OPENED — with no grant in the frame at all.
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, []byte("Ihello\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readUntil(t, ws, 'O', 3*time.Second)
|
|
if string(got['O']) != "hello\n" {
|
|
t.Fatalf("a shell with no grant presented must open on a local box: O=%q E=%q X=%q", got['O'], got['E'], got['X'])
|
|
}
|
|
fp.Kill()
|
|
readUntil(t, ws, 'X', 3*time.Second)
|
|
araw, _ := os.ReadFile(filepath.Join(env.DcnDir, "audit.jsonl"))
|
|
if !strings.Contains(string(araw), `"event":"start"`) {
|
|
t.Fatalf("the session must be audited like any other:\n%s", araw)
|
|
}
|
|
// A key offered without a grant is still pinned, and a second key refused.
|
|
dir := t.TempDir()
|
|
pub, _, _ := ed25519.GenerateKey(nil)
|
|
key := base64.StdEncoding.EncodeToString(pub)
|
|
if why := sessionPinOfferedKey(dir, "", key); why != "" {
|
|
t.Fatalf("first offer must pin: %s", why)
|
|
}
|
|
f, _ := dcnLoadFile(dir)
|
|
if f.VerifyPubkey != key {
|
|
t.Fatalf("the offered key must be pinned: %q", f.VerifyPubkey)
|
|
}
|
|
pub2, _, _ := ed25519.GenerateKey(nil)
|
|
if why := sessionPinOfferedKey(dir, "", base64.StdEncoding.EncodeToString(pub2)); why == "" ||
|
|
!strings.Contains(why, "mismatch") {
|
|
t.Fatalf("a second key must be refused, not re-pinned: %q", why)
|
|
}
|
|
if sessionPinOfferedKey(dir, "", "") != "" {
|
|
t.Fatal("no key offered: nothing to pin, nothing to refuse")
|
|
}
|
|
}
|
|
|
|
// On an ANCHORED box the grant was never the wall for session:open — the
|
|
// per-session approval is (boxConsume, bound to this session id). With the
|
|
// grant no longer demanded (0.39.0), that approval is the ONLY wall left on
|
|
// the box, so it is driven here through the handler itself: a frame with no
|
|
// grant and no approval is refused 126 by boxConsume's own words, opens no
|
|
// shell and leaves no transcript.
|
|
func TestSessionOnAnAnchoredBoxTheApprovalIsTheWall(t *testing.T) {
|
|
setupBox(t)
|
|
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
env.HostName = tAnchor
|
|
withFake(t)
|
|
ws := sessionDialAs(t, env, tAnchor)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, SessionID: "sess-anchored-1"})
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "126" || len(got['O']) != 0 {
|
|
t.Fatalf("an anchored box must refuse a grant-less session:open with no approval: X=%q O=%q E=%q", got['X'], got['O'], got['E'])
|
|
}
|
|
if !strings.Contains(string(got['E']), "session:open needs your approval") {
|
|
t.Fatalf("the refusal must be the per-session approval's, not a grant's: E=%q", got['E'])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-anchored-1.log")); err == nil {
|
|
t.Fatal("a refused session must not leave a transcript")
|
|
}
|
|
araw, _ := os.ReadFile(filepath.Join(env.DcnDir, "audit.jsonl"))
|
|
if !strings.Contains(string(araw), `"event":"refused"`) {
|
|
t.Fatalf("refusal not audited:\n%s", araw)
|
|
}
|
|
// The same box WITH a factor enrolled: the wall is still the approval —
|
|
// a frame that carries none is refused by consumeApproval's own words,
|
|
// not waved through because the box could take one.
|
|
if err := stepupSave(env.DcnDir, &stepupFile{SecretB32: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
|
|
EnrolledAt: "2026-09-25T00:00:00Z", LastCounter: -1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ws2 := sessionDialAs(t, env, tAnchor)
|
|
sendSpec(t, ws2, sessionSpec{Op: sessionOpOpen, SessionID: "sess-anchored-2"})
|
|
got = readUntil(t, ws2, 'X', 3*time.Second)
|
|
if string(got['X']) != "126" || len(got['O']) != 0 || !strings.Contains(string(got['E']), "none was given") {
|
|
t.Fatalf("with a factor enrolled and no approval in the frame the session must still be refused: X=%q O=%q E=%q",
|
|
got['X'], got['O'], got['E'])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-anchored-2.log")); err == nil {
|
|
t.Fatal("a refused session must not leave a transcript")
|
|
}
|
|
}
|
|
|
|
func TestSessionServerMismatchRefused(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
env.Server = "other-box"
|
|
if _, err := sessionVerifyGrant(env.DcnDir, env.HostName, env.Server, sessionOpOpen, terms, sig, ""); err == nil {
|
|
t.Fatal("a grant for box1 must not open a session on other-box")
|
|
}
|
|
}
|
|
|
|
func TestSessionIdleTimeoutKills(t *testing.T) {
|
|
oldT, oldC := sessionIdleTimeout, sessionIdleCheck
|
|
sessionIdleTimeout, sessionIdleCheck = 50*time.Millisecond, 20*time.Millisecond
|
|
t.Cleanup(func() { sessionIdleTimeout, sessionIdleCheck = oldT, oldC })
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
fp := withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "sess-idle"})
|
|
got := readUntil(t, ws, 'X', 5*time.Second)
|
|
if string(got['X']) != "124" || !strings.Contains(string(got['E']), "idle") {
|
|
t.Fatalf("want X 124 + E idle, got X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
fp.mu.Lock()
|
|
killed := fp.killed
|
|
fp.mu.Unlock()
|
|
if !killed {
|
|
t.Fatal("idle timeout must kill the shell")
|
|
}
|
|
}
|
|
|
|
func TestSessionHostCloseKills(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
fp := withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "sess-close"})
|
|
_ = ws.WriteMessage(websocket.BinaryMessage, []byte("Ix"))
|
|
readUntil(t, ws, 'O', 3*time.Second)
|
|
_ = ws.Close()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
fp.mu.Lock()
|
|
k := fp.killed
|
|
fp.mu.Unlock()
|
|
if k {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("closing the socket must kill the shell")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestSessionRootRefusedWithoutStepup(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
|
|
withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpRoot, GrantB64: terms, SigB64: sig, SessionID: "sess-root"})
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "126" || !strings.Contains(string(got['E']), "session:root") {
|
|
t.Fatalf("want X 126 + E about session:root, got X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
// When the daemon IS root, the refusal must be the step-up one (no factor enrolled
|
|
// / no approval), never a spawn: the audit says refused, and no transcript exists.
|
|
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-root.log")); err == nil {
|
|
t.Fatal("root refused must not leave a transcript")
|
|
}
|
|
}
|
|
|
|
func TestSessionRootApprovalBindsToSession(t *testing.T) {
|
|
// The purpose the user approves names the server AND the session id, so an
|
|
// approval for one root shell cannot open another.
|
|
p1, err := purposeForSessionRoot("box1", "sess-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p2, _ := purposeForSessionRoot("box1", "sess-b")
|
|
if p1 == p2 {
|
|
t.Fatal("purposes for different sessions must differ")
|
|
}
|
|
if got, err := purposeFromDetails(map[string]any{"kind": "session_root", "server": "box1", "session_id": "sess-a"}); err != nil || got != p1 {
|
|
t.Fatalf("purposeFromDetails: %q %v", got, err)
|
|
}
|
|
if _, err := purposeForSessionRoot("box1", "a|b"); err == nil {
|
|
t.Fatal("reserved character must be refused")
|
|
}
|
|
}
|
|
|
|
func TestSessionBadSpecIsExit1(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
withFake(t)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "../../etc"})
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "1" {
|
|
t.Fatalf("bad session_id must be exit 1, got %q", got['X'])
|
|
}
|
|
}
|
|
|
|
func TestSessionShellListedAndPasswd(t *testing.T) {
|
|
dir := t.TempDir()
|
|
shells := filepath.Join(dir, "shells")
|
|
_ = os.WriteFile(shells, []byte("# list\n/bin/sh\n/bin/bash\n"), 0o644)
|
|
if !sessionShellListed(shells, "/bin/bash") || sessionShellListed(shells, "/bin/cat") || sessionShellListed(shells, "bash") {
|
|
t.Fatal("shell allowlist wrong")
|
|
}
|
|
passwd := filepath.Join(dir, "passwd")
|
|
_ = os.WriteFile(passwd, []byte("root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534::/nonexistent:/usr/sbin/nologin\n"), 0o644)
|
|
if sessionShellFromPasswd(passwd, "root") != "/bin/bash" || sessionShellFromPasswd(passwd, "nobody") != "/usr/sbin/nologin" || sessionShellFromPasswd(passwd, "x") != "" {
|
|
t.Fatal("passwd shell parse wrong")
|
|
}
|
|
}
|
|
|
|
// TestSessionRealPtyCat runs the real spawner with /bin/cat standing in as the shell
|
|
// (allowed through a fixture /etc/shells): input must come back through a real pty,
|
|
// with a real process group, and closing the socket must reap it.
|
|
func TestSessionRealPtyCat(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("real pty test is linux-only")
|
|
}
|
|
if _, err := os.Stat("/bin/cat"); err != nil {
|
|
t.Skip("no /bin/cat")
|
|
}
|
|
shells := filepath.Join(t.TempDir(), "shells")
|
|
_ = os.WriteFile(shells, []byte("/bin/cat\n"), 0o644)
|
|
oldShells := sessionShellsPath
|
|
sessionShellsPath = shells
|
|
t.Cleanup(func() { sessionShellsPath = oldShells })
|
|
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, Shell: "/bin/cat", Cols: 80, Rows: 24, GrantB64: terms, SigB64: sig, SessionID: "sess-real-cat"})
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, []byte("Iping\r")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A pty echoes the typed line and cat writes it back: "ping" appears at least once.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
var seen []byte
|
|
_ = ws.SetReadDeadline(deadline)
|
|
for !strings.Contains(string(seen), "ping") {
|
|
_, data, err := ws.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read: %v (seen %q)", err, seen)
|
|
}
|
|
if len(data) > 0 && data[0] == 'E' {
|
|
t.Fatalf("E frame: %s", data[1:])
|
|
}
|
|
if len(data) > 0 && data[0] == 'O' {
|
|
seen = append(seen, data[1:]...)
|
|
}
|
|
}
|
|
// Ctrl-D ends cat -> X with cat's exit.
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, []byte{'I', 4}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readUntil(t, ws, 'X', 5*time.Second)
|
|
if string(got['X']) != "0" {
|
|
t.Fatalf("cat exit: %q (E=%q)", got['X'], got['E'])
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(env.CtxRoot, "sessions", "sess-real-cat.log"))
|
|
if err != nil || !strings.Contains(string(raw), "ping") || !strings.Contains(string(raw), "reason=exit") {
|
|
t.Fatalf("transcript: %v\n%s", err, raw)
|
|
}
|
|
}
|
|
|
|
// TestSessionSpawnFailureLeavesNoTranscript: a spawn that fails must leave no
|
|
// O_EXCL file behind, so the same session id (and a root approval bound to it) can
|
|
// be retried.
|
|
func TestSessionSpawnFailureLeavesNoTranscript(t *testing.T) {
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
old := sessionSpawn
|
|
sessionSpawn = func(op, shell string, cols, rows int) (sessionProc, error) {
|
|
return nil, errBoom
|
|
}
|
|
t.Cleanup(func() { sessionSpawn = old })
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "sess-nospawn"})
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "1" || !strings.Contains(string(got['E']), "boom") {
|
|
t.Fatalf("X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-nospawn.log")); err == nil {
|
|
t.Fatal("failed spawn must not leave a transcript")
|
|
}
|
|
// And the id is retryable: a second attempt with a working spawner succeeds.
|
|
fp := withFake(t)
|
|
ws2 := sessionDial(t, env)
|
|
sendSpec(t, ws2, sessionSpec{Op: sessionOpOpen, GrantB64: terms, SigB64: sig, SessionID: "sess-nospawn"})
|
|
_ = ws2.WriteMessage(websocket.BinaryMessage, []byte("Iok"))
|
|
readUntil(t, ws2, 'O', 3*time.Second)
|
|
fp.Kill()
|
|
if got := readUntil(t, ws2, 'X', 3*time.Second); string(got['X']) != "0" {
|
|
t.Fatalf("retry after failed spawn: X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
}
|
|
|
|
var errBoom = errBoomT{}
|
|
|
|
type errBoomT struct{}
|
|
|
|
func (errBoomT) Error() string { return "boom: no shell today" }
|
|
|
|
// TestSessionWorkerLaneRouting: a session worker refuses an H frame with a plain
|
|
// line + X 1; a harness worker refuses an S frame the same way.
|
|
func TestSessionWorkerLaneRouting(t *testing.T) {
|
|
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
up := websocket.Upgrader{}
|
|
serve := func(handler func(*websocket.Conn)) *websocket.Conn {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
handler(c)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ws.Close() })
|
|
return ws
|
|
}
|
|
ws := serve(func(c *websocket.Conn) { handleSessionWorkerConn(c, "", env) })
|
|
_ = ws.WriteMessage(websocket.BinaryMessage, []byte(`H{"harness_id":"x","mode":"echo"}`))
|
|
got := readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "1" || !strings.Contains(string(got['E']), "session worker") {
|
|
t.Fatalf("session worker given H: X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
ws = serve(func(c *websocket.Conn) { handleHarnessConn(c, "", "") })
|
|
_ = ws.WriteMessage(websocket.BinaryMessage, []byte(`S{"op":"session:open","session_id":"sess-wrong-lane"}`))
|
|
got = readUntil(t, ws, 'X', 3*time.Second)
|
|
if string(got['X']) != "1" || !strings.Contains(string(got['E']), "harness worker") {
|
|
t.Fatalf("harness worker given S: X=%q E=%q", got['X'], got['E'])
|
|
}
|
|
}
|
|
|
|
// TestSessionRefusesTerminalServers: tmux/screen are servers that outlive the
|
|
// session's kill, so they are never the session shell even when /etc/shells lists them.
|
|
func TestSessionRefusesTerminalServers(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unix spawner")
|
|
}
|
|
shells := filepath.Join(t.TempDir(), "shells")
|
|
_ = os.WriteFile(shells, []byte("/usr/bin/tmux\n/usr/bin/screen\n/bin/sh\n"), 0o644)
|
|
old := sessionShellsPath
|
|
sessionShellsPath = shells
|
|
t.Cleanup(func() { sessionShellsPath = old })
|
|
for _, sh := range []string{"/usr/bin/tmux", "/usr/bin/screen"} {
|
|
if _, err := sessionResolveIdentity(sessionOpOpen, sh); err == nil || !strings.Contains(err.Error(), "terminal server") {
|
|
t.Fatalf("%s must be refused as a session shell, got %v", sh, err)
|
|
}
|
|
}
|
|
if !sessionShellIsServer("/opt/homebrew/bin/tmux") || sessionShellIsServer("/bin/zsh") {
|
|
t.Fatal("sessionShellIsServer wrong")
|
|
}
|
|
}
|
|
|
|
// TestSessionUnprivilegedHasNoRootGroups: when the daemon is root the spawned
|
|
// shell must carry exactly the session user's gid — no inherited supplementary
|
|
// groups, and never group 0. Runs the real spawner with /bin/sh and reads `id -G`.
|
|
func TestSessionUnprivilegedHasNoRootGroups(t *testing.T) {
|
|
if runtime.GOOS != "linux" || os.Geteuid() != 0 {
|
|
t.Skip("needs linux + root")
|
|
}
|
|
shells := filepath.Join(t.TempDir(), "shells")
|
|
_ = os.WriteFile(shells, []byte("/bin/sh\n"), 0o644)
|
|
old := sessionShellsPath
|
|
sessionShellsPath = shells
|
|
t.Cleanup(func() { sessionShellsPath = old })
|
|
|
|
env, terms, sig := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
|
ws := sessionDial(t, env)
|
|
sendSpec(t, ws, sessionSpec{Op: sessionOpOpen, Shell: "/bin/sh", GrantB64: terms, SigB64: sig, SessionID: "sess-groups"})
|
|
first := readUntil(t, ws, 'O', 10*time.Second) // the prompt: the shell is up and reading
|
|
if len(first['E']) > 0 {
|
|
t.Fatalf("E: %s", first['E'])
|
|
}
|
|
_ = ws.WriteMessage(websocket.BinaryMessage, []byte("Iecho GROUPS=$(id -G); echo UID=$(id -u); exit\r"))
|
|
got := readUntil(t, ws, 'X', 10*time.Second)
|
|
out := string(first['O']) + string(got['O'])
|
|
line := ""
|
|
for _, l := range strings.Split(out, "\n") {
|
|
l = strings.TrimSpace(l)
|
|
if strings.HasPrefix(l, "GROUPS=") && !strings.Contains(l, "$(") {
|
|
line = strings.TrimPrefix(l, "GROUPS=")
|
|
}
|
|
}
|
|
if line == "" {
|
|
t.Fatalf("no GROUPS line in output:\n%s", out)
|
|
}
|
|
groups := strings.Fields(line)
|
|
if len(groups) != 1 {
|
|
t.Fatalf("shell carries supplementary groups %v (want exactly the session gid)", groups)
|
|
}
|
|
if groups[0] == "0" {
|
|
t.Fatalf("shell is in group 0: %v", groups)
|
|
}
|
|
if strings.Contains(out, "UID=0") {
|
|
t.Fatalf("shell runs as uid 0:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A TERMINAL MUST NOT REQUIRE A FOLDER GRANT FIRST.
|
|
//
|
|
// Until 0.24.1 the box's pinned host key was written by exactly one thing — a
|
|
// DCN store sync — so "let the host open a terminal here" was inseparable from
|
|
// "give the host a folder here". Two different permissions, welded together by
|
|
// where the key happened to live. Measured across one deployment: 19 of 20
|
|
// online machines held zero DCN grants, so no terminal could open on any of
|
|
// them, and the refusal read "sync a grant first", which sounds like advice and
|
|
// is actually a coupling.
|
|
//
|
|
// The session frame now carries the host's own verify key and the box TOFU-pins
|
|
// it — the same shape, the same lane and the same mismatch rule as the DCN pin.
|
|
func TestSessionPinsTheHostKeyWithoutAStoreGrant(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
key := base64.StdEncoding.EncodeToString(pub)
|
|
hn, _ := os.Hostname()
|
|
|
|
// A signed grant the host would send for session:open on this machine.
|
|
terms, sig := func() (string, string) {
|
|
b, _ := json.Marshal(map[string]any{
|
|
"server": hn, "ops": []string{sessionOpOpen},
|
|
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
|
return base64.StdEncoding.EncodeToString(b),
|
|
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, b))
|
|
}()
|
|
|
|
// BEFORE: no key pinned and none offered — the old behaviour, unchanged.
|
|
if _, err := sessionVerifyGrant(dir, "", hn, sessionOpOpen, terms, sig, ""); err == nil ||
|
|
!contains(err.Error(), "no entity key pinned") {
|
|
t.Fatalf("a box with no pin and no offer must still refuse: %v", err)
|
|
}
|
|
f, _ := dcnLoadFile(dir)
|
|
if f.VerifyPubkey != "" {
|
|
t.Fatal("a refused verify must not have pinned anything")
|
|
}
|
|
|
|
// THE FIX: the host offers its key, the box pins it, the grant verifies —
|
|
// with no store grant anywhere.
|
|
if _, err := sessionVerifyGrant(dir, "", hn, sessionOpOpen, terms, sig, key); err != nil {
|
|
t.Fatalf("a terminal must open with no DCN store grant: %v", err)
|
|
}
|
|
f, _ = dcnLoadFile(dir)
|
|
if f.VerifyPubkey != key {
|
|
t.Fatalf("the host key must be pinned: %q", f.VerifyPubkey)
|
|
}
|
|
if len(f.Stores) != 0 {
|
|
t.Fatalf("pinning must NOT invent a store grant: %v", f.Stores)
|
|
}
|
|
|
|
// AND IT IS A PIN, NOT A HANDOFF. A second, different key is refused —
|
|
// otherwise anything that could reach the lane could re-home the box.
|
|
pub2, priv2, _ := ed25519.GenerateKey(nil)
|
|
b2, _ := json.Marshal(map[string]any{
|
|
"server": hn, "ops": []string{sessionOpOpen},
|
|
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
|
t2 := base64.StdEncoding.EncodeToString(b2)
|
|
s2 := base64.StdEncoding.EncodeToString(ed25519.Sign(priv2, b2))
|
|
if _, err := sessionVerifyGrant(dir, "", hn, sessionOpOpen, t2, s2,
|
|
base64.StdEncoding.EncodeToString(pub2)); err == nil || !contains(err.Error(), "mismatch") {
|
|
t.Fatalf("a different key must be refused, not re-pinned: %v", err)
|
|
}
|
|
f, _ = dcnLoadFile(dir)
|
|
if f.VerifyPubkey != key {
|
|
t.Fatalf("a refused re-pin must leave the original key: %q", f.VerifyPubkey)
|
|
}
|
|
|
|
// A malformed offer is refused and pins nothing.
|
|
dir2 := t.TempDir()
|
|
if _, err := sessionVerifyGrant(dir2, "", hn, sessionOpOpen, terms, sig, "not-base64!!"); err == nil ||
|
|
!contains(err.Error(), "malformed") {
|
|
t.Fatalf("a malformed key must be refused: %v", err)
|
|
}
|
|
f2, _ := dcnLoadFile(dir2)
|
|
if f2.VerifyPubkey != "" {
|
|
t.Fatalf("a malformed offer must pin nothing: %q", f2.VerifyPubkey)
|
|
}
|
|
}
|