Documentation
¶
Overview ¶
Package pairing implements the in-memory state machine for the admin-approval pairing flow.
The flow:
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}.
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.
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
- Variables
- type MintFunc
- type Options
- type PollResult
- type Request
- type State
- type Store
- func (s *Store) Approve(id, currentFingerprint string, mint MintFunc) (Request, error)
- func (s *Store) Close()
- func (s *Store) CreateRequest(...) (Request, error)
- func (s *Store) Decline(id string) (Request, error)
- func (s *Store) Delete(id, pollSecret string) error
- func (s *Store) List() []Request
- func (s *Store) Poll(id, pollSecret string) (PollResult, error)
- func (s *Store) SetOnStateChange(fn func(snapshot Request))
- func (s *Store) TTL() time.Duration
- func (s *Store) TTLSeconds() int
Constants ¶
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 ¶
var ( ErrNotFound = errors.New("pairing: request not found") 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 ¶
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.
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 (*Store) Approve ¶
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 ¶
Decline transitions a Pending request to Declined and schedules the grace-window cleanup. Returns the post-transition snapshot.
func (*Store) Delete ¶
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 ¶
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 ¶
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 ¶
TTL returns the configured Pending TTL as a Duration. Used by the admin handler to compute per-row countdowns.
func (*Store) TTLSeconds ¶
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.