pairing

package
v0.22.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 16 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 (
	OfferV1 = 1
	OfferV2 = 2
)

OfferV1 and OfferV2 are the offer formats 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. v2 (GDK-1498) carries one token per scope in a list — one scan pairs a phone's mirror and its shell — where v1 carries a single token whose scope lives server-side. A v1 offer for one scope stays byte-identical v1 (GDK-799: the v1 line is a promise to existing pairers).

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 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. It refuses the shapes a version cannot carry — a v1 with a token list, a v2 with a top-level token, no tokens, a duplicate scope, or an entry missing its scope or token — so a malformed offer cannot leave a mint path. Errors name the defect without quoting the payload.

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.

func VersionSkew added in v0.22.0

func VersionSkew(home, client string) string

VersionSkew classifies the home serve's recorded version against this client's (GDK-1273): "same", "home-older", "home-newer", or "unknown". Comparison is component-wise over the leading dotted numbers, with a `-suffix` ignored (a dev build rides its release line); a version with no parseable number prefix can only be compared for equality, and either side empty is "unknown" — an absent record must not read as "same".

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 (a paired laptop riding the passthrough). Production mints name their scope via MintScoped (pairflow's mint command is the caller); this origin-scope surface carries no production call site — the tests state the token contract through it.

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 MintScopedMulti added in v0.21.0

func MintScopedMulti(dir, label string, scopes []string, ttl time.Duration, now time.Time) ([]string, []Meta, error)

MintScopedMulti mints one token per scope under one label in a single store write — GDK-1498's offer-v2 shape, where a device is one label carrying several scoped credentials. scopes must be non-empty and duplicate-free, and the label-conflict check runs against tokens already in the store, not the siblings being minted — the whole device commits or nothing does, so a crash cannot strand half a phone.

func Revoke

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

Revoke revokes the token(s) selected by exact label or hash prefix and returns them. A device is one label (GDK-1498: one offer carries its every scope), so an exact label revokes every live token with that label; a hash prefix still selects exactly one and refuses ambiguities. 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,omitempty"` // v1's single token; never set in v2
	ExpiresAt string       `json:"expires_at"`
	Label     string       `json:"label"`
	Tokens    []OfferToken `json:"tokens,omitempty"` // v2's scoped list; never set in v1
}

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 tokens, 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 of either version and returns it with the normalized token list: one entry per scope for v2, one unscoped entry for v1. Errors describe the problem without quoting the payload — the tokens inside must not leak through an error path. Unknown version, missing endpoint, and each malformed-token shape are distinct errors so `init --pairing-code` can tell the user what to redo.

func (Offer) ConsumerToken added in v0.21.0

func (o Offer) ConsumerToken() (string, error)

ConsumerToken picks the token a pairing consumer — a second gadak binding this serve as its workspace origin (`init --pairing-code`) — presents: the serve token when the offer carries one, else the origin one. A v1 offer has a single token whose scope the payload cannot name; it is returned as-is and the serve's gate judges it, exactly as every pre-v2 consumer behaved. A terminal token is never returned — it opens a shell, not a workspace — and an offer carrying nothing else is refused naming that scope.

type OfferToken added in v0.21.0

type OfferToken struct {
	Scope string `json:"scope"`
	Token string `json:"token"`
}

OfferToken is one scoped credential inside an offer. v2 entries name their scope; the normalized v1 entry carries Scope "" — v1's scope is not in the payload (it lives in the server's store), so callers that need it decide from context, exactly as they did before v2 existed.

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"`
	// ServerVersion is the home serve's gadak version as its own response
	// header stated it during the verify round trip (GDK-1273) — the
	// pair-time record `gadak status` and `doctor` read for the skew line.
	// Empty on credentials written before the header existed or served by
	// a gadak that predates it.
	ServerVersion string `json:"serverVersion,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