hubclient

package
v0.19.1 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Overview

Package hubclient is a lock's client for lockhub, the approval hub: it pushes signed tickets, long-polls the hub's decisions and turns an approval into a plan that runs once.

Every request but Hello is signed with the lock instance's key (the Lock-Instance, Lock-Time, Lock-Nonce and Lock-Sig headers, see ticket.RequestMessage); every decision is verified with the hub key the lock pinned, never one the hub sends. A screen leaves the lock only as a render.MaskedScreen.

The client fails closed: a hub that cannot be reached leaves the plan pending, and nothing falls back to another approver. A ticket the hub did not acknowledge is pushed again by the next Submit of its plan or by Sync.

Index

Constants

View Source
const (
	// MaxPending bounds the pending tickets of an instance.
	MaxPending = 30
	// RateN tickets a human decides per RateWindow at most (one every 10 s
	// on average); RateTotal tickets in all, auto-decided ones included
	// (see Limits).
	RateN      = 30
	RateWindow = 5 * time.Minute
	RateTotal  = 300
	// MaxWait bounds a long poll (the hub's own bound).
	MaxWait = 60 * time.Second
	// DecisionWait is how long a run waits for the decision on the ticket
	// it just pushed (Await) before it answers pending: about what an
	// auto rule of the decider takes (tens of milliseconds), with room.
	DecisionWait = 2 * time.Second
	// MaxInbox bounds the decisions kept for plans not yet run.
	MaxInbox = plans.MaxPlans
	// MaxRequests bounds the hub requests waiting to be handled.
	MaxRequests = 64
)

Defaults.

View Source
const (
	LimitPending = "max_pending"
	LimitRate    = "tickets_per_window"
	LimitTotal   = "total"
)

Which limit a RateLimitError names.

View Source
const (
	CodeFacadeRefused = "facade_refused" // no pinned facade, or not this one
	CodeNotPending    = "not_pending"    // nothing to reveal: the ticket is not pending
	CodeNoClear       = "no_clear_screen"
	CodeNotRenewable  = "not_renewable" // not expired, or gone
	CodeRecheckFailed = "recheck_failed"
	CodePolicyChanged = "policy_changed" // plan again
	CodeLimited       = "rate_limited"
	CodeFailed        = "failed"
)

Answer codes: the only error text a lock sends the hub for a request.

Variables

View Source
var (
	// ErrUnreachable: the hub did not answer, or answered 5xx. A ticket
	// signed for the plan stays pending.
	ErrUnreachable = errors.New("hubclient: hub unreachable")
	// ErrRateLimited: too many pending tickets or too many new tickets in
	// the rate window, here or at the hub; no ticket was opened.
	ErrRateLimited = errors.New("hubclient: too many tickets")
	// ErrPending: no decision on the ticket yet.
	ErrPending = errors.New("hubclient: no decision yet")
	// ErrDenied: the human denied the ticket; the plan is Denied.
	ErrDenied = errors.New("hubclient: denied")
	// ErrWithdrawn: the human withdrew the ticket at the hub (lox run, lox
	// withdraw); the plan is Withdrawn. It is an ErrDenied too.
	ErrWithdrawn = errors.New("hubclient: ticket withdrawn by the human")
	// ErrBadDecision: the hub sent a decision that does not verify under
	// the pinned hub key or is not for this instance; it was dropped.
	ErrBadDecision = errors.New("hubclient: decision refused")
)
View Source
var ErrBadRequest = errors.New("hubclient: hub request refused")

ErrBadRequest: a hub request that does not verify, is not for this lock, is stale, or names a facade the lock does not pin.

View Source
var ErrNotRenewable = errors.New("hubclient: ticket cannot be renewed")

ErrNotRenewable: the ticket cannot be renewed (not expired, or gone).

Functions

func AnswerCode added in v0.5.0

func AnswerCode(err error) string

AnswerCode is the code a lock answers the hub for a request that failed with err.

func CanonicalURL added in v0.8.1

func CanonicalURL(raw string) (string, error)

CanonicalURL checks raw as a hub URL and returns it as scheme://host[:port], the only form accepted: the hub API lives at the root, and the hub authenticates by signature, so a user, password, path, query or fragment has no use and would carry a token to errors, the console's lines, its command line and the hub. Its errors never quote raw.

func HubKeyCommand

func HubKeyCommand(w io.Writer, home string, args []string) error

HubKeyCommand is a lock's `hub-key --instance NAME`: it prints the public key of the instance (InstanceKey of home, "" the user's, created on the first call) in base64 std on one line, for `lox run` to pin at the hub.

func InstanceKey

func InstanceKey(home, instance string) (ed25519.PrivateKey, error)

InstanceKey returns the Ed25519 key of instance, kept in KeyDir(home)/<instance>.key (0600 in a 0700 directory, the seed in base64 std on one line); home "" is the user's home. The first call creates it; a key file group or others can read is refused.

func KeyDir

func KeyDir(home string) string

KeyDir is where a lock keeps its instance keys, under its home volume.

func ParseRisks added in v0.4.0

func ParseRisks(s string) ([]ticket.Risk, error)

ParseRisks reads a comma-separated list of risk classes ("" is none), as --require-device takes it.

func ReadDevices added in v0.4.0

func ReadDevices(path string) ([]ticket.Device, error)

ReadDevices reads the pinned approval devices: a ticket.DeviceSet (the file `lox run` mounts at /run/lockfamily/devices.json, written from the devices the human confirmed at the host's terminal).

func ReadFacades added in v0.5.0

func ReadFacades(path string) ([]ticket.PinnedFacade, error)

ReadFacades reads the facade keys a lock pins: a ticket.FacadeSet (the file `lox run` mounts at /run/lockfamily/facades.json, written from the facade keys the human reviewed at the host's terminal).

func ReadHubKey

func ReadHubKey(path string) (ed25519.PublicKey, error)

ReadHubKey reads the pinned hub key: an Ed25519 public key in base64 std on one line (the file `lox run` mounts at /run/lockfamily/hub.pub).

func ValidInstance

func ValidInstance(name string) bool

ValidInstance reports whether name is a valid lock instance name.

Types

type Client

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

Client talks to one hub for one lock instance. It is safe for concurrent use.

func New

func New(cfg Config) (*Client, error)

New checks cfg and loads the state file, if any.

func (*Client) Ask added in v0.13.0

func (c *Client) Ask(ctx context.Context, t *ticket.Ticket, screen render.MaskedScreen) (*ticket.Decision, error)

Ask pushes a connect ticket and waits for the hub's decision on it, until ctx ends: what lazyconn asks before opening a backend connection. A connect ticket is the lock's own (it runs no plan of an agent), so it is not recorded in a plans.Store and is never resent after a restart: a lock that restarts starts disconnected and asks again.

t is a ticket.KindConnect ticket (ticket.NewConnectTicket); Ask sets its instance, key id, screen and touched kinds from screen, signs it and pushes it. The decision comes back verified under the pinned hub key, for this instance and key, matching t (ticket.Decision.Match), with a device's signature over the ticket checked when the decision names one; a denial comes back as a decision too. The caller checks Usable, and opens the secret with the ticket's one-use key (Decision.OpenSecret).

Ask polls the hub itself, or waits for a concurrent Watch to: either way the decision is taken from the client's inbox, once.

func (*Client) Await added in v0.17.0

func (c *Client) Await(ctx context.Context, ticketID string, wait time.Duration) bool

Await waits up to wait (at most MaxWait) for a decision on ticketID, as Poll keeps them (Watch runs it), and reports whether one is kept: the product then runs the ticket at once (Begin), and the agent never hears pending for a ticket an auto rule decided in milliseconds. A decision kept already answers at once; ctx ending, or none within wait, is false. Await neither verifies nor acts on the decision: Begin does.

func (*Client) Begin

func (c *Client) Begin(st *plans.Store, ticketID string, caller plans.Caller, recheck Recheck) (*plans.Plan, *ticket.Decision, error)

Begin turns the decision on ticketID into a run of its plan, for caller (the agent that made the plan): the decision verifies under the pinned hub key and matches the ticket the lock signed (ticket.Decision.Match), is still usable, and the plan's binding has not moved (recheck, plans.Store.Check). The plan comes back Executing under the decision's nonce, consumed: the product runs it, then calls Finish.

Begin also returns the verified decision, for the lock's audit (who approved, on which facade, the hub's signature), with the plan or with the error of a decision that was matched but not acted on.

A decision taken on a device (facade mobile) must carry the signature of a pinned device over the lock's own signed ticket (ticket.VerifyDevice), and with Config.RequireDevice an approval of those risk classes must.

No decision yet is ErrPending; a denial ends the plan Denied and is ErrDenied with the human's reason, a withdrawal at the hub (ticket.Decision.Withdrawal) ends it Withdrawn and is ErrWithdrawn (an ErrDenied too). A plan EndDenied ended already, or ends while Begin runs, answers the same, once, with the denial: of runs racing on one denial only one gets it, the others ErrUsed. A decision too old or past the ticket ends it Expired. An expired ticket is ticket.ErrExpired: the product renews it (Renew) and answers pending with the new ticket.

A ticket that asked the human for typed values (ticket.Subject.Inputs) runs with them: the plan Begin returns carries them in Plan.Inputs, opened with the one-use key the store kept in memory (dropped once the plan runs), and the product wipes them when done (secrets.Wipe). An approval without them, or whose box does not open, ends the plan Denied (ErrBadDecision); a lock restarted since it signed the ticket lost the key, and the run is ticket.ErrExpired, renewed as above.

func (*Client) Consumed added in v0.13.0

func (c *Client) Consumed(ctx context.Context, id string) error

Consumed tells the hub that the decision on ticket id was acted on (a connect ticket: the connection is open).

func (*Client) EndDenied added in v0.16.0

func (c *Client) EndDenied(st *plans.Store) error

EndDenied ends, without waiting for their agent, the pending plans whose ticket the hub denied, as Poll kept the denials: Withdrawn for a withdrawal at the hub (the human in lox run or lox withdraw), Denied otherwise. The plan then frees its pending slot (MaxPending) and Sync never pushes it again. The denial stays kept: the agent's next Begin on the ticket answers ErrWithdrawn or ErrDenied with it. Watch calls it after each Poll. A denial that does not match the lock's own ticket is dropped (ErrBadDecision) and its plan stays pending.

func (*Client) Finish

func (c *Client) Finish(ctx context.Context, st *plans.Store, planID string, caller plans.Caller, ok bool, reason string) error

Finish ends an executing plan (plans.Store.Finish) and tells the hub its ticket was consumed. The plan is finished even when the hub cannot be told; the error says so.

func (*Client) HandleRequests added in v0.5.0

func (c *Client) HandleRequests(ctx context.Context, st *plans.Store) error

HandleRequests acts on the hub requests the last polls brought (Watch calls it after each Poll): a reveal seals the plan's clear screen to the facade's ephemeral key, a renewal asks again for an expired ticket. Each request is answered at the hub, once.

func (*Client) Hello

func (c *Client) Hello(ctx context.Context) (Hello, error)

Hello asks the hub who it is (unsigned; it proves nothing about the hub).

func (*Client) Limits added in v0.15.0

func (c *Client) Limits() Limits

Limits returns the client's limits, defaults applied.

func (*Client) OnRequest added in v0.5.0

func (c *Client) OnRequest(f func(q *ticket.HubRequest, renewal string, err error))

OnRequest sets f, told of every hub request the lock acted on or refused (q nil when it did not verify), with the new ticket of a renewal: for the lock's audit and console.

func (*Client) Poll

func (c *Client) Poll(ctx context.Context, wait time.Duration) (int, error)

Poll long-polls the hub once, waiting up to wait (at most MaxWait) for decisions after the cursor, keeps those that verify under the pinned hub key for this instance, and advances and saves the cursor. It returns how many decisions it kept; decisions it dropped make ErrBadDecision. A decision taken on a device is kept once it names a device this lock pins: the device's signature over the lock's own ticket is checked later, by Begin and by a renewal's age-out, never by Poll, so a kept decision is not yet one the lock will act on. Poll also queues the hub's requests (reveal, renew) it carries, at most MaxRequests.

func (*Client) Renew added in v0.5.0

func (c *Client) Renew(ctx context.Context, st *plans.Store, ticketID string, caller plans.Caller) (string, error)

Renew asks again for the expired ticket ticketID of caller (decision H1): a new ticket for the same plan, re-checked now (SetRecompute), that says what changed in its plan-hash inputs. It returns the new ticket; a ticket renewed already returns its renewal. The agent runs the new ticket once it is decided.

func (*Client) SetLimits added in v0.15.0

func (c *Client) SetLimits(l Limits)

SetLimits replaces the client's limits (a profile reloaded after its review); fields <= 0 take the defaults. The tickets already counted stay counted.

func (*Client) SetRecompute added in v0.5.0

func (c *Client) SetRecompute(r Recompute)

SetRecompute sets the product's Recompute.

func (*Client) Submit

func (c *Client) Submit(ctx context.Context, st *plans.Store, planID string, caller plans.Caller, t *ticket.Ticket, screen render.MaskedScreen) (string, error)

Submit asks the hub for a decision on plan planID of caller and returns the ticket id. t is the product's ticket for the plan (NewTicket; its plan hash and policy fingerprint the plan's); Submit sets its instance, key id, screen and touched kinds from screen, signs it, records it in st and pushes it.

Submit is idempotent: a plan already pending returns its ticket, and a pending plan of caller with the same plan hash lends its ticket instead of opening a new one. A screen MaskScreen did not build is refused (render.ErrUnmasked). With the hub unreachable, Submit returns the ticket id with ErrUnreachable: the plan is pending, and the ticket goes out on a later Submit or Sync.

func (*Client) SubmitClear added in v0.4.0

func (c *Client) SubmitClear(ctx context.Context, st *plans.Store, planID string, caller plans.Caller, t *ticket.Ticket, screen render.MaskedScreen, clear *ticket.Screen) (string, error)

SubmitClear is Submit with the clear screen of the request: with Config.SealClear, it is sealed to each pinned device (ticket.SealClearScreen) in the ticket, which the hub relays without being able to read it; otherwise, with clear nil, or a clear screen over ticket.MaxClearScreen, it is not sent at all.

func (*Client) Sync

func (c *Client) Sync(ctx context.Context, st *plans.Store) error

Sync pushes every pending ticket the hub has not acknowledged since this client started: after a restart of the lock or of the hub. It also withdraws at the hub the tickets of plans withdrawn while the lock ran without it (plans.Store.WithdrawAll, Untold).

func (*Client) URL added in v0.8.1

func (c *Client) URL() string

URL is the hub as the client prints it: scheme://host[:port], the only form of the hub URL New accepts (CanonicalURL).

func (*Client) Watch

func (c *Client) Watch(ctx context.Context, st *plans.Store, report func(error))

Watch polls until ctx ends, backing off while the hub is unreachable and resending unacknowledged tickets (Sync) when it answers again. Errors go to report (nil: dropped).

func (*Client) Withdraw

func (c *Client) Withdraw(ctx context.Context, st *plans.Store, planID string, caller plans.Caller, reason string) error

Withdraw ends a plan not yet run as Withdrawn and withdraws its ticket at the hub. The plan is withdrawn even when the hub cannot be told; the error says so.

type Config

type Config struct {
	// URL is the hub's base URL ("http://lockhub:8443").
	URL string
	// Instance is the lock instance's name (Lock-Instance, Source.Instance).
	Instance string
	// Key is the instance's Ed25519 key: it signs tickets and requests.
	Key ed25519.PrivateKey
	// HubKey is the pinned hub key decisions must verify under.
	HubKey ed25519.PublicKey
	// StateFile keeps the decision cursor and the decisions not yet acted
	// on, in the lock's volume (written 0600).
	StateFile string
	// ApprovalTTL is the instance's approval lifetime (ticket.ClampTTL; 0
	// keeps each risk class's).
	ApprovalTTL time.Duration
	// MaxPending, RateN, RateWindow and RateTotal override the defaults
	// when > 0 (Limits; SetLimits changes them later).
	MaxPending int
	RateN      int
	RateWindow time.Duration
	RateTotal  int
	// HTTP is the HTTP client (nil: one without a global timeout; long
	// polls are bounded by MaxWait). Redirects are refused unless it sets
	// its own CheckRedirect.
	HTTP *http.Client
	// PlainHTTP (the flag --hub-plain-http) allows an http:// URL to a hub
	// on a network of its own (lox run's): a service name without a dot or
	// a private address only. Off, http is refused but to a loopback
	// address; use https. Requests and decisions are signed either way.
	PlainHTTP bool
	// Devices are the approval devices the lock pins (--devices-file): a
	// decision that carries a device signature, or that names the mobile
	// facade, is kept only if it verifies under one of them, over the
	// lock's own signed ticket (ticket.VerifyDevice).
	Devices []ticket.Device
	// SealClear (--seal-clear) seals each ticket's clear screen to every
	// pinned device (SubmitClear). Off, clear screens never leave the
	// lock. The device's code comes from the hub: see the ticket package
	// on what a hub that serves malicious code can read.
	SealClear bool
	// RequireDevice lists the risk classes whose approvals must be signed
	// by a pinned device, whatever facade the hub says they came from
	// (--require-device): an approval without one ends the plan Denied.
	RequireDevice []ticket.Risk
	// Facades are the facade keys the lock pins (--facade-keys-file): a
	// reveal is sealed only to a key one of them signed.
	Facades []ticket.PinnedFacade
	// Now is the clock (nil: time.Now).
	Now func() time.Time
}

Config configures a Client. URL, Instance, Key, HubKey and StateFile are required: there is no discovery and no unpinned hub.

type Hello

type Hello struct {
	Name    string `json:"name"`
	Version string `json:"version"`
	API     int    `json:"api"`
}

Hello is the hub's answer to GET /v1/hello.

type HubError

type HubError struct {
	Status  int
	Code    string
	Message string
}

HubError is an error answer of the hub.

func (*HubError) Error

func (e *HubError) Error() string

func (*HubError) Is

func (e *HubError) Is(target error) bool

Is maps 429 to ErrRateLimited and 5xx to ErrUnreachable.

type Limits added in v0.15.0

type Limits struct {
	// MaxPending bounds the pending tickets of an instance
	// (limits.max_pending).
	MaxPending int
	// RateN tickets a human decides per RateWindow at most
	// (limits.tickets_per_window, limits.ticket_window).
	RateN      int
	RateWindow time.Duration
	// RateTotal tickets per RateWindow at most, auto-decided ones
	// included (never below RateN).
	RateTotal int
}

Limits bound the tickets a lock opens. The rate is there to keep an agent from flooding the human with questions, not to slow the reads that need nobody: a ticket an auto rule decided (`lox run`'s default:read, session:connect, ...) leaves RateN's count once its decision comes back, and only RateTotal, a high safety cap against a runaway agent, still counts it.

func DefaultLimits added in v0.15.0

func DefaultLimits() Limits

DefaultLimits are the limits of a lock that sets none.

func (Limits) Effective added in v0.15.0

func (l Limits) Effective() Limits

Effective is l with each field <= 0 replaced by its default, and RateTotal raised to RateN when below it.

type RateLimitError added in v0.15.0

type RateLimitError struct {
	// Limit is LimitPending, LimitRate or LimitTotal.
	Limit string
	// Count is the current count, Max the limit.
	Count, Max int
	Window     time.Duration
	// RetryAfter is when a ticket will be accepted again; 0 for
	// LimitPending: once a pending ticket is decided.
	RetryAfter time.Duration
}

RateLimitError is a ticket the lock refused to open: it names the limit, the current count and when the next ticket will be accepted. It is ErrRateLimited.

func (*RateLimitError) Error added in v0.15.0

func (e *RateLimitError) Error() string

func (*RateLimitError) Is added in v0.15.0

func (e *RateLimitError) Is(target error) bool

Is makes a RateLimitError ErrRateLimited.

type Recheck

type Recheck func(p *plans.Plan) (policyFP, planHash string, err error)

Recheck recomputes, just before running, the policy fingerprint and plan hash of a plan from its body and the lock's current state.

type Recompute added in v0.5.0

type Recompute func(p *plans.Plan) (policyFP, planHash string, inputs map[string]string, err error)

Recompute gives, for a stored plan, its policy fingerprint, plan hash and plan-hash inputs (ticket.InputDigests) as the lock sees them now: a renewal shows the human what moved since the expired ticket. An input it cannot check now (no session) is ticket.UncheckedInput, never its stored digest: the renewal then says so, and is not one click; keep the stored plan hash in that case (the run checks it). A product sets it with SetRecompute; without one, a renewal keeps the plan's hash and says it was not checked (the run checks it anyway).

Directories

Path Synopsis
Package hubtest is a fake lockhub for the tests of locks: it speaks the lock API v1 (signed requests, tickets, decisions with a cursor) on an httptest server, and lets a test decide tickets.
Package hubtest is a fake lockhub for the tests of locks: it speaks the lock API v1 (signed requests, tickets, decisions with a cursor) on an httptest server, and lets a test decide tickets.

Jump to

Keyboard shortcuts

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