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

ticket

package
v0.3.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: 20 Imported by: 0

Documentation

Overview

Package ticket is the contract between a lock and lockhub: the ticket a lock pushes when a request needs a human, the decision the hub signs back, their canonical JSON (RFC 8785), Ed25519 signatures and the X25519 sealed boxes that carry what the hub must not read.

Signatures are computed over the canonical form of the value without its "sig" member, prefixed by a Context. Verification decodes strictly (no unknown member, no trailing data), validates, re-canonicalizes what it decoded and checks the signature over that: what verifies is exactly what the caller then reads.

Index

Constants

View Source
const SealAlg = "x25519-hkdf-sha256-xchacha20poly1305"

SealAlg is the only sealed-box construction: an ephemeral X25519 key agreement, HKDF-SHA256 over the shared secret (salt: the ephemeral then the recipient public key), XChaCha20-Poly1305 with a random nonce.

View Source
const Version = 1

Version is the schema version of tickets and decisions this package writes and reads. A value of any other version is refused (fail closed).

Variables

View Source
var ErrBadSignature = errors.New("ticket: bad signature")

ErrBadSignature: the signature does not verify under the key given, or the key is not the one the signed value names.

View Source
var ErrDenied = errors.New("ticket: not approved")

ErrDenied: the decision is not an approval.

View Source
var ErrExpired = errors.New("ticket: expired")

ErrExpired: the ticket or the approval is past its TTL.

View Source
var ErrInvalid = errors.New("ticket: invalid")

ErrInvalid is wrapped by every validation error.

View Source
var ErrMismatch = errors.New("ticket: decision does not match the ticket")

ErrMismatch: a verified decision is not for this ticket.

View Source
var ErrNotCanonicalizable = errors.New("ticket: not canonicalizable JSON")

ErrNotCanonicalizable is wrapped by every error of CanonicalizeJSON: input that is not I-JSON (RFC 7493), the subset RFC 8785 defines.

View Source
var ErrOpen = errors.New("ticket: sealed box does not open")

ErrOpen: a sealed box does not open with this key and associated data.

Risks lists every risk class, from the least to the most sensitive.

Functions

func Canonical

func Canonical(v any) ([]byte, error)

Canonical is the RFC 8785 (JCS) form of v: json.Marshal, then CanonicalizeJSON. Signatures and hashes are computed over it, so it refuses what json.Marshal would encode with a loss: a string with invalid UTF-8 (json.Marshal writes U+FFFD for it) and, as CanonicalizeJSON, an integer a double cannot hold exactly. Encode such values as strings (base64, decimal) first.

func CanonicalizeJSON

func CanonicalizeJSON(b []byte) ([]byte, error)

CanonicalizeJSON is the RFC 8785 (JCS) form of one JSON value: object members sorted by their UTF-16 code units, no whitespace, strings with the minimal escapes, numbers as ECMAScript prints them. It refuses what I-JSON refuses: invalid UTF-8, lone surrogates, duplicate member names, numbers out of the IEEE 754 double range, integers it cannot hold exactly (above 2^53, mostly: two of them would print alike), and trailing data.

func ClampTTL

func ClampTTL(r Risk, configured time.Duration) time.Duration

ClampTTL is the TTL of class r for an instance configured with configured: an instance may only shorten it. configured <= 0 keeps r.TTL().

func HashOf

func HashOf(v any) (string, error)

HashOf is "sha256:" and the hex SHA-256 of the canonical form of v: the plan hash of a plan's inputs, a policy fingerprint.

func KeyID

func KeyID(pub ed25519.PublicKey) string

KeyID names an Ed25519 public key: "ed25519:" and the first 16 bytes of its SHA-256, in hex. It names a key, it does not vouch for one: the verifier always uses the key it pinned.

func NewID

func NewID(now time.Time) string

NewID is a fresh ticket id: a ULID (48-bit Unix milliseconds, then 80 random bits), 26 upper-case characters that sort by creation time.

func NewNonce

func NewNonce() string

NewNonce is a fresh random nonce: 16 bytes, base64url.

func Open

func Open(priv *ecdh.PrivateKey, s *Sealed, aad []byte) ([]byte, error)

Open decrypts s with priv and aad. Any failure is ErrOpen.

func RequestMessage

func RequestMessage(method, pathQuery, instance string, at time.Time, nonce string, body []byte) []byte

RequestMessage is what a lock signs, under ContextRequest, for one HTTP request to the hub (the Lock-Sig header): the method, the path with its query, the signing instance (Lock-Instance), the Unix time in seconds (Lock-Time), the request nonce (Lock-Nonce) and the hex SHA-256 of the body, one per line. The hub accepts it within a minute of its own clock and each nonce once.

func SignBytes

func SignBytes(key ed25519.PrivateKey, c Context, msg []byte) string

SignBytes signs msg under context c.

func ValidID

func ValidID(s string) bool

ValidID reports whether s is a ULID in canonical form: 26 characters of the upper-case alphabet, the first at most '7'.

func VerifyBytes

func VerifyBytes(pub ed25519.PublicKey, c Context, msg []byte, sig string) error

VerifyBytes checks sig, as SignBytes returns it, over msg under c.

func VerifyKeyCert

func VerifyKeyCert(signer ed25519.PublicKey, c Context, bind string, data []byte) (*ecdh.PublicKey, error)

VerifyKeyCert decodes a key certificate signed by signer under context c and bound to bind, and returns its X25519 key.

Types

type Approver

type Approver struct {
	ID      string `json:"id"`
	Display string `json:"display"`
}

Approver is who decided, as the hub identified them.

type Context

type Context string

Context separates what one Ed25519 key signs: a lock's key signs its tickets, its X25519 key and its requests to the hub, and none of these signatures is valid as another. A new signed kind gets a new context.

const (
	ContextTicket   Context = "lockkit/ticket/v1"
	ContextDecision Context = "lockkit/decision/v1"
	// ContextKeyCert signs an X25519 public key with an Ed25519 key.
	ContextKeyCert Context = "lockkit/x25519-key/v1"
	// ContextRequest signs a lock's HTTP request to the hub (Lock-Sig).
	ContextRequest Context = "lockkit/request/v1"
	// ContextReveal signs a facade's ephemeral key for a reveal.
	ContextReveal Context = "lockkit/reveal/v1"
)

type Decision

type Decision struct {
	V        int     `json:"v"`
	Ticket   string  `json:"ticket"`
	Instance string  `json:"instance"`
	KeyID    string  `json:"key_id"` // the lock's key
	PlanHash string  `json:"plan_hash"`
	Decision Outcome `json:"decision"`
	// Reason is the human's reason (a denial's, mostly); untrusted.
	Reason   string   `json:"reason"`
	Approver Approver `json:"approver"`
	// Facade is the facade the human answered on ("tty", "deck", ...).
	Facade string    `json:"facade"`
	At     time.Time `json:"at"`
	// Nonce is single use: a lock consumes it with the decision.
	Nonce    string `json:"nonce"`
	HubKeyID string `json:"hub_key_id"` // KeyID of the hub's key
	Sig      string `json:"sig,omitempty"`
}

Decision is the hub's answer to one ticket, signed by the hub. It repeats the ticket's instance, lock key and plan hash: a lock refuses a decision that does not match its own ticket (Match), so a decision cannot be replayed to another instance or plan.

func VerifyDecision

func VerifyDecision(pub ed25519.PublicKey, data []byte) (*Decision, error)

VerifyDecision decodes a decision and checks it: schema, then the signature under pub, the hub key the caller pinned. A decision naming another hub key id is refused. The caller then checks Match against its ticket and consumes the nonce.

func (*Decision) Marshal

func (d *Decision) Marshal() ([]byte, error)

Marshal is the canonical JSON of a signed decision, as it is sent.

func (*Decision) Match

func (d *Decision) Match(t *Ticket) error

Match checks that d answers t: same ticket id, instance, lock key and plan hash. A lock calls it with its own ticket (never one rebuilt from the decision) before acting on d.

func (*Decision) Sign

func (d *Decision) Sign(key ed25519.PrivateKey) error

Sign validates d, sets d.HubKeyID to the key's id when empty (a different one is an error), and signs it.

func (*Decision) Usable

func (d *Decision) Usable(t *Ticket, now time.Time, ttl time.Duration) error

Usable checks that an approval of t can still be acted on at now: it came before the ticket expired, and it is younger than ttl (the instance's ClampTTL for t.Risk; 0 never ages). A decision dated in the future by more than a minute is refused too, and anything but an approval is ErrDenied.

func (*Decision) Validate

func (d *Decision) Validate() error

Validate checks d's schema. It does not check the signature.

type Flags

type Flags struct {
	// Production: the target is production; the facade asks for the
	// typed instance name.
	Production bool `json:"production"`
	// NeverAuto: never approved by an auto mode.
	NeverAuto bool `json:"never_auto"`
	// Retype, when set, is the answer that asks the lock to retype values.
	Retype string `json:"retype"`
}

Flags change how a facade asks.

type KeyCert

type KeyCert struct {
	V      int    `json:"v"`
	KeyID  string `json:"key_id"` // the signing Ed25519 key
	X25519 string `json:"x25519"`
	// Bind ties the key to one use ("" for a long-lived key).
	Bind string `json:"bind"`
	Sig  string `json:"sig,omitempty"`
}

KeyCert is an X25519 public key signed by an Ed25519 key: a lock's sealing key (ContextKeyCert), or a facade's ephemeral key for one reveal (ContextReveal, Bind = the ticket id). Whoever seals to it checks the signature under the Ed25519 key they pinned: a hub cannot substitute its own key.

func CertifyX25519

func CertifyX25519(key ed25519.PrivateKey, c Context, pub *ecdh.PublicKey, bind string) (*KeyCert, error)

CertifyX25519 signs pub with key under context c (ContextKeyCert or ContextReveal), bound to bind.

type Outcome

type Outcome string

Outcome is the closed set of decisions.

const (
	Approved Outcome = "approved"
	Denied   Outcome = "denied"
)

func (*Outcome) UnmarshalJSON

func (o *Outcome) UnmarshalJSON(b []byte) error

UnmarshalJSON refuses an outcome other than Approved and Denied.

type PII

type PII struct {
	// Touched names the kinds of personal data involved ("email").
	Touched []string `json:"touched"`
	// ClearSealed is the unmasked screen sealed to one facade's device
	// key, for a facade able to show it; nil otherwise.
	ClearSealed *Sealed `json:"clear_sealed"`
}

PII says what personal data the request touches.

type Requester

type Requester struct {
	Agent   string `json:"agent"`
	Session string `json:"session"`
}

Requester is the agent that asked, as the agent says: a label for display, never authenticated.

type Risk

type Risk string

Risk is the closed set of risk classes. Each product maps its operations onto one of them; the hub routes and expires tickets by risk alone, so a product cannot invent a class a facade does not know.

const (
	RiskRead         Risk = "read"
	RiskReadPII      Risk = "read-pii"
	RiskWrite        Risk = "write"
	RiskProdWrite    Risk = "prod-write"
	RiskPolicyLoosen Risk = "policy-loosen"
	RiskUnlock       Risk = "unlock"
)

func ParseRisk

func ParseRisk(s string) (Risk, error)

ParseRisk returns the risk class named s; any other name is an error.

func (Risk) TTL

func (r Risk) TTL() time.Duration

TTL is how long a ticket of class r waits for a decision, and how long an approval stays usable once given; 0 means no expiry. Reads never expire; writes and policy loosening last 24 h; production writes and unlock (a secret typed for the lock) 4 h.

func (*Risk) UnmarshalJSON

func (r *Risk) UnmarshalJSON(b []byte) error

UnmarshalJSON refuses a risk class outside Risks.

func (Risk) Valid

func (r Risk) Valid() bool

Valid reports whether r is one of Risks.

type Screen

type Screen struct {
	// Header names the request ("db / shop · tier read").
	Header string `json:"header"`
	// Note is the agent's one-line reason (untrusted).
	Note string `json:"note"`
	// Body are the lines of the request; Warnings are shown in red.
	Body     []string `json:"body"`
	Warnings []string `json:"warnings"`
	// Red frames the screen in red outside production too.
	Red bool `json:"red"`
}

Screen is the approval screen, masked by the lock before it leaves the lock (the render package builds it). It mirrors the console's screen.

type Sealed

type Sealed struct {
	Alg   string `json:"alg"`
	EPK   string `json:"epk"`   // ephemeral X25519 public key
	Nonce string `json:"nonce"` // 24 bytes
	CT    string `json:"ct"`    // ciphertext and tag
}

Sealed is a sealed box: what only the holder of one X25519 private key can read, relayed by a hub that cannot (a secret for unlock, a clear screen for a facade's device, a reveal).

func Seal

func Seal(to *ecdh.PublicKey, plaintext, aad []byte) (*Sealed, error)

Seal encrypts plaintext to the X25519 key to. aad is bound to the box and must be given again to Open (a ticket id, a context): a box sealed for one ticket does not open as another's.

type Source

type Source struct {
	// Lock is the product: "locksql", "lockfile", ...
	Lock string `json:"lock"`
	// Instance names the lock instance ("crm-prod"); decisions are bound
	// to it.
	Instance string `json:"instance"`
	// KeyID is KeyID of the Ed25519 key that signs the ticket.
	KeyID string `json:"key_id"`
}

Source is the lock that issued a ticket.

type Ticket

type Ticket struct {
	V         int       `json:"v"`
	ID        string    `json:"id"` // NewID
	Source    Source    `json:"source"`
	Workspace string    `json:"workspace"`
	Requester Requester `json:"requester"`
	Risk      Risk      `json:"risk"`
	Flags     Flags     `json:"flags"`
	Screen    Screen    `json:"screen"`
	PII       PII       `json:"pii"`
	// PlanHash binds the ticket to the plan it asks for (HashOf).
	PlanHash string `json:"plan_hash"`
	// PolicyFP is the fingerprint of the policy the plan was made under.
	PolicyFP string    `json:"policy_fp"`
	Created  time.Time `json:"created"`
	// Expires is when a pending ticket expires; nil never.
	Expires *time.Time `json:"expires"`
	Sig     string     `json:"sig,omitempty"`
}

Ticket is a request for a human decision, signed by the lock.

func NewTicket

func NewTicket(src Source, risk Risk, planHash, policyFP string, now time.Time, ttl time.Duration) *Ticket

NewTicket is a ticket of the current Version with a fresh ID, created now, expiring after ttl (0: never; see ClampTTL).

func VerifyTicket

func VerifyTicket(pub ed25519.PublicKey, data []byte) (*Ticket, error)

VerifyTicket decodes a ticket and checks it: schema, then the signature under pub, the lock key the caller pinned for t.Source.Instance. A ticket naming another key id is refused.

func (*Ticket) Expired

func (t *Ticket) Expired(now time.Time) bool

Expired reports whether pending ticket t has expired at now.

func (*Ticket) Marshal

func (t *Ticket) Marshal() ([]byte, error)

Marshal is the canonical JSON of a signed ticket, as it is sent.

func (*Ticket) Sign

func (t *Ticket) Sign(key ed25519.PrivateKey) error

Sign validates t, sets t.Source.KeyID to the key's id when empty (a different one is an error), and signs it.

func (*Ticket) Validate

func (t *Ticket) Validate() error

Validate checks t's schema: version, id, source, risk, hashes, times, sizes. It does not check the signature.

Jump to

Keyboard shortcuts

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