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
- func AdmitsOrigin(scope string) bool
- func AdmitsTerminal(scope string) bool
- func AuthorizeMeta(dir, bearer string, now time.Time) (Verdict, Meta, error)
- func EncodeOffer(o Offer) (string, error)
- func Explain(dir, bearer string, now time.Time) (Verdict, Reason)
- func FormatExpiry(t time.Time) string
- func RemotePath(dir string) string
- func SaveRemote(dir string, r Remote) error
- func StorePath(dir string) string
- func TokenActive(dir, hash string, now time.Time) bool
- type Meta
- func List(dir string) ([]Meta, error)
- func Mint(dir, label string, ttl time.Duration, now time.Time) (string, Meta, error)
- func MintScoped(dir, label, scope string, ttl time.Duration, now time.Time) (string, Meta, error)
- func Revoke(dir, selector string, now time.Time) (Meta, error)
- func Rotate(dir, label string, ttl time.Duration, now time.Time) (string, Meta, error)
- type Offer
- type Reason
- type Remote
- type Verdict
Constants ¶
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.
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.
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.
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.
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.
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.
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.
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
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
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
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 ¶
EncodeOffer renders o as the single-line base64url form.
func Explain ¶
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 ¶
FormatExpiry renders an offer's ExpiresAt for mint output; "" when unset.
func RemotePath ¶
RemotePath is the absolute credential path inside a profile directory.
func SaveRemote ¶
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 TokenActive ¶ added in v0.18.0
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 ¶
List returns every stored token, earliest created first, including revoked and expired ones — revocation is audit history, not noise.
func Mint ¶
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
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 ¶
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.
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 ¶
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.
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 ¶
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 ¶
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.