diff --git a/go/internal/agent/grantstore.go b/go/internal/agent/grantstore.go new file mode 100644 index 0000000..238502b --- /dev/null +++ b/go/internal/agent/grantstore.go @@ -0,0 +1,122 @@ +// go/internal/agent/grantstore.go +// +// The agent's read side of the guest-grant store (G1c enforcement): find the +// grant backing a guest offer, tombstone a revoked gid, and sweep expired grant +// files at startup. AddGrant (the write side) lives in grants.go. +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +func tombstonePath(dir string) string { return filepath.Join(grantsDir(dir), "revoked.json") } + +// loadTombstones returns the set of revoked gids. A missing or unreadable file +// is an empty set — a grant is enforced on its signature and clock regardless, +// and revocation is the owner's tool, so a lost tombstone file fails toward +// "still valid until it expires", bounded by the 24 h TTL cap. +func loadTombstones(dir string) map[string]bool { + set := map[string]bool{} + raw, err := os.ReadFile(tombstonePath(dir)) + if err != nil { + return set + } + var gids []string + if json.Unmarshal(raw, &gids) != nil { + return set + } + for _, g := range gids { + set[g] = true + } + return set +} + +// TombstoneGrant records gid as revoked (idempotent) and removes its grant file +// so no future attach can load it. +func TombstoneGrant(dir, gid string) error { + set := loadTombstones(dir) + set[gid] = true + gids := make([]string, 0, len(set)) + for g := range set { + gids = append(gids, g) + } + data, err := json.Marshal(gids) + if err != nil { + return err + } + if err := os.MkdirAll(grantsDir(dir), 0o700); err != nil { + return err + } + if err := os.WriteFile(tombstonePath(dir), data, 0o600); err != nil { + return err + } + _ = os.Remove(grantPath(dir, gid)) + return nil +} + +// findValidGuestGrant returns the grant that authorizes a guest offer, or nil. +// A grant qualifies only if it names this owner and machine, is bound to the +// offering guest key, verifies against the owner signature, is not tombstoned, +// and its window covers now. Enforcement runs on EVERY attach, so a grant that +// has since expired or been revoked stops working without touching the file. +func findValidGuestGrant(dir, owner, machine, guest string, now time.Time) *identity.SignedGrant { + entries, err := os.ReadDir(grantsDir(dir)) + if err != nil { + return nil + } + tombstoned := loadTombstones(dir) + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" || e.Name() == "revoked.json" { + continue + } + raw, err := os.ReadFile(filepath.Join(grantsDir(dir), e.Name())) + if err != nil { + continue + } + sg, err := identity.ParseSignedGrant(raw) + if err != nil { + continue + } + if sg.Guest != guest || sg.Owner != owner || sg.Machine != machine { + continue + } + if tombstoned[sg.GID] { + continue + } + if identity.VerifyGrant(sg) != nil || sg.ValidAt(now) != nil { + continue + } + return sg + } + return nil +} + +// sweepExpiredGrants removes grant files whose window has fully closed (past na +// plus the skew tolerance), so the store does not grow without bound. Tombstones +// are kept — they are tiny and must outlive the grant file. Called at startup. +func sweepExpiredGrants(dir string, now time.Time) { + entries, err := os.ReadDir(grantsDir(dir)) + if err != nil { + return + } + cutoff := now.Add(-identity.GrantSkew).Unix() + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" || e.Name() == "revoked.json" { + continue + } + p := filepath.Join(grantsDir(dir), e.Name()) + raw, err := os.ReadFile(p) + if err != nil { + continue + } + sg, err := identity.ParseSignedGrant(raw) + if err != nil || sg.NA < cutoff { + _ = os.Remove(p) + } + } +} diff --git a/go/internal/agent/grouped.go b/go/internal/agent/grouped.go index e7ff51e..eb4c91b 100644 --- a/go/internal/agent/grouped.go +++ b/go/internal/agent/grouped.go @@ -35,9 +35,12 @@ func isDefaultTmuxLaunch(launch []string) bool { return true } -// groupedNameRe matches the session names this agent mints. The startup sweep -// and the snapshot filter act ONLY on names of this shape. -var groupedNameRe = regexp.MustCompile(`^mir-[0-9a-f]{8}$`) +// groupedNameRe matches the session names this agent mints: an owner attach's +// mir-<8 hex> and a read-write guest's guest-<8 hex> (spec G1c). The kill guard, +// startup sweep, and snapshot filter act ONLY on names of these shapes, so a +// guest session is cleaned up and hidden from other viewers exactly like a +// mir-* one, and neither can ever name the base. +var groupedNameRe = regexp.MustCompile(`^(?:mir|guest)-[0-9a-f]{8}$`) // newAttachSessionName mints a fresh grouped-session name (mir-<8 hex>). func newAttachSessionName() string { @@ -46,6 +49,12 @@ func newAttachSessionName() string { return "mir-" + hex.EncodeToString(b) } +// guestSessionName is a read-write guest's grouped session, derived from the +// grant id so detach cleanup and the orphan sweep can find it deterministically. +func guestSessionName(gid string) string { + return "guest-" + gid[:8] +} + var groupedBaseMu sync.Mutex // ensureGroupedBase makes sure the base session exists BEFORE a grouped member diff --git a/go/internal/agent/guest.go b/go/internal/agent/guest.go new file mode 100644 index 0000000..0638248 --- /dev/null +++ b/go/internal/agent/guest.go @@ -0,0 +1,306 @@ +// go/internal/agent/guest.go +// +// Guest enforcement at the agent (spec G1c). An offer whose proven principal is +// not a pinned owner but is bound by a stored, valid grant is served as a GUEST: +// +// - read-only (default): a pane mirror, not a tmux client. The agent paints +// the scope's active pane once with capture-pane, then streams pipe-pane +// output as terminal frames. Guest input is read and dropped — there is no +// shell and no tmux client, so a ro guest cannot type, switch windows, or +// see any other session. It is confinement by construction. +// - read-write: a grouped guest- session (D4's machinery) so the guest +// gets a real tmux client on the shared windows. A writable shell is total +// control as the agent user; the grant's --write consent said so. +// +// Both are bounded by a per-session deadline at the grant's na and by the +// revoke registry, so expiry and revocation tear a live guest down at once. +package agent + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "sync" + "syscall" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" + "github.com/srcful/terminal-relay/go/internal/noise" + "github.com/srcful/terminal-relay/go/internal/peer" +) + +// gidShapeRe matches a grant id (16 lowercase hex) before it names a file or a +// tombstone — the same shape identity.Grant validates. +var gidShapeRe = regexp.MustCompile(`^[0-9a-f]{16}$`) + +// guestRegistry tracks live guest sessions so revoke-grant (and a future admin +// action) can cancel every session serving a given gid immediately. +type guestRegistry struct { + mu sync.Mutex + next uint64 + byID map[string]map[uint64]context.CancelFunc +} + +func (g *guestRegistry) add(gid string, cancel context.CancelFunc) func() { + g.mu.Lock() + defer g.mu.Unlock() + if g.byID == nil { + g.byID = map[string]map[uint64]context.CancelFunc{} + } + if g.byID[gid] == nil { + g.byID[gid] = map[uint64]context.CancelFunc{} + } + id := g.next + g.next++ + g.byID[gid][id] = cancel + return func() { + g.mu.Lock() + defer g.mu.Unlock() + if m := g.byID[gid]; m != nil { + delete(m, id) + if len(m) == 0 { + delete(g.byID, gid) + } + } + } +} + +func (g *guestRegistry) drop(gid string) { + g.mu.Lock() + cancels := make([]context.CancelFunc, 0, len(g.byID[gid])) + for _, c := range g.byID[gid] { + cancels = append(cancels, c) + } + g.mu.Unlock() + for _, c := range cancels { + c() + } +} + +// serveGuest serves an authenticated guest over the Noise session per the +// grant's mode. The deadline at na and the revoke registry both cancel gctx, so +// expiry or revocation ends the session within the frame loop's next read. +func (rt *Runtime) serveGuest(ctx context.Context, mc peer.MsgConn, sess *noise.Session, grant *identity.SignedGrant) error { + rt.sessionStarted() + defer rt.sessionEnded() + + gctx, cancel := context.WithDeadline(ctx, time.Unix(grant.NA, 0)) + defer cancel() + unregister := rt.guests.add(grant.GID, cancel) + defer unregister() + + if rt.Logf != nil { + rt.Logf("event=guest_attach gid=%s mode=%s scope=%q", grant.GID, grant.Mode, grant.Scope) + } + if grant.Mode == "rw" { + return rt.serveGuestRW(gctx, mc, sess, grant) + } + return rt.serveGuestRO(gctx, mc, sess, grant) +} + +// endedByGrant turns a deadline/cancel into the honest guest-facing reason. +func endedByGrant(ctx context.Context) string { + if ctx.Err() == context.DeadlineExceeded { + return "\r\n[this share has ended — ask for a new invite]\r\n" + } + return "\r\n[this share was ended by the owner]\r\n" +} + +// serveGuestRO mirrors the scope's active pane read-only. No PTY, no tmux +// client, no control channel: the guest sees output and nothing else. +func (rt *Runtime) serveGuestRO(ctx context.Context, mc peer.MsgConn, sess *noise.Session, grant *identity.SignedGrant) error { + var sendMu sync.Mutex + safeSend := func(framed []byte) error { + sendMu.Lock() + defer sendMu.Unlock() + return send(mc, sess, framed) + } + hello, _ := json.Marshal(map[string]string{"name": rt.machineName() + " (shared, read-only)"}) + _ = safeSend(noise.EncodeHello(hello)) + + // The scope names the session; tmux resolves it to that session's active + // pane. (An exact-match "=name" works for session targets but not pane + // targets on tmux 3.x, so the plain name is what pane commands take.) + target := grant.Scope + // Initial paint. A missing scope session is an honest dead end, not a crash. + paint, err := exec.Command("tmux", "capture-pane", "-e", "-p", "-t", target).Output() + if err != nil { + _ = safeSend(noise.EncodeData([]byte("\r\n[this share's session isn't running right now]\r\n"))) + <-ctx.Done() + return nil + } + _ = safeSend(noise.EncodeData(paint)) + + // Live stream: tmux pipes the pane's output into a fifo we read. -O sends + // only pane output (never our input) down the pipe. + fifoDir, err := os.MkdirTemp("", "mir-guest-") + if err != nil { + return err + } + defer os.RemoveAll(fifoDir) + fifo := filepath.Join(fifoDir, "pane") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + return err + } + if err := exec.Command("tmux", "pipe-pane", "-O", "-t", target, "cat > "+fifo).Run(); err != nil { + return err + } + defer exec.Command("tmux", "pipe-pane", "-t", target).Run() // stop piping + + streamErr := make(chan error, 1) + go func() { + f, err := os.OpenFile(fifo, os.O_RDONLY, 0) + if err != nil { + streamErr <- err + return + } + defer f.Close() + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + if e := safeSend(noise.EncodeData(buf[:n])); e != nil { + streamErr <- e + return + } + } + if err != nil { + streamErr <- err + return + } + } + }() + + // Inbound frames from a read-only guest are dropped. Reading them keeps the + // transport drained and lets us notice a disconnect; nothing is forwarded to + // any pane, and FrameControl never reaches tmux. + dropped := 0 + recvErr := make(chan error, 1) + go func() { + for { + ct, err := mc.Recv(ctx) + if err != nil { + recvErr <- err + return + } + pt, err := sess.Decrypt(ct) + if err != nil { + recvErr <- err + return + } + if typ, _, derr := noise.DecodeFrame(pt); derr == nil && typ == noise.FrameData { + dropped++ + } + } + }() + + var reason string + select { + case <-ctx.Done(): + reason = endedByGrant(ctx) + case <-streamErr: + case <-recvErr: + } + if reason != "" { + _ = safeSend(noise.EncodeData([]byte(reason))) + } + if rt.Logf != nil && dropped > 0 { + rt.Logf("event=guest_input_dropped gid=%s frames=%d", grant.GID, dropped) + } + return nil +} + +// serveGuestRW gives the guest a real tmux client on a grouped guest- +// session. It reuses the owner session bridge but with tmuxPid=0 and no control +// handler, so a guest gets NO agent-level control channel (no FrameControl to +// tmux, no window snapshot) — only the raw terminal, where tmux's own Ctrl-B +// still works inside their own client. A non-tmux launch cannot be grouped, so +// a guest cannot be served rw there; refuse honestly. +func (rt *Runtime) serveGuestRW(ctx context.Context, mc peer.MsgConn, sess *noise.Session, grant *identity.SignedGrant) error { + if !isDefaultTmuxLaunch(rt.launch) { + _ = send(mc, sess, noise.EncodeHello(mustJSON(map[string]string{"name": rt.machineName()}))) + _ = send(mc, sess, noise.EncodeData([]byte("\r\n[write sharing needs tmux on this machine — ask the owner]\r\n"))) + <-ctx.Done() + return nil + } + if err := ensureGroupedBase(grant.Scope); err != nil { + return err + } + name := guestSessionName(grant.GID) + pty, err := StartPTY(ctx, groupedLaunch(grant.Scope, name)) + if err != nil { + return err + } + defer pty.Close() + defer killGroupedSession(name) + + err = RunAgentSession(ctx, mc, sess, pty, rt.machineName()+" (shared)", nil, 0, nil) + if ctx.Err() != nil { + _ = send(mc, sess, noise.EncodeData([]byte(endedByGrant(ctx)))) + } + return err +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} + +// revokeGrantHandler builds the per-session handler for revoke-grant from one +// authenticated owner: tombstone the gid (future attaches refuse) and drop every +// live session serving it. Only the session owner's own gids are actionable. +func (rt *Runtime) revokeGrantHandler(owner string) ControlHandler { + return func(payload []byte) (bool, map[string]string) { + var c struct { + A string `json:"a"` + GID string `json:"gid"` + } + if json.Unmarshal(payload, &c) != nil || c.A != "revoke-grant" { + return false, nil + } + if !guestGIDShape(c.GID) { + return true, nil + } + // Only tombstone a gid this owner actually granted; loading it back + // confirms owner + machine before the gid becomes a persistent tombstone. + sg := grantByID(rt.cfg.Dir, c.GID) + if sg == nil || sg.Owner != owner || sg.Machine != rt.cfg.MachineID { + // Nothing of ours by that gid — still drop any live session and ack, + // so a revoke stays idempotent even after the file is gone. + rt.guests.drop(c.GID) + return true, map[string]string{"name": rt.machineName(), "ack": "revoke-grant:" + c.GID} + } + if err := TombstoneGrant(rt.cfg.Dir, c.GID); err != nil { + if rt.Logf != nil { + rt.Logf("event=grant_revoke_failed gid=%s err=%v", c.GID, err) + } + return true, nil + } + rt.guests.drop(c.GID) + if rt.Logf != nil { + rt.Logf("event=grant_revoked gid=%s", c.GID) + } + return true, map[string]string{"name": rt.machineName(), "ack": "revoke-grant:" + c.GID} + } +} + +// grantByID loads one stored grant by gid, or nil. +func grantByID(dir, gid string) *identity.SignedGrant { + if !guestGIDShape(gid) { + return nil + } + raw, err := os.ReadFile(grantPath(dir, gid)) + if err != nil { + return nil + } + sg, err := identity.ParseSignedGrant(raw) + if err != nil { + return nil + } + return sg +} + +func guestGIDShape(gid string) bool { return gidShapeRe.MatchString(gid) } diff --git a/go/internal/agent/guest_offer_test.go b/go/internal/agent/guest_offer_test.go new file mode 100644 index 0000000..c1baad2 --- /dev/null +++ b/go/internal/agent/guest_offer_test.go @@ -0,0 +1,215 @@ +// go/internal/agent/guest_offer_test.go — the guest branch of authorizeOffer: +// who a stored grant does and does not let through. No tmux; pure authorization. +package agent + +import ( + "bytes" + "encoding/base64" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" + "github.com/srcful/terminal-relay/go/internal/signal" +) + +func offerSigner(t *testing.T, fill byte) *identity.Signer { + t.Helper() + s, err := identity.DeriveSigner(bytes.Repeat([]byte{fill}, 32)) + if err != nil { + t.Fatal(err) + } + return s +} + +// signedOffer builds the binding + auth a principal presents for an attach. The +// x25519 hex is arbitrary here — the unit path never runs Noise — but must be +// 64 hex so the binding validates. +func signedOffer(t *testing.T, s *identity.Signer, machineID, session, sdp string) signal.SignalMsg { + t.Helper() + const x = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + sb, err := s.SignBinding(s.Address[:8], x, time.Now().Unix()) + if err != nil { + t.Fatal(err) + } + rec, err := sb.JSON() + if err != nil { + t.Fatal(err) + } + auth := s.SignAuth(identity.AttachChallenge(session, machineID, sdp)) + return signal.SignalMsg{ + Type: signal.TypeOffer, Session: session, SDP: sdp, + Binding: rec, Auth: base64.StdEncoding.EncodeToString(auth), + } +} + +func guestRuntime(t *testing.T, owner string) *Runtime { + t.Helper() + dir := t.TempDir() + cfg, err := LoadOrInit(dir, "box", "https://relay.example") + if err != nil { + t.Fatal(err) + } + cfg.PairedOwners = []string{owner} + return NewRuntime(cfg, defaultLaunch, nil) +} + +func TestAuthorizeOfferGuestAccepted(t *testing.T) { + owner, guest := offerSigner(t, 0x11), offerSigner(t, 0x22) + rt := guestRuntime(t, owner.Address) + sg, err := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, time.Now()) + if err != nil { + t.Fatal(err) + } + if err := AddGrant(rt.cfg.Dir, sg); err != nil { + t.Fatal(err) + } + m := signedOffer(t, guest, rt.cfg.MachineID, "sess-1", "sdp") + auth, err := rt.authorizeOffer(owner.Address, m) + if err != nil { + t.Fatalf("valid guest refused: %v", err) + } + if auth.grant == nil || auth.grant.GID != sg.GID { + t.Fatalf("guest not recognized: %+v", auth) + } +} + +func TestAuthorizeOfferOwnerStillWorks(t *testing.T) { + owner := offerSigner(t, 0x11) + rt := guestRuntime(t, owner.Address) + m := signedOffer(t, owner, rt.cfg.MachineID, "sess-owner", "sdp") + auth, err := rt.authorizeOffer(owner.Address, m) + if err != nil { + t.Fatalf("owner attach refused: %v", err) + } + if auth.grant != nil { + t.Fatalf("owner mistaken for a guest: %+v", auth.grant) + } +} + +func TestAuthorizeOfferGuestRejections(t *testing.T) { + owner, guest, other := offerSigner(t, 0x11), offerSigner(t, 0x22), offerSigner(t, 0x33) + now := time.Now() + + install := func(rt *Runtime, sg *identity.SignedGrant) { + if err := AddGrant(rt.cfg.Dir, sg); err != nil { + t.Fatal(err) + } + } + + t.Run("no grant at all", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("guest with no grant was let in") + } + }) + + t.Run("expired grant", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := owner.SignGrant(identity.Grant{ + V: 1, Owner: owner.Address, Machine: rt.cfg.MachineID, Guest: guest.Address, + Scope: "main", Mode: "ro", NB: now.Add(-2 * time.Hour).Unix(), NA: now.Add(-time.Hour).Unix(), + GID: "aaaabbbbccccddee", + }) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("expired grant was accepted") + } + }) + + t.Run("not yet valid grant", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := owner.SignGrant(identity.Grant{ + V: 1, Owner: owner.Address, Machine: rt.cfg.MachineID, Guest: guest.Address, + Scope: "main", Mode: "ro", NB: now.Add(time.Hour).Unix(), NA: now.Add(2 * time.Hour).Unix(), + GID: "aaaabbbbccccdd11", + }) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("not-yet-valid grant was accepted") + } + }) + + t.Run("tombstoned grant", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + if err := TombstoneGrant(rt.cfg.Dir, sg.GID); err != nil { + t.Fatal(err) + } + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("tombstoned grant was accepted") + } + }) + + t.Run("grant for a different owner", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + // Signed by `other`, who is not this machine's owner — VerifyGrant passes + // against `other`, but findValidGuestGrant requires Owner == the routed owner. + sg, _ := identity.MintGrant(other, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("grant naming a different owner was accepted") + } + }) + + t.Run("grant for a different machine", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, "some-other-machine", guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("grant for another machine was accepted") + } + }) + + t.Run("grant bound to a different guest key", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, rt.cfg.MachineID, other.Address, "main", "ro", time.Hour, now) + install(rt, sg) + // `guest` presents the offer, but the grant is bound to `other`. + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("guest used a grant bound to another key") + } + }) + + t.Run("tampered auth signature", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp") + m.Auth = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x00}, 64)) + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("bad auth signature accepted") + } + }) + + t.Run("auth over a different sdp", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "s", "sdp-A") + m.SDP = "sdp-B" // signature no longer covers the offered SDP + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("auth not bound to the offered SDP") + } + }) + + t.Run("replay of the same session", func(t *testing.T) { + rt := guestRuntime(t, owner.Address) + sg, _ := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, now) + install(rt, sg) + m := signedOffer(t, guest, rt.cfg.MachineID, "sess-replay", "sdp") + if _, err := rt.authorizeOffer(owner.Address, m); err != nil { + t.Fatalf("first attach refused: %v", err) + } + if _, err := rt.authorizeOffer(owner.Address, m); err == nil { + t.Fatal("replayed session id accepted twice") + } + }) +} diff --git a/go/internal/agent/guest_serve_test.go b/go/internal/agent/guest_serve_test.go new file mode 100644 index 0000000..1634487 --- /dev/null +++ b/go/internal/agent/guest_serve_test.go @@ -0,0 +1,304 @@ +// go/internal/agent/guest_serve_test.go — the guest serve paths against a real +// tmux server: read-only is a pane mirror that shows output and drops input; +// read-write gets a grouped session whose keystrokes land; expiry and revocation +// each tear a live guest down. Skipped under -short or without tmux. +package agent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" + "github.com/srcful/terminal-relay/go/internal/noise" + "github.com/srcful/terminal-relay/go/internal/peer" +) + +// guestTmux starts a private tmux server with session `main` running scopeCmd, +// and returns a runner for direct tmux calls. Same isolation as the hook tests. +func guestTmux(t *testing.T, scopeCmd string) func(args ...string) (string, error) { + t.Helper() + if testing.Short() { + t.Skip("skipping live tmux test under -short") + } + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not installed") + } + dir, err := os.MkdirTemp("", "mirguest") + if err != nil { + t.Fatal(err) + } + t.Setenv("TMUX_TMPDIR", dir) + t.Setenv("TMUX", "") + run := func(args ...string) (string, error) { + out, err := exec.Command("tmux", args...).CombinedOutput() + return strings.TrimSpace(string(out)), err + } + args := []string{"new-session", "-d", "-s", "main"} + if scopeCmd != "" { + args = append(args, scopeCmd) + } + if out, err := run(args...); err != nil { + t.Skipf("cannot start a tmux server here: %v (%s)", err, out) + } + t.Cleanup(func() { _, _ = run("kill-server") }) + return run +} + +// guestClient is the guest end of a Noise session driving rt.serveGuest. +type guestClient struct { + mc peer.MsgConn + sess *noise.Session +} + +// startGuestServe wires a Noise-KK pipe, runs rt.serveGuest(grant) on the agent +// side, and returns the guest client plus a channel with serveGuest's return. +func startGuestServe(t *testing.T, ctx context.Context, rt *Runtime, grant *identity.SignedGrant) (*guestClient, <-chan error) { + t.Helper() + agentPriv, agentPub, _ := noise.GenerateStatic() + guestPriv, guestPub, _ := noise.GenerateStatic() + clientMC, agentMC := peer.Pipe() + + done := make(chan error, 1) + go func() { + s, err := peer.RunResponder(ctx, agentMC, agentPriv, guestPub) + if err != nil { + done <- err + return + } + done <- rt.serveGuest(ctx, agentMC, s, grant) + }() + cs, err := peer.RunInitiator(ctx, clientMC, guestPriv, agentPub) + if err != nil { + t.Fatalf("guest KK: %v", err) + } + return &guestClient{mc: clientMC, sess: cs}, done +} + +func (g *guestClient) send(t *testing.T, ctx context.Context, framed []byte) { + t.Helper() + ct, err := g.sess.Encrypt(framed) + if err != nil { + t.Fatal(err) + } + if err := g.mc.Send(ct); err != nil { + t.Fatal(err) + } +} + +// readUntil accumulates decoded DATA frames until want appears or the deadline +// passes, returning everything seen (so a test can also assert an absence). +func (g *guestClient) readUntil(t *testing.T, want string, within time.Duration) (string, bool) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), within) + defer cancel() + var buf strings.Builder + for { + ct, err := g.mc.Recv(ctx) + if err != nil { + return buf.String(), false + } + pt, err := g.sess.Decrypt(ct) + if err != nil { + return buf.String(), false + } + if typ, payload, derr := noise.DecodeFrame(pt); derr == nil && typ == noise.FrameData { + buf.Write(payload) + if strings.Contains(buf.String(), want) { + return buf.String(), true + } + } + } +} + +func guestTestRuntime(t *testing.T, owner *identity.Signer) *Runtime { + t.Helper() + dir := t.TempDir() + cfg, err := LoadOrInit(dir, "box", "https://relay.example") + if err != nil { + t.Fatal(err) + } + cfg.PairedOwners = []string{owner.Address} + return NewRuntime(cfg, defaultLaunch, nil) +} + +// TestGuestROMirrorsOutputAndDropsInput is the spec's differential: a read-only +// guest sees the pane's real output but its own keystrokes never reach the pane. +// The scope runs a shell, which echoes whatever reaches its tty — so anything +// the guest could inject would come back, and anything sent with send-keys does. +func TestGuestROMirrorsOutputAndDropsInput(t *testing.T) { + run := guestTmux(t, "") // default shell in the pane + owner, guest := offerSigner(t, 0x11), offerSigner(t, 0x22) + rt := guestTestRuntime(t, owner) + sg, err := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, time.Now()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + gc, done := startGuestServe(t, ctx, rt, sg) + + // The guest tries to inject; a ro guest's input must be dropped, so the + // shell never echoes or runs it. + gc.send(t, ctx, noise.EncodeData([]byte("echo INJECTED\n"))) + time.Sleep(300 * time.Millisecond) + // Real pane input, echoed and run by the shell. + if out, err := run("send-keys", "-t", "main", "echo REALDATA", "Enter"); err != nil { + t.Fatalf("send-keys: %v (%s)", err, out) + } + + seen, ok := gc.readUntil(t, "REALDATA", 6*time.Second) + if !ok { + t.Fatalf("guest never saw the real pane output:\n%q", seen) + } + if strings.Contains(seen, "INJECTED") { + t.Fatalf("read-only guest input reached the pane:\n%q", seen) + } + cancel() + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("serveGuest did not return after cancel") + } +} + +// TestGuestRWKeystrokesLand: a read-write guest gets a grouped session on the +// shared window, so what it types runs in the scope's shell. +func TestGuestRWKeystrokesLand(t *testing.T) { + run := guestTmux(t, "") // default shell in the pane + owner, guest := offerSigner(t, 0x11), offerSigner(t, 0x22) + rt := guestTestRuntime(t, owner) + sg, err := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "rw", time.Hour, time.Now()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + gc, done := startGuestServe(t, ctx, rt, sg) + + // A real client always drains the terminal; a grouped tmux client redraws + // enough to fill the pipe, so keep reading in the background until ctx is + // cancelled or the agent's send blocks (a test artifact, not a product + // concern). + go func() { + for { + if _, err := gc.mc.Recv(ctx); err != nil { + return + } + } + }() + + marker := filepath.Join(t.TempDir(), "rw_landed") + time.Sleep(500 * time.Millisecond) // let the grouped client attach + gc.send(t, ctx, noise.EncodeData([]byte("touch "+marker+"\n"))) + + landed := false + for i := 0; i < 60; i++ { + if _, err := os.Stat(marker); err == nil { + landed = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !landed { + t.Fatal("read-write guest keystrokes never reached the shell") + } + // The grouped guest session is hidden from a snapshot and cleaned up on end. + if out, _ := run("list-sessions", "-F", "#{session_name}"); !strings.Contains(out, "guest-"+sg.GID[:8]) { + t.Fatalf("expected a guest-%s session while serving, saw:\n%s", sg.GID[:8], out) + } + cancel() + select { + case <-done: + case <-time.After(4 * time.Second): + t.Fatal("serveGuest did not return after cancel") + } + // killGroupedSession ran on detach. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if out, _ := run("list-sessions", "-F", "#{session_name}"); !strings.Contains(out, "guest-"+sg.GID[:8]) { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("guest grouped session outlived the attach") +} + +// TestGuestExpiryDropsLiveSession: a live guest is torn down within ~1s of na. +func TestGuestExpiryDropsLiveSession(t *testing.T) { + guestTmux(t, "") + owner, guest := offerSigner(t, 0x11), offerSigner(t, 0x22) + rt := guestTestRuntime(t, owner) + now := time.Now() + sg, err := owner.SignGrant(identity.Grant{ + V: 1, Owner: owner.Address, Machine: rt.cfg.MachineID, Guest: guest.Address, + Scope: "main", Mode: "ro", NB: now.Add(-identity.GrantSkew).Unix(), NA: now.Add(time.Second).Unix(), + GID: "eeee1111eeee2222", + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + gc, done := startGuestServe(t, ctx, rt, sg) + + seen, _ := gc.readUntil(t, "this share has ended", 4*time.Second) + if !strings.Contains(seen, "this share has ended") { + t.Fatalf("guest was not told the share ended:\n%q", seen) + } + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("serveGuest did not return at expiry") + } +} + +// TestGuestRevokeDropsAndBars: revoke-grant tombstones the gid, drops the live +// session at once, and a later attach with that grant refuses. +func TestGuestRevokeDropsAndBars(t *testing.T) { + guestTmux(t, "") + owner, guest := offerSigner(t, 0x11), offerSigner(t, 0x22) + rt := guestTestRuntime(t, owner) + sg, err := identity.MintGrant(owner, rt.cfg.MachineID, guest.Address, "main", "ro", time.Hour, time.Now()) + if err != nil { + t.Fatal(err) + } + if err := AddGrant(rt.cfg.Dir, sg); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + gc, done := startGuestServe(t, ctx, rt, sg) + + // Wait until the session has registered, then revoke it from the owner side. + if _, ok := gc.readUntil(t, "read-only", 3*time.Second); !ok { + // the HELLO name carries "(shared, read-only)"; if missed, proceed anyway + } + revoke := mustJSON(map[string]string{"a": "revoke-grant", "gid": sg.GID}) + handled, ack := rt.revokeGrantHandler(owner.Address)(revoke) + if !handled || ack["ack"] != "revoke-grant:"+sg.GID { + t.Fatalf("revoke handler = (%v, %v)", handled, ack) + } + + seen, _ := gc.readUntil(t, "ended by the owner", 4*time.Second) + if !strings.Contains(seen, "ended by the owner") { + t.Fatalf("revoked guest not told:\n%q", seen) + } + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("serveGuest did not return after revoke") + } + // A fresh attach with the same (now tombstoned) grant must refuse. + if g := findValidGuestGrant(rt.cfg.Dir, owner.Address, rt.cfg.MachineID, guest.Address, time.Now()); g != nil { + t.Fatal("revoked grant still authorizes an attach") + } +} diff --git a/go/internal/agent/pinset_test.go b/go/internal/agent/pinset_test.go index 6c14e0a..6c1da51 100644 --- a/go/internal/agent/pinset_test.go +++ b/go/internal/agent/pinset_test.go @@ -85,7 +85,7 @@ func TestHandshakeSlotFreedWhileSessionActive(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- rt.serveAuthenticated(ctx, agentMC, "owner-test", ownerPub, releaseHS) + done <- rt.serveAuthenticated(ctx, agentMC, "owner-test", &offerAuth{pub: ownerPub}, releaseHS) }() if _, err := peer.RunInitiator(ctx, clientMC, ownerPriv, hostPub); err != nil { t.Fatalf("initiator KK: %v", err) diff --git a/go/internal/agent/runtime.go b/go/internal/agent/runtime.go index 2d5da76..b0b7480 100644 --- a/go/internal/agent/runtime.go +++ b/go/internal/agent/runtime.go @@ -84,6 +84,8 @@ type Runtime struct { Logf func(string, ...any) // optional reconnect/status log (set by the CLI) rename renameState // live display name + signaling writers for mid-run rename (see rename.go) + + guests guestRegistry // live guest sessions by gid, so revoke-grant drops them at once (see guest.go) } // admit reserves a slot for a new attach handshake, returning false immediately @@ -164,8 +166,9 @@ func (rt *Runtime) Up(ctx context.Context) error { return errNoOwner } if isDefaultTmuxLaunch(rt.launch) { - sweepOrphanGroupedSessions() // mir-* leftovers from a crashed agent + sweepOrphanGroupedSessions() // mir-*/guest-* leftovers from a crashed agent } + sweepExpiredGrants(rt.cfg.Dir, time.Now()) // drop grant files whose window has closed rt.reconcileOwners(ctx, append([]string(nil), rt.cfg.PairedOwners...)) t := time.NewTicker(rt.reloadInterval) defer t.Stop() @@ -396,7 +399,7 @@ func (rt *Runtime) iceFor(ctx context.Context) []peer.ICEServer { } func (rt *Runtime) handleOffer(ctx context.Context, w *signalWriter, m signal.SignalMsg, owner string) { - ownerPub, err := rt.authorizeOffer(owner, m) + auth, err := rt.authorizeOffer(owner, m) if err != nil { return } @@ -451,28 +454,60 @@ func (rt *Runtime) handleOffer(ctx context.Context, w *signalWriter, m signal.Si return } - _ = rt.serveAuthenticated(attachCtx, dc, owner, ownerPub, releaseHS) + _ = rt.serveAuthenticated(attachCtx, dc, owner, auth, releaseHS) +} + +// offerAuth is the result of authorizing an offer: the transport key to pin for +// Noise-KK, and — for a guest — the grant that authorized them (nil = owner). +type offerAuth struct { + pub []byte + grant *identity.SignedGrant } // authorizeOffer verifies pin, binding, SDP-bound owner signature, and session // replay before any ICE/Pion allocation. Exported-to-package so tests can drive // the live pin set without a full WebRTC handshake. -func (rt *Runtime) authorizeOffer(owner string, m signal.SignalMsg) ([]byte, error) { - if !rt.ownerPinned(owner) || m.Session == "" || m.Auth == "" { +func (rt *Runtime) authorizeOffer(owner string, m signal.SignalMsg) (*offerAuth, error) { + if m.Session == "" || m.Auth == "" || m.Binding == "" { return nil, fmt.Errorf("attach: not authorized") } - ownerPub, err := ownerPubFromBinding(m.Binding, owner) + // The offer is authenticated by whoever signed its binding and the SDP-bound + // attach challenge — the "principal". For an owner that is the routed owner + // itself; for a guest it is the guest key, which is why the binding wallet, + // not the routed owner_id, decides who this is. + sb, err := identity.ParseSignedBinding([]byte(m.Binding)) + if err != nil || identity.VerifyBinding(sb) != nil { + return nil, fmt.Errorf("attach: bad binding") + } + principal := sb.Wallet + pub, err := hex.DecodeString(sb.X25519) if err != nil { - return nil, err + return nil, fmt.Errorf("attach: bad transport key") } - auth, err := base64.StdEncoding.DecodeString(m.Auth) - if err != nil || identity.VerifyAuth(owner, identity.AttachChallenge(m.Session, rt.cfg.MachineID, m.SDP), auth) != nil { + sig, err := base64.StdEncoding.DecodeString(m.Auth) + if err != nil || identity.VerifyAuth(principal, identity.AttachChallenge(m.Session, rt.cfg.MachineID, m.SDP), sig) != nil { return nil, fmt.Errorf("attach: bad auth") } - if !rt.acceptAttachSession(owner, m.Session) { + + // Owner path (byte-identical to before): the principal is the routed, pinned + // owner. + if principal == owner && rt.ownerPinned(owner) { + if !rt.acceptAttachSession(principal, m.Session) { + return nil, fmt.Errorf("attach: replay") + } + return &offerAuth{pub: pub}, nil + } + + // Guest path: a stored, valid grant for THIS owner and machine, bound to the + // principal key. ValidAt + tombstone are re-checked here on every attach. + grant := findValidGuestGrant(rt.cfg.Dir, owner, rt.cfg.MachineID, principal, time.Now()) + if grant == nil { + return nil, fmt.Errorf("attach: not authorized") + } + if !rt.acceptAttachSession(principal, m.Session) { return nil, fmt.Errorf("attach: replay") } - return ownerPub, nil + return &offerAuth{pub: pub, grant: grant}, nil } // serveAuthenticated runs the Noise-KK responder against the pinned owner X25519 key @@ -481,14 +516,19 @@ func (rt *Runtime) authorizeOffer(owner string, m signal.SignalMsg) ([]byte, err // The active-session bracket lives HERE — after auth — not at the transport accept: // pre-auth handshakes (already bounded by admit()) must not inflate the active count // and starve opt-in auto-update, which defers binary swaps until the agent is idle. -func (rt *Runtime) serveAuthenticated(ctx context.Context, mc peer.MsgConn, owner string, ownerPub []byte, handshakeDone func()) error { - sess, err := peer.RunResponder(ctx, mc, rt.cfg.HostPriv(), ownerPub) +func (rt *Runtime) serveAuthenticated(ctx context.Context, mc peer.MsgConn, owner string, auth *offerAuth, handshakeDone func()) error { + sess, err := peer.RunResponder(ctx, mc, rt.cfg.HostPriv(), auth.pub) if err != nil { return err } if handshakeDone != nil { handshakeDone() } + // A guest is served in its own confined path (ro mirror or grouped rw), never + // the owner shell below. + if auth.grant != nil { + return rt.serveGuest(ctx, mc, sess, auth.grant) + } rt.sessionStarted() defer rt.sessionEnded() // Grouped per-attach sessions (spec D4): under the default tmux launch each @@ -528,7 +568,7 @@ func (rt *Runtime) serveAuthenticated(ctx context.Context, mc peer.MsgConn, owne } windows = func() []byte { return tmuxSessionsJSON(pid, collapse) } } - return RunAgentSession(ctx, mc, sess, pty, rt.machineName(), windows, pid, chainControl(rt.renameHandler(owner), rt.grantHandler(owner))) + return RunAgentSession(ctx, mc, sess, pty, rt.machineName(), windows, pid, chainControl(rt.renameHandler(owner), rt.grantHandler(owner), rt.revokeGrantHandler(owner))) } // agentSignalURL builds ws(s)://host/agent/signal?owner_id=..&machine_id=.. diff --git a/go/internal/cli/share.go b/go/internal/cli/share.go index d6d22a8..d7e563d 100644 --- a/go/internal/cli/share.go +++ b/go/internal/cli/share.go @@ -237,6 +237,7 @@ func (a *app) cmdJoin(args []string) error { if err := client.AddMachine(*dir, client.Machine{ Name: info.Name, MachineID: info.MachineID, HostPubHex: info.HostPubHex, SignalURL: signalURL, + Owner: sg.Owner, // a guest entry: attach routes under the machine owner, authenticates as us }); err != nil { return err } diff --git a/go/internal/client/grantctl.go b/go/internal/client/grantctl.go index 5ee4e10..e86212b 100644 --- a/go/internal/client/grantctl.go +++ b/go/internal/client/grantctl.go @@ -60,6 +60,45 @@ func GrantOverSession(ctx context.Context, mc peer.MsgConn, sess *noise.Session, } } +// RevokeGrantOverSession delivers a grant revocation to the agent over an +// established owner session and waits for the acknowledging HELLO. The agent +// tombstones the gid and drops any live guest serving it. (The `mir share +// revoke` command that calls this is wired in G1d.) +func RevokeGrantOverSession(ctx context.Context, mc peer.MsgConn, sess *noise.Session, gid string, wait time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + + payload, err := json.Marshal(map[string]string{"a": "revoke-grant", "gid": gid}) + if err != nil { + return err + } + if err := newSender(mc, sess).send(noise.EncodeControl(payload)); err != nil { + return fmt.Errorf("revoke: send failed: %w", err) + } + want := "revoke-grant:" + gid + for { + ct, err := mc.Recv(ctx) + if err != nil { + if ctx.Err() != nil { + return ErrGrantUnconfirmed + } + return err + } + pt, err := sess.Decrypt(ct) + if err != nil { + return err + } + typ, payload, err := noise.DecodeFrame(pt) + if err != nil || typ != noise.FrameHello { + continue + } + var meta map[string]string + if json.Unmarshal(payload, &meta) == nil && meta["ack"] == want { + return nil + } + } +} + var guestGIDRe = regexp.MustCompile(`^[0-9a-f]{16}$`) // SaveGuestGrant stores the grant record a guest received with an invite, so diff --git a/go/internal/client/relay_locator.go b/go/internal/client/relay_locator.go index b0c681f..912cd6b 100644 --- a/go/internal/client/relay_locator.go +++ b/go/internal/client/relay_locator.go @@ -23,7 +23,14 @@ import ( type relayLocator struct{} func (relayLocator) Dial(ctx context.Context, m Machine, id *Identity, ice []peer.ICEServer) (peer.MsgConn, func(), error) { - ownerID := id.OwnerID + // The URL owner_id only routes the offer to the agent's registration; the + // binding and auth below authenticate this identity. A guest entry sets + // m.Owner (the machine owner) so routing works while we attach as the guest; + // your own machines leave it empty and route under your own id, unchanged. + ownerID := m.Owner + if ownerID == "" { + ownerID = id.OwnerID + } wsURL := "ws" + strings.TrimPrefix(m.SignalURL, "http") + "/attach?owner_id=" + url.QueryEscape(ownerID) + "&machine_id=" + url.QueryEscape(m.MachineID) diff --git a/go/internal/client/store.go b/go/internal/client/store.go index c7064c5..265402a 100644 --- a/go/internal/client/store.go +++ b/go/internal/client/store.go @@ -41,6 +41,12 @@ type Machine struct { // Registry merge is last-writer-wins on it, so a rename made on one device // beats every stale name without ever letting a stale one bounce back. NameTS int64 `json:"name_ts,omitempty"` + // Owner routes an attach that this identity does not own itself: a guest + // entry carries the machine owner's id so the relay routes the offer to the + // agent's registration, while the offer is still authenticated by this + // (guest) identity. Empty for your own machines — the attach URL then uses + // your own owner id, byte-identical to before guest sharing. + Owner string `json:"owner,omitempty"` } type IdentityStorageInfo struct {