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
- Variables
- func Canonical(v any) ([]byte, error)
- func CanonicalizeJSON(b []byte) ([]byte, error)
- func ClampTTL(r Risk, configured time.Duration) time.Duration
- func HashOf(v any) (string, error)
- func KeyID(pub ed25519.PublicKey) string
- func NewID(now time.Time) string
- func NewNonce() string
- func Open(priv *ecdh.PrivateKey, s *Sealed, aad []byte) ([]byte, error)
- func RequestMessage(method, pathQuery, instance string, at time.Time, nonce string, body []byte) []byte
- func SignBytes(key ed25519.PrivateKey, c Context, msg []byte) string
- func ValidID(s string) bool
- func VerifyBytes(pub ed25519.PublicKey, c Context, msg []byte, sig string) error
- func VerifyKeyCert(signer ed25519.PublicKey, c Context, bind string, data []byte) (*ecdh.PublicKey, error)
- type Approver
- type Context
- type Decision
- type Flags
- type KeyCert
- type Outcome
- type PII
- type Requester
- type Risk
- type Screen
- type Sealed
- type Source
- type Ticket
Constants ¶
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.
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 ¶
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.
var ErrDenied = errors.New("ticket: not approved")
ErrDenied: the decision is not an approval.
var ErrExpired = errors.New("ticket: expired")
ErrExpired: the ticket or the approval is past its TTL.
var ErrInvalid = errors.New("ticket: invalid")
ErrInvalid is wrapped by every validation error.
var ErrMismatch = errors.New("ticket: decision does not match the ticket")
ErrMismatch: a verified decision is not for this ticket.
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.
var ErrOpen = errors.New("ticket: sealed box does not open")
ErrOpen: a sealed box does not open with this key and associated data.
var Risks = []Risk{RiskRead, RiskReadPII, RiskWrite, RiskProdWrite, RiskPolicyLoosen, RiskUnlock}
Risks lists every risk class, from the least to the most sensitive.
Functions ¶
func Canonical ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
ValidID reports whether s is a ULID in canonical form: 26 characters of the upper-case alphabet, the first at most '7'.
func VerifyBytes ¶
VerifyBytes checks sig, as SignBytes returns it, over msg under c.
Types ¶
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 ¶
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) Match ¶
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 ¶
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.
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.
func (*Outcome) UnmarshalJSON ¶
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 ¶
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.
func (Risk) TTL ¶
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 ¶
UnmarshalJSON refuses a risk class outside 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).
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 ¶
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.