Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions go/internal/agent/grantstore.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
15 changes: 12 additions & 3 deletions go/internal/agent/grouped.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading