Help us shape the future of Go! Take the 2026 Go Developer Survey

hubclient

package
v0.7.1 Latest Latest
Warning

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

Go to latest
Published: Oct 30, 2026 License: Apache-2.0 Imports: 26 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 = 20
	// RateN new tickets per RateWindow at most.
	RateN      = 10
	RateWindow = 5 * time.Minute
	// MaxWait bounds a long poll (the hub's own bound).
	MaxWait = 60 * 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 (
	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")
	// 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 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 `lock up` 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 `lock up` 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 `lock up` 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 `lock up` 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) 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 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.

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) 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.

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) 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) 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 and RateWindow override the defaults when > 0.
	MaxPending int
	RateN      int
	RateWindow time.Duration
	// 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 (lock up'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 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