pairing

package
v0.18.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package pairing owns the device tokens that gate a serve's origin passthrough once it is exposed beyond loopback (GDK-433), and — since GDK-797/GDK-883 — the mirror REST a paired phone companion reads.

Model: each paired device — a remote gadak binding this serve as its workspace origin, or a phone app reading this serve's mirror — gets an opaque random token, presented as `Authorization: Bearer <token>` (the Jira DC PAT shape; the Cloud email:token Basic shape is deliberately not reused). The server stores only the SHA-256 hash plus metadata, in `pairing.json` inside the profile directory: next to config.json and its 0600 atomic-write convention, and never inside the mirror (gadak.db is a disposable cache of the origin, not a place for originals) or any export. The plaintext exists once, in `gadak pairing mint` output, inside a one-line offer.

Gate semantics: while at least one active — unrevoked, unexpired — token exists, every request under origin.RESTPrefix must present a valid Bearer token whose scope admits the passthrough (ScopeOrigin or the home machine's ScopeLocalRouting), and a DNS-named Host inside the server's mirror REST must present a ScopeServe one. With no active token both surfaces behave exactly as before (implicit loopback trust, decision 0003; DNS Hosts stay behind the rebinding guard). There is no loopback bypass on the passthrough: a tailnet proxy reaches the serve as loopback, so the token is the only identity the server can distinguish.

Index

Constants

View Source
const HomeLabel = "_home"

HomeLabel is the reserved label of the home routing token. It is not a device: revoking it locks local writes while a serve is running.

View Source
const OfferV1 = 1

OfferV1 is the only offer format this build understands. A version bump means capability change, not app version: mixed-version clients syncing against one serve is the normal state (GDK-433 prior-art survey), so an unknown version is an explicit refusal, never a silent best-effort parse.

View Source
const RemoteRel = "remote-origin.json"

RemoteRel is the profile-relative file holding this workspace's stored pairing credential: the workspace's origin is a remote gadak serve, and this is how to reach and authenticate it.

It sits next to config.json (the credential convention — 0600, atomic write, profile-scoped) rather than inside it, for the same reason the server's token store is a separate file: config.json is what settings surfaces read and rewrite; a credential file has one writer, the pairing path. The token is plaintext here because the client must present it.

View Source
const ScopeLocalRouting = "local-routing"

ScopeLocalRouting is the home machine's own routing token. pairing list uses this so the `_home` row is not mistaken for a paired device.

View Source
const ScopeOrigin = "origin"

ScopeOrigin is full origin passthrough — the device-token scope. It exists in the stored metadata so a narrower scope (read-only, wiki-only, local-routing) can be told apart from tokens minted before scopes mattered.

View Source
const ScopeServe = "serve"

ScopeServe is the paired-client scope (GDK-797, widened by GDK-883): the whole mirror REST on this serve — everything the loopback web UI can call — and nothing outside it. It deliberately cannot ride the origin passthrough: a leaked serve token must not reach raw issuetap/Jira REST, and a minted origin token must not dump the mirror. The scopes are one-way doors by construction, not by caller discipline.

View Source
const ScopeTerminal = "terminal"

ScopeTerminal is the shell scope (GDK-863): the PTY sessions `gadak serve` runs, and nothing else. It is the third one-way door and the sharpest one — a leaked serve token leaks the data in the mirror, a leaked terminal token leaks the machine. So no other scope opens it and it opens no other surface: not the mirror REST, not the origin passthrough. It is never a default, and `pairing mint` will not produce one without --scope terminal being typed.

View Source
const StoreRel = "pairing.json"

StoreRel is the profile-relative path of the token store. A separate file, not a config.json key: config.json is settings the UI edits and exports surface; this is a credential-adjacent secret list.

Variables

This section is empty.

Functions

func AdmitsOrigin added in v0.17.3

func AdmitsOrigin(scope string) bool

AdmitsOrigin reports whether a token scope may ride the origin passthrough: origin (the device scope), local-routing (the home machine's own writes), and the empty scope of tokens minted before scopes mattered — those were origin tokens and must keep working. serve and terminal are the deliberate exclusions: the phone scope opens the mirror REST and the shell scope opens a PTY, neither opens raw REST.

func AdmitsTerminal added in v0.18.0

func AdmitsTerminal(scope string) bool

AdmitsTerminal reports whether a token scope may open a shell. Only ScopeTerminal does — not the empty scope of pre-scope tokens (they were minted when no terminal existed and must not silently acquire one), not local-routing, and above all not serve: a leaked phone token leaks the mirror's data, and it must never become a leak of the machine.

func AuthorizeMeta added in v0.17.3

func AuthorizeMeta(dir, bearer string, now time.Time) (Verdict, Meta, error)

AuthorizeMeta is Authorize returning the matched token on accept. The scope on that Meta is what lets a gate refuse a valid token minted for another surface: a serve token must not ride the origin passthrough, an origin token must not read the mirror REST. Meta is zero on every non-accept verdict.

func EncodeOffer

func EncodeOffer(o Offer) (string, error)

EncodeOffer renders o as the single-line base64url form.

func Explain

func Explain(dir, bearer string, now time.Time) (Verdict, Reason)

Explain classifies a rejected bearer. Authorize remains the gate decision (and last_used_at); this is the reason the 401 body carries.

hash in store, RevokedAt set → revoked
hash in store, ExpiresAt elapsed → expired
empty bearer or hash not in the store → unknown

func FormatExpiry

func FormatExpiry(t time.Time) string

FormatExpiry renders an offer's ExpiresAt for mint output; "" when unset.

func RemotePath

func RemotePath(dir string) string

RemotePath is the absolute credential path inside a profile directory.

func SaveRemote

func SaveRemote(dir string, r Remote) error

SaveRemote writes the pairing credential atomically at 0600, creating the profile directory if needed. Called only after a successful verify-before-save round trip — a credential that was never proven good must not reach disk.

func StorePath

func StorePath(dir string) string

StorePath is the absolute token-store path inside a profile directory.

func TokenActive added in v0.18.0

func TokenActive(dir, hash string, now time.Time) bool

TokenActive reports whether the stored token with this hash id is active right now. It is the revoke watchdog's question (GDK-862): a live PTY session records the id of the token that opened it, and asking this on an interval is how `gadak pairing revoke` — which runs in a different process and can only edit the file — reaches a shell that is already open. Fails closed like Authorize: an unreadable store answers false, so a store that has gone missing cuts sessions rather than keeping them.

Types

type Meta

type Meta struct {
	Label      string     `json:"label"`
	Scope      string     `json:"scope"`
	Hash       string     `json:"hash"`
	CreatedAt  time.Time  `json:"created_at"`
	ExpiresAt  time.Time  `json:"expires_at"`
	RevokedAt  *time.Time `json:"revoked_at,omitempty"`
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}

Meta is one stored token. Hash is the hex SHA-256 of the plaintext; the plaintext is never persisted anywhere.

func List

func List(dir string) ([]Meta, error)

List returns every stored token, earliest created first, including revoked and expired ones — revocation is audit history, not noise.

func Mint

func Mint(dir, label string, ttl time.Duration, now time.Time) (string, Meta, error)

Mint creates an origin-scope device token — every pre-serve caller's meaning (a paired laptop riding the passthrough). Scope choice lives in MintScoped; this stays so those call sites keep saying exactly that.

func MintScoped added in v0.17.3

func MintScoped(dir, label, scope string, ttl time.Duration, now time.Time) (string, Meta, error)

MintScoped is Mint with the scope named. scope is ScopeOrigin or ScopeServe; anything else is refused — minting a token whose scope no gate reads would be a silent lie. The reserved `_home` label keeps its ScopeLocalRouting regardless, so a stray `--label _home --scope serve` cannot manufacture a mirror-reading routing token.

func Revoke

func Revoke(dir, selector string, now time.Time) (Meta, error)

Revoke revokes the token selected by exact label or hash prefix and returns it. Refuses ambiguities instead of guessing, and refuses an already-revoked token rather than no-op'ing silently. Revoked entries stay in the store as audit history but never stand as candidates: a re-minted label revokes by label again, pointed at the live mint.

func Rotate

func Rotate(dir, label string, ttl time.Duration, now time.Time) (string, Meta, error)

Rotate replaces every live token with label by a newly minted one in a single store write, so pairing list never shows two live rows with the same name. Used for the home routing token: mint + revoke of the previous `_home` must not be two verbs a crash can split.

func (Meta) Active

func (m Meta) Active(now time.Time) bool

Active reports whether this token admits requests at now. The home origin's clock is authoritative (GDK-369): expiry is judged where the store lives, never by a client-supplied timestamp.

type Offer

type Offer struct {
	V         int    `json:"v"`
	Endpoint  string `json:"endpoint"`
	Token     string `json:"token"`
	ExpiresAt string `json:"expires_at"`
	Label     string `json:"label"`
}

Offer is the one-line pairing secret `gadak pairing mint` prints and a remote gadak consumes via `gadak init --pairing-code`. Base64url of a JSON document: one pasteable line, no shell-hostile characters.

The offer carries the token itself, so it is treated like a credential: never echoed into a log line, an error message, or argv more than the one flag the user already typed. ExpiresAt is advisory for the human (RFC3339, home origin's clock); the gate judges expiry from the store.

func DecodeOffer

func DecodeOffer(s string) (Offer, error)

DecodeOffer parses an offer line. Errors describe the problem without quoting the payload — the token inside must not leak through an error path. Unknown version, missing endpoint, and missing token are distinct errors so `init --pairing-code` can tell the user what to redo.

type Reason

type Reason string

Reason is why a rejected bearer was refused. Only tokens that were minted (hash in the store) get a detailed reason; anything else is unknown so a probe cannot tell unused strings from never-issued ones.

const (
	ReasonExpired Reason = "expired"
	ReasonRevoked Reason = "revoked"
	ReasonUnknown Reason = "unknown"
)

type Remote

type Remote struct {
	Endpoint string `json:"endpoint"`
	Token    string `json:"token"`
	Label    string `json:"label,omitempty"`
	PairedAt string `json:"pairedAt,omitempty"`
}

Remote is the stored client side of a pairing: where the home serve is, the device token, and the label the home shows in `pairing list`.

func LoadRemote

func LoadRemote(dir string) (*Remote, error)

LoadRemote reads the stored pairing credential. Missing file is (nil, nil): most workspaces are not paired, and that is not an error.

type Verdict

type Verdict int

Verdict is Authorize's answer.

const (
	// VerdictOff: no active token exists, so the gate does not apply and
	// the passthrough keeps its pre-pairing behavior.
	VerdictOff Verdict = iota
	// VerdictAccept: a valid Bearer token was presented.
	VerdictAccept
	// VerdictReject: active tokens exist but the request carried no valid
	// one. Missing and wrong are the same answer — do not reveal which.
	VerdictReject
)

func Authorize

func Authorize(dir, bearer string, now time.Time) (Verdict, error)

Authorize decides whether bearer may pass the gate at now. It fails closed: a store that cannot be read rejects, because tokens may exist; only a proven-absent store is VerdictOff. On accept it records last_used_at (throttled to one disk write per token per interval). Callers that must also know *which* token answered — the scope-aware gates (GDK-797) — use AuthorizeMeta.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL