pairing

package
v0.1.7 Latest Latest
Warning

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

Go to latest
Published: Jun 27, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package pairing implements the in-memory state machine for the admin-approval pairing flow.

The flow:

  1. iOS POSTs to /v1/pairing/requests with a deviceName + the SHA-256 hash (hex) of a 32-byte pollSecret it keeps in memory. The bridge creates a Request in state Pending, stores the hash + the bridge's current cert fingerprint, and returns {requestID, verificationCode, ttlSeconds}.

  2. The admin web console lists pending requests, shows the verificationCode (which the operator reads off the iOS device's waiting screen), and Approves/Declines. Approve calls the injected MintFunc to create a real bearer token in the auth store; the raw token is held against the request until the iOS device acknowledges receipt.

  3. iOS polls GET /v1/pairing/{id} with Authorization: Bearer <pollSecret>. The Store SHA-256s the presented secret and constant-time-compares against the stored hash. While Pending the poll returns {status:"pending"}; once Approved the poll returns the raw token on every authorized request — NOT read-once. iOS may legitimately retry the same poll across a network blip, and the pollSecret + cert pin gate the re-reads. The token is removed only when iOS sends DELETE /v1/pairing/{id} (acknowledgment after keychain persist) OR when TTL+grace elapses without acknowledgment, in which case the Store revokes the minted token via the injected RevokeFunc and deletes the row.

State transitions are guarded by a single mutex and a per-request timer. Every transition explicitly Stops the prior timer to prevent goroutine accumulation under join-spam (only Pending counts toward the MaxPending cap; terminal-state rows linger for the grace window so a late poll sees the verdict instead of 404).

The Store deliberately holds no persistence — pending requests are ephemeral by design. A bridge restart drops every in-flight request; iOS detects the restart via bridgeStartedAt mismatch and prompts the user to retry. This keeps the disk-leak surface for pollSecret / rawToken at zero.

Index

Constants

View Source
const (
	// DefaultTTL is how long a Pending request stays alive without an
	// admin verdict. After expiry the row transitions to Expired and
	// holds for DefaultGrace before deletion. iOS sees expired requests
	// as terminal and re-taps Join to retry.
	DefaultTTL = 5 * time.Minute

	// DefaultGrace is how long a terminal-state request lingers before
	// deletion, so a poll fired concurrently with the transition still
	// gets a structured verdict instead of a 404.
	DefaultGrace = 60 * time.Second

	// DefaultMaxPending caps concurrent Pending requests bridge-wide.
	// No per-IP cap — under double-NAT/mesh routers every LAN device
	// presents the same router IP, so per-IP throttling produces false
	// positives. The admin UI's visible queue is the single bound;
	// a malicious flood is its own remediation prompt.
	DefaultMaxPending = 16
)

Defaults match the values documented in the iOS-side join-flow plan.

Variables

View Source
var (
	ErrNotFound       = errors.New("pairing: request not found")
	ErrUnauthorized   = errors.New("pairing: poll secret mismatch")
	ErrQueueFull      = errors.New("pairing: pending queue full")
	ErrAlreadyDecided = errors.New("pairing: request already decided")
	ErrCertRotated    = errors.New("pairing: cert fingerprint changed since request created")
	ErrBadHash        = errors.New("pairing: malformed pollSecretHash (must be 64 hex chars)")
	// ErrIDCollisionCap signals the request-ID collision retry budget
	// was exhausted. Indicates a degenerate `crypto/rand` state
	// (entropy exhaustion, kernel CSPRNG returning a fixed value);
	// the handler should map it to 503 + retry-later. Branchable via
	// errors.Is so the HTTP layer can route it without string matching.
	ErrIDCollisionCap = errors.New("pairing: request ID collision retry limit exceeded")
)

Sentinel errors returned by Store methods. Callers should branch via errors.Is for HTTP status mapping.

Functions

This section is empty.

Types

type MintFunc

type MintFunc func(name string) (rawToken, tokenID string, err error)

MintFunc is the Approve callback that creates the bearer token. The adapter at the call site wraps auth.Store.Mint(name) into this shape so the pairing package doesn't dictate the auth-store API.

type Options

type Options struct {
	TTL        time.Duration
	Grace      time.Duration
	MaxPending int

	// Now is the clock source. nil defaults to time.Now. Tests inject a
	// controllable clock; production passes nil.
	Now func() time.Time

	// RevokeToken is invoked when an Approved request hits TTL+grace
	// without iOS acknowledging receipt via DELETE — defends against
	// orphaned tokens after a network blip kills the iOS poll between
	// mint and consume. nil means "never revoke" (test mode); production
	// wires this to auth.Store.Revoke.
	RevokeToken func(tokenID string) error

	// OnStateChange is invoked AFTER every observable state transition
	// (Pending→Approved via Approve, Pending→Declined via Decline,
	// Pending→Expired via the timer sweep). The snapshot is the
	// post-transition state; nil-safe (no-op when not wired).
	//
	// cmd/bridge wires this to publish a `pairing.<requestID>` event
	// to the SSE broker so iOS clients see push-delivered approval/
	// decline/expiry updates instead of polling. Production: paired
	// with broker.Publish; tests: paired with a stubOnStateChange
	// recorder.
	//
	// **Invoked OUTSIDE the pairing mutex** to mirror the RevokeToken
	// pattern — the broker has its own mutex and we don't want
	// cross-mutex coupling stalling concurrent CreateRequest calls.
	// Same callback contract: keep handlers lightweight, hop to a
	// goroutine for heavy work.
	OnStateChange func(snapshot Request)
}

Options configures a Store. Zero values fall through to package defaults.

type PollResult

type PollResult struct {
	State               State
	TTLSecondsRemaining int
	Token               string // empty unless State == StateApproved
	TokenID             string // empty unless State == StateApproved
	VerificationCode    string // echoed so iOS can re-display after a foreground resume
}

PollResult is the wire shape Poll returns.

type Request

type Request struct {
	ID               string
	DeviceName       string
	ClientVersion    string
	VerificationCode string
	// PollHash is the raw bytes of sha256(pollSecret), decoded once at
	// CreateRequest time so every Poll comparison runs against an
	// already-canonical [32]byte (no hex casing pitfall in the compare
	// path).
	PollHash [32]byte
	// CertFingerprint is the bridge's cert SHA-256 fingerprint at the
	// moment of request creation. Compared against the live fingerprint
	// at Approve time — mismatch transitions to CertRotated instead of
	// Approved, defending against a cert rotation between the iOS user
	// initiating Join and the admin clicking Approve.
	CertFingerprint string
	// SourceIP is the request's RemoteAddr host. Display-only — the
	// admin UI shows it so the operator can spot LAN spam patterns.
	SourceIP string
	// DeviceToken is the iOS client's durable, device-local recovery
	// token (the Keychain identity that survives app reinstall). Optional
	// — empty for pre-feature clients. Carried through so the admin
	// approve path can bind/refresh the device_registrations row (with a
	// real device name) the instant a pairing is approved, rather than
	// waiting for the first authed request's X-Device-Token header.
	//
	// SECRET: this is the client's own recovery token. It is preserved by
	// snapshot() (the admin approve handler needs it to rebind) but MUST
	// NOT be serialized into any wire DTO — the pairing poll / SSE event
	// (PairingStateEvent) and the admin pending-pairing row deliberately
	// omit it.
	DeviceToken string
	State       State
	TokenID     string // populated on Approve; tied to auth.Store.Mint return
	RawToken    string // populated on Approve; returned on every authorized poll until DELETE
	CreatedAt   time.Time
	DecidedAt   time.Time
	// contains filtered or unexported fields
}

Request is the in-memory record for a single pairing attempt. Fields are exported so the admin handler can render the snapshot returned by Store.List(); the live row inside the Store is held by pointer and never escapes Store methods directly.

type State

type State int

State enumerates the lifecycle of a pairing request. Pending is the only mutable state from outside the Store; everything else is set by transition methods and lingers for the grace window before deletion.

const (
	StatePending State = iota
	StateApproved
	StateDeclined
	StateExpired
	StateCertRotated
)

func (State) String

func (s State) String() string

String returns the lowercase wire form. Stable — iOS decodes by exact match.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store holds the live set of pairing requests. Safe for concurrent use by HTTP handlers (CreateRequest / Poll / Delete) and the admin (List / Approve / Decline). Internally a single mutex serializes every state transition; the auth.Store mutex (taken by RevokeToken inside the sweeper) is never held while the pairing mutex is held — the timer callback releases the pairing mutex before invoking revoke.

func NewStore

func NewStore(opts Options) *Store

NewStore constructs a Store with the given options.

func (*Store) Approve

func (s *Store) Approve(id, currentFingerprint string, mint MintFunc) (Request, error)

Approve transitions a Pending request to Approved, mints a bearer token via the injected MintFunc, and schedules the undelivered-revoke deadline. Returns the post-transition snapshot.

`currentFingerprint` is the bridge's cert fingerprint at approve time. If the captured-at-create fingerprint differs, the request transitions to CertRotated (refusing to approve onto a new cert) and ErrCertRotated is returned. iOS sees a terminal cert_rotated state and prompts re-pair.

MintFunc is called while Store.mu is held — callers must ensure mint can't deadlock by re-entering the pairing store. auth.Store.Mint satisfies this (separate mutex, no callbacks back into pairing).

func (*Store) Close

func (s *Store) Close()

Close stops every per-request timer. Call on clean shutdown so the timer goroutines drain promptly. Safe to call multiple times.

func (*Store) CreateRequest

func (s *Store) CreateRequest(deviceName, clientVersion, pollSecretHashHex, sourceIP, certFingerprint, deviceToken string) (Request, error)

CreateRequest stores a new Pending request and returns its snapshot (verification code + ID for the iOS POST response). Called from the /v1/pairing/requests handler.

func (*Store) Decline

func (s *Store) Decline(id string) (Request, error)

Decline transitions a Pending request to Declined and schedules the grace-window cleanup. Returns the post-transition snapshot.

func (*Store) Delete

func (s *Store) Delete(id, pollSecret string) error

Delete removes a request after authenticating via pollSecret. Used by iOS for both user-cancel (Pending) and acknowledgment of token receipt (Approved). Idempotent at the HTTP layer — the handler maps ErrNotFound to 200 (already-deleted is success for an ack). The Store treats ErrNotFound as a real signal so callers can distinguish.

func (*Store) List

func (s *Store) List() []Request

List returns a snapshot of every request, for the admin Devices page. Sorted by CreatedAt ascending so the oldest pending shows first — admin tends to triage in arrival order, and the bounded list size (MaxPending = 16) makes the sort cost negligible.

func (*Store) Poll

func (s *Store) Poll(id, pollSecret string) (PollResult, error)

Poll authenticates the caller via pollSecret (raw, base64url-ish bytes) and returns the current state. The token is included on every authorized poll while the state is Approved, NOT read-once — see the "Token delivery contract" in the package doc.

func (*Store) SetOnStateChange

func (s *Store) SetOnStateChange(fn func(snapshot Request))

SetOnStateChange wires (or rewires) the callback fired AFTER every observable state transition (Approve, Decline, timer-driven Pending→ Expired). nil disables notification. cmd/bridge sets this after the SSE broker starts — the chicken-and-egg with apiSrv means Options can't carry the broker reference at construction time.

func (*Store) TTL

func (s *Store) TTL() time.Duration

TTL returns the configured Pending TTL as a Duration. Used by the admin handler to compute per-row countdowns.

func (*Store) TTLSeconds

func (s *Store) TTLSeconds() int

TTLSeconds returns the configured Pending TTL as whole seconds. Used by the iOS-facing handler so the POST response advertises the same window the Pending sweeper enforces. Floors at 1 for non-zero TTLs so a sub-second TTL (only used in tests) still surfaces a positive value.

Jump to

Keyboard shortcuts

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