entitlement

package
v0.58.21 Latest Latest
Warning

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

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 2 Imported by: 0

README

entitlement

import "github.com/kitsunium/sdk/framework/internal/core/entitlement"

Package entitlement — the error-code range this domain owns (framework/connectors/ssh block).

Package entitlement is the contract for machine entitlement: turning local key material plus a vendor-signed roster into a grant, or a typed refusal.

The trust model has exactly one SIGNER: the vendor. What a consuming binary links in is that signer's ed25519 public key — or, while a signing key is being rotated, an ordered list of the keys it will accept, which is a property of the verifier and lives in framework/internal/service/entitlement. Several accepted keys are still one signer: authentication establishes that the vendor issued these bytes and nothing more, so no quorum is being taken. Everything else — the roster, the per-subject keys, the host serving them — is untrusted input. A hostile endpoint can serve whatever it likes; it cannot forge a signature made with the vendor's private half.

What that anchor cannot do is survive a patched binary. A check running on someone else's machine is removable by definition. This domain stops casual sharing and makes revocation real for cooperative installs; it is not a wall against a determined attacker, and is deliberately not built as if it were.

Why Identity is a port

Proving possession needs key material the user already has, in a format the standard library cannot parse — OpenSSH, via golang.org/x/crypto/ssh, which brings golang.org/x/sys and is banned SDK-wide (ADR 0078). Naming the capability as a port keeps the MECHANISM here, stdlib-only, and puts the one implementation that needs a vendor dependency where a vendor dependency is allowed: the framework's connector module framework/connectors/ssh, which a consumer opts into (ADR 0158).

A consumer that does not want that dependency supplies its own Identity. That is not a theoretical escape hatch — the product this was versed from does exactly that, keeping its 327 lines of SSH handling rather than pulling the connector's graph for them.

There is **no registry**. A registry's key would name an identity scheme, and the whole trust chain is anchored to one vendor for one product.

Package entitlement — the sentinels every implementation of this domain returns.

Each names a distinct operator situation rather than a generic failure, because callers map them to exit codes and an operator acts on the difference: a revoked subject, an expired window and an unreachable roster need three different responses.

Package entitlement - the record of a verification that succeeded. A daemon ages against it: memory of a past check must not outlive the roster window, otherwise revocation would never reach a long-running process.

Package entitlement - where the roster is fetched from. Authority comes from the vendor signature, never from the origin that served the bytes, so the binary is free to ask several places and keep whichever answers.

Package entitlement - the signed roster: the only statement the binary trusts, and only because the vendor signed it. Origin is irrelevant here; a substituted endpoint can serve any bytes but cannot forge the signature.

Index

Constants

const CodeAmbiguousLicence errs.Code = 140040

CodeAmbiguousLicence identifies more than one entitlement identity on one machine, which no automatic choice can resolve safely.

Code 0.2.35.8

const CodeCINotEntitled errs.Code = 140043

CodeCINotEntitled identifies a CI account the roster does not cover.

Code 0.2.35.11

const CodeCIUnknownKey errs.Code = 140042

CodeCIUnknownKey identifies a CI token signed by a key the issuer does not publish.

Code 0.2.35.10

const CodeCIUnverifiable errs.Code = 140041

CodeCIUnverifiable identifies a CI run whose provenance could not be proven.

Code 0.2.35.9

const CodeClockRegressed errs.Code = 140045

CodeClockRegressed identifies a machine whose clock is behind the last signed document it verified — the anti-rollback ratchet.

Code 0.2.35.13

const CodeKeyMismatch errs.Code = 140038

CodeKeyMismatch identifies a local key that does not match the fingerprint the roster publishes for this subject.

Code 0.2.35.6

const CodeLicenceExpired errs.Code = 140037

CodeLicenceExpired identifies a subject whose own validity window has closed.

Code 0.2.35.5

const CodeNoLicence errs.Code = 140033

CodeNoLicence identifies a machine with no entitlement key at all.

Code 0.2.35.1

const CodeNoPossession errs.Code = 140044

CodeNoPossession identifies a holder who could not prove possession of the private half of the published key.

Code 0.2.35.12

const CodeProductInvalid errs.Code = 140047

CodeProductInvalid identifies a ProductValue whose origin list could not survive the day it is needed — see ProductValue.Validate.

Code 0.2.35.15

const CodeRevoked errs.Code = 140036

CodeRevoked identifies a subject absent from a roster that was itself valid.

Code 0.2.35.4

const CodeRosterStale errs.Code = 140035

CodeRosterStale identifies a roster that verified but whose validity window has closed.

Code 0.2.35.3

const CodeRosterUnreachable errs.Code = 140039

CodeRosterUnreachable identifies a fetch that could not decide either way. It reports "cannot decide", never "no" — the distinction is the whole point.

Code 0.2.35.7

const CodeRosterUnsigned errs.Code = 140034

CodeRosterUnsigned identifies a roster carrying no valid vendor signature. It is the spoofing signal: a substituted endpoint cannot produce one.

Code 0.2.35.2

const CodeUpdateRequired errs.Code = 140046

CodeUpdateRequired identifies a build below the floor the roster mandates.

Code 0.2.35.14

const RosterLifetime time.Duration = 86400000000000

RosterLifetime bounds how long a verified grant survives without a fresh fetch. It is the single dial of the whole scheme: it caps both how long a revoked subject keeps working and how long a hostile endpoint can replay a genuine roster.

Variables

var ErrAmbiguousLicense *errs.Error

ErrAmbiguousLicense reports that the key directory holds more than one usable licence identity, so which one this machine presents cannot be decided here. It is deliberately distinct from ErrNoLicense: the machine is enrolled, possibly several times over, and the fix is to remove the identities that do not belong rather than to enrol.

Guessing was the previous behaviour — the lexicographically first UUID won — which let a stray .pub decide which licence got verified, which one a rotation overwrote, and which one `license status` reported.

Code 0.2.35.8 · reason AMBIGUOUS_LICENCE · public "more than one entitlement identity is present on this machine"

var ErrCINotEntitled *errs.Error

ErrCINotEntitled reports that the CI run authenticated, but the account it belongs to is not one the roster covers. The token is genuine; the licence simply does not extend to it.

Code 0.2.35.11 · reason CI_NOT_ENTITLED · public "this CI account is not covered by an entitlement"

var ErrCIUnknownKey *errs.Error

ErrCIUnknownKey reports that the token names a signing key the fetched key set does not publish — a key rotated out from under a token still in flight, or a forgery. Distinct from ErrCIUnverifiable because the two send an operator to different places: this one says the issuer's key set and the token disagree, which is the issuer's side of the exchange.

It used to promise a remedy the client applies itself — "refresh the key set once, since GitHub rotates keys, then refuse if it still does not appear". No implementation does that and none is expected to: the verifier holds no key-set cache, so it already fetches the published set fresh on every verification and a rotation is picked up by the next one. A second fetch inside one verification would re-read a URL it read seconds earlier, which cannot make an endpoint publish a kid it does not publish. See the service's publishedJWKS for the argument in full; a sentinel must not advertise a mechanism nobody implements.

Code 0.2.35.10 · reason CI_UNKNOWN_KEY · public "the CI token is signed by a key the issuer does not publish"

var ErrCIUnverifiable *errs.Error

ErrCIUnverifiable reports that a claim of running inside CI could not be authenticated: no token, a malformed one, a bad signature, or one minted for someone else. It never means "not in CI" — it means the claim was not provable, and the caller falls back to the ordinary device path rather than granting a free seat.

Code 0.2.35.9 · reason CI_UNVERIFIABLE · public "this run could not be proven to be a CI run"

var ErrClockRegressed *errs.Error

ErrClockRegressed reports that this machine's clock reads EARLIER than the newest vendor-signed instant it has ever authenticated.

It is the one statement about time this package can make without the network, and it is worth making because the alternative is to believe a clock the holder controls. A roster is signed evidence that time was at least its IssuedAt; a clock that has since moved backwards past that point is either broken or being moved, and both are the holder's to fix.

What it does NOT catch is a FROZEN clock: parked exactly on the mark, nothing local can tell that time has passed. Nor does it survive deletion — the mark lives on a disk its holder owns. It bounds the careless case and the casual one, which is this package's whole remit.

Code 0.2.35.13 · reason CLOCK_REGRESSED · public "this machine's clock is behind the last signed document it verified"

var ErrKeyMismatch *errs.Error

ErrKeyMismatch reports that the local key does not match the fingerprint the roster records for this UUID, which is what a copied .pub file looks like: the public half is published, so possession of it proves nothing.

Code 0.2.35.6 · reason KEY_MISMATCH · public "the local key does not match the published fingerprint"

var ErrLicenseExpired *errs.Error

ErrLicenseExpired reports that the subject's own validity window has closed, even though the roster itself is fresh and still lists them. Distinct from ErrRosterStale (the whole roster's signing freshness) and from ErrRevoked (removed from the roster entirely): renewal, not rotation or re-enrolment, is what resolves this one.

Code 0.2.35.5 · reason LICENCE_EXPIRED · public "this entitlement has expired"

var ErrNoLicense *errs.Error

ErrNoLicense reports that no licence key was found on this machine. The caller has never enrolled, or the key was removed.

Code 0.2.35.1 · reason NO_LICENCE · public "no entitlement key was found on this machine"

var ErrNoPossession *errs.Error

ErrNoPossession reports that the private half could not be exercised, so holding the public key was never turned into proof of ownership.

Code 0.2.35.12 · reason NO_POSSESSION · public "possession of the private key could not be proven"

var ErrRevoked *errs.Error

ErrRevoked reports that the licence UUID is absent from a roster that was itself valid — the subject was removed upstream.

Code 0.2.35.4 · reason REVOKED · public "this entitlement has been revoked"

var ErrRosterStale *errs.Error

ErrRosterStale reports that the roster verified but no longer authorises anything. It bounds how long a revoked client keeps working offline, and how long a hostile endpoint can replay a genuine roster.

Those are the same bound, and cache.go is what finally made the first half of that sentence describe something: an offline client re-reads the last bundle it authenticated and is refused HERE the moment that bundle's window closes — at most RosterLifetime after it was signed, whoever is replaying it and from wherever.

TWO conditions reach it, and the second is the other half of that replay bound rather than a separate situation. A window that has CLOSED is the absolute one. A roster SUPERSEDED by a newer signed decision this machine has already accepted is the relative one: its own window may be wide open, and it is still the replay of a statement the vendor has since replaced. Only the `condition` field says which, because the resolving action does not differ — publish, or fetch, a current roster.

Code 0.2.35.3 · reason ROSTER_STALE · public "the roster has expired"

var ErrRosterUnreachable *errs.Error

ErrRosterUnreachable reports that no origin could be reached AND no usable bundle was cached, so no authorization decision could be made at all. It is deliberately distinct from a refusal: the caller knows nothing, rather than knowing the answer is no.

It names the NETWORK even when the cache is what failed last. The cache is a fallback nobody configured and most operators have never heard of; pointing them at it would send them to fix the wrong thing, when what happened is that every publication point was unreachable.

Code 0.2.35.7 · reason ROSTER_UNREACHABLE · public "the roster could not be reached"

var ErrRosterUnsigned *errs.Error

ErrRosterUnsigned reports that the roster carried no valid vendor signature. This is the spoofing signal: a substituted endpoint cannot produce one.

Code 0.2.35.2 · reason ROSTER_UNSIGNED · public "the roster is not signed by the expected vendor"

var ErrUpdateRequired *errs.Error

ErrUpdateRequired reports that the roster demands a newer binary than this one. It is NOT a licence failure — the subject is perfectly entitled — so it carries its own sentinel and its own exit code: the resolving action is an upgrade, and telling somebody their licence is broken when it is not would send them to the wrong place entirely.

Code 0.2.35.14 · reason UPDATE_REQUIRED · public "a newer version is required"

func GrantDeadline

func GrantDeadline(verifiedAt time.Time, bounds ...time.Time) time.Time

GrantDeadline computes NotAfter from the verification's own lifetime and however many further bounds the caller can name.

A zero expiry means "not recorded" throughout this package — an absent subject term, or a roster field a predecessor never wrote — and must never be read as "already expired". Zero values are therefore skipped rather than minimised over: otherwise the earliest bound would always be the zero time and every grant would be born dead.

Why the bounds are variadic

Because "a grant may not outlive the document that authorised it" is a rule about however many documents there were, and the CI path has three. It was written as two fixed parameters — the roster's window and the subject's term — which are exactly the two a DEVICE grant rests on. A CI seat rests on a third: the Actions token that proved the run is real, whose own expiry GitHub sets minutes out. ciseat.go therefore bounded the seat by the roster and the account's term and by nothing else, so a grant established by a 30-minute proof survived it by up to a day — the invariant NotAfter's own comment states, contradicted by the one caller that had a document the signature said the least about.

Variadic rather than a fourth parameter so the two existing call sites compile unchanged and read unchanged; a caller with nothing extra to name passes nothing extra.

No skew here, deliberately

checkTiming allows clockSkew when ADMITTING an Actions token, which is the permissive direction and the right one there: refusing a genuine token over two minutes of drift would break a working runner. Adding the same allowance to a BOUND would run the opposite way — it would extend the grant past the proof — so the bound is the claim as written.

type BoundProver

BoundProver is Identity's sibling for the one claim the three methods cannot make: possession of the key the ROSTER authorised, rather than of whatever this machine holds at the moment it is asked.

The gap it closes

The verification engine asks Fingerprint what this machine presents, compares the answer ITSELF against the roster's entry, and then asks ProvePossession for a proof. The authorised fingerprint never traverses the port, so an implementation cannot bind the proof to it: between the two calls the material may be replaced — a rotation, a mounted volume remounted, a deliberate swap — and the implementation signs with what it finds on the second call. A consumer can DETECT that by remembering the key object across the two calls and refusing a proof with nothing remembered, which is as far as the three methods reach; it cannot be told which key it was supposed to be holding.

Why a sibling and not a fourth parameter

Identity is reachable through a pkg/v1 type alias, so ADR 0039's rule binds and ADR 0040 §4 says the version does not matter: a published interface is extended by a sibling, never by widening, because Go satisfies interfaces STRUCTURALLY and every downstream implementation breaks at compile time with no deprecation window. Widening ProvePossession would be worse than adding a method — it invalidates a method implementers have already written — and it would not even buy the guarantee that tempts it: an implementation can satisfy a two-parameter signature and ignore the second argument, so binding is the implementation's choice under either shape. The sibling buys the same real property and breaks nobody.

What its ABSENCE means, stated rather than left to be discovered

An Identity that does not implement this keeps the three-method behaviour exactly: the engine falls back to ProvePossession and the window between the two calls stays open. That is not a mechanism this package can close from here, and pretending otherwise would be the claim that outruns the code.

type BoundProver interface {
	// ProvePossessionFor demonstrates that this machine holds the private half
	// of the key whose PUBLISHED fingerprint is authorised — the value the
	// roster records for this subject, in the roster's own spelling.
	//
	// A nil error is the proof, for the same reason it is on ProvePossession.
	// An implementation that finds it holds different material refuses with
	// ErrKeyMismatch: that is what "a local key that does not match the
	// published fingerprint" means, so the refusal taxonomy does not grow.
	//
	// authorised is compared however the implementation compares fingerprints —
	// byte equality, in the roster's spelling, is what Fingerprint's own
	// contract already requires.
	ProvePossessionFor(subject string, authorised string) error
}

func (BoundProver) ProvePossessionFor
func (BoundProver) ProvePossessionFor(subject string, authorised string) error

ProvePossessionFor demonstrates that this machine holds the private half of the key whose PUBLISHED fingerprint is authorised — the value the roster records for this subject, in the roster's own spelling.

A nil error is the proof, for the same reason it is on ProvePossession. An implementation that finds it holds different material refuses with ErrKeyMismatch: that is what "a local key that does not match the published fingerprint" means, so the refusal taxonomy does not grow.

authorised is compared however the implementation compares fingerprints — byte equality, in the roster's spelling, is what Fingerprint's own contract already requires.

type CIEntitlementValue

CIEntitlementValue is what the roster grants one account's CI.

type CIEntitlementValue struct {
	// ExpiresAt closes the entitlement, and is the licence's own term so CI
	// stops exactly when the devices do. The zero value means no term was
	// recorded, treated as no expiry rather than an instantly-closed one —
	// the same rule SubjectValue follows, for the same reason.
	ExpiresAt time.Time `json:"exp"`
}

type GrantValue

GrantValue records a verification that succeeded, and how long that verification may be remembered.

The timestamp alone is not enough to age against, which is the defect this type carried until NotAfter existed. A grant used to expire at VerifiedAt+RosterLifetime and nothing else, so a daemon that happened to verify late in a roster's window kept serving for a further 24 hours after that window closed — up to 47 hours after the vendor last signed anything, in a scheme whose single dial is a 24-hour bound. It outlived the subject's OWN term too: a licence expiring at noon left a daemon that verified at 11:59 serving until the following day.

type GrantValue struct {
	// Subject is the client UUID that was verified.
	Subject    string
	// VerifiedAt is when the roster was last successfully authenticated.
	VerifiedAt time.Time
	// NotAfter is the instant this grant stops authorising anything: the
	// EARLIEST of the verification's own lifetime and every document the
	// verification rested on — the roster's expiry, the subject's term, and
	// for a CI seat the expiry of the Actions token that proved the run.
	// Each bounds something different, so the tightest is the only correct
	// answer — a grant may not outlive the document that authorised it.
	//
	// The zero value means "no deadline was computed", which is how a grant
	// seeded from a bare timestamp behaves: serve.go does exactly that at
	// start-up, from the root gate's verification, with no roster in scope.
	// The fallback is the verification's own lifetime rather than reading a
	// zero deadline as already-past, which would refuse a daemon at the very
	// instant it starts.
	//
	// Which is why a caller that wants to ACT on the deadline should read
	// Deadline() and not this field: the method resolves that fallback and the
	// field cannot. Honouring either is the consumer's job — nothing in this
	// SDK revokes anything when the instant passes. See Expired.
	NotAfter   time.Time
	// Offline records that no origin answered and the decision rested on the
	// last bundle this machine authenticated.
	//
	// It changes nothing about the grant's authority: an offline roster
	// passed the identical signature and window checks, and NotAfter above
	// already bounds it by that roster's own expiry. It exists so the fact can
	// be SAID. A binary quietly running on a copy of a document it can no
	// longer fetch is the one state where "it worked" and "it is still true"
	// come apart, and the operator is the only party who can act on that.
	Offline    bool
}

func (GrantValue) Deadline
func (*GrantValue) Deadline() time.Time

Deadline reports the instant this grant stops authorising anything — the EFFECTIVE one, which is not always the NotAfter field.

Why the field is not enough

NotAfter's zero value means "no deadline was computed", and Expired then ages the grant against VerifiedAt+RosterLifetime instead. That fallback lived inside Expired and nowhere else, so a caller reading the FIELD to schedule its next check read the zero instant and had no way to derive the deadline the SDK would actually apply. Its only remaining option was to poll — which is precisely how a grant that expires a second after a check keeps authorising until the next tick, whatever the tick is.

With the effective instant readable, a consumer can wake AT the deadline rather than sometime after it: arm a timer on Deadline(), or pass it to a context. That is the SDK's whole contribution to the problem, and the rest is stated in Expired's own comment.

Expired is defined in terms of this method rather than repeating the fallback, because two copies of one rule is how a field and a method come to disagree about when a grant died.

A POINTER receiver for the same reason Expired has one — see there.

func (GrantValue) Expired
func (*GrantValue) Expired(now time.Time) bool

Expired reports whether a grant may no longer be relied on. A daemon that cannot refresh must stop trusting its own memory eventually, otherwise revocation would never reach it.

Honouring the deadline is the CONSUMER's job, and nothing here enforces it

This is a value type. Verify computes a grant, hands it back and keeps no reference to it: there is no goroutine, no timer and no callback anywhere in this SDK that revokes anything when Deadline passes, and a process holding an expired grant is not interrupted. Whatever gates work on a grant has to ask.

So a grant expiring a second after a check keeps authorising until the consumer looks again, and how long that is, is the consumer's tick — not a property of this domain. What the domain owes is a deadline that can be acted on WITHOUT polling, which is what Deadline is for: schedule on it, and the question is asked when the answer changes rather than on a cadence.

Said here rather than only in an ADR, because a consumer reading this method is exactly the reader who might otherwise assume something upstream is watching the clock for them.

A POINTER receiver on a read-only method, which is unusual and deliberate: the struct outgrew KTN-VAR-BIGSTRUCT's by-value threshold when Offline was added, and every caller passes this as a METHOD VALUE (`licenseStillValidFrom(svc, refusal, grant.Expired)`) that would otherwise copy the whole struct into the closure on every watchdog tick.

It is a method-set change and therefore a source-level API change, named here rather than left to be discovered: Expired is no longer in GrantValue's VALUE method set, so a NON-ADDRESSABLE grant — a map entry, a composite literal used inline — stops compiling against it. Every caller in this repository holds an addressable grant, and pkg/license has no consumer outside the module, which is what makes the trade payable; a released SDK would not.

type Identity

Identity is the machine's half of the proof: which subject this machine claims to be, and evidence that it holds the private key the roster publishes a fingerprint for.

It is FROZEN at three methods, and the split is what makes the refusals distinguishable. Discover answers "who are we", Fingerprint answers "what do we present", ProvePossession answers "can we back it up" — and a caller that fails the third after passing the second has key material it cannot use, which is a different operator situation from having none at all.

Frozen means frozen: it is reachable through a pkg/v1 type alias, so ADR 0039 binds and ADR 0040 §4 removes the v0 licence from it — the version does not matter, because structural satisfaction breaks downstream implementations at any version. It is extended by a SIBLING, and BoundProver below is the first one.

type Identity interface {
	// Discover returns the subject identifier this machine is enrolled as.
	//
	// It refuses rather than choosing when more than one identity is present:
	// picking one silently would make revocation unverifiable, since the
	// operator could not tell which identity was checked.
	Discover() (subject string, err error)
	// Fingerprint returns the published fingerprint of the subject's public
	// key, in whatever spelling the roster uses. It is compared by byte
	// equality against the roster's entry and never parsed.
	Fingerprint(subject string) (fingerprint string, err error)
	// ProvePossession demonstrates that this machine holds the private half of
	// the key whose fingerprint Fingerprint returned. A nil error is the proof;
	// there is deliberately no value to inspect, because anything returned here
	// would be a second thing to verify.
	//
	// It proves possession of whatever material this implementation holds NOW,
	// which is not the same claim as "possession of the key the roster
	// authorised" — nothing the roster said reaches this method. BoundProver is
	// the sibling that carries the missing half; an implementation that can make
	// the stronger claim implements it, and the engine prefers it when present.
	ProvePossession(subject string) error
}

func (Identity) Discover
func (Identity) Discover() (subject string, err error)

Discover returns the subject identifier this machine is enrolled as.

It refuses rather than choosing when more than one identity is present: picking one silently would make revocation unverifiable, since the operator could not tell which identity was checked.

func (Identity) Fingerprint
func (Identity) Fingerprint(subject string) (fingerprint string, err error)

Fingerprint returns the published fingerprint of the subject's public key, in whatever spelling the roster uses. It is compared by byte equality against the roster's entry and never parsed.

func (Identity) ProvePossession
func (Identity) ProvePossession(subject string) error

ProvePossession demonstrates that this machine holds the private half of the key whose fingerprint Fingerprint returned. A nil error is the proof; there is deliberately no value to inspect, because anything returned here would be a second thing to verify.

It proves possession of whatever material this implementation holds NOW, which is not the same claim as "possession of the key the roster authorised" — nothing the roster said reaches this method. BoundProver is the sibling that carries the missing half; an implementation that can make the stronger claim implements it, and the engine prefers it when present.

type OriginValue

OriginValue is one publication point for the roster pair.

It carries no trust of its own: the vendor signature over the bytes is what authorises anything, so an origin is only ever an availability choice. That is precisely why the list can be extended freely — a hostile entry gains nothing, and a dead one is skipped.

type OriginValue struct {
	// Name identifies the origin in diagnostics. It is not part of the
	// trust decision — nothing here is — but an operator staring at a
	// refusal needs to know which endpoints were tried.
	Name      string
	// BundleURL serves the signed bundle: roster and signature in one
	// document. ONE url, not two, because two objects cannot be fetched
	// atomically — see BundleValue for the five-minute cache window that made
	// a correct client refuse a correct roster.
	BundleURL string
}

type RosterValue

RosterValue is the signed statement of who may run the linter. It carries no secret: every field is publishable, which is why it can live in a public repository. Authority comes from the detached signature, never from the origin that served the bytes.

type RosterValue struct {
	// IssuedAt is when the vendor signed this roster.
	IssuedAt        time.Time                     `json:"iat"`
	// ExpiresAt closes the roster's own signing-freshness window. Past it
	// the roster is refused even though its signature still verifies. This
	// bounds replay and revocation latency for the roster as a whole; it is
	// independent of any one subject's ExpiresAt below.
	ExpiresAt       time.Time                     `json:"exp"`
	// Subjects maps a client UUID to what the roster records for it.
	Subjects        map[string]SubjectValue       `json:"subjects"`
	// RequiredVersion is the lowest binary version allowed to run, as a
	// SemVer tag ("v1.5.14"). Empty means no floor, which is how a roster
	// published before this field existed behaves — absence must not lock
	// out every client that predates it.
	//
	// It lives HERE, in the signed roster, rather than being read from a
	// release API, and that placement is the whole design. The roster is
	// already fetched on every cold start and already authenticated against
	// the compiled-in anchor, so a mandatory update inherits both properties
	// for free: it cannot be skipped by staying offline (the fetch is
	// mandatory), and it cannot be forged by redirecting the endpoint (the
	// signature is checked first). A version read from an unauthenticated
	// API would be defeated by one line in /etc/hosts.
	RequiredVersion string                        `json:"minv,omitempty"`
	// CIAccounts maps a GitHub account's NUMERIC id to what the roster grants
	// its CI runs. A run inside that account's Actions may be authorised
	// without spending one of the licence's device seats.
	//
	// Keyed by id and never by login: a login can be renamed, and a released
	// one can be claimed by somebody else, so matching on the name would turn
	// a freed handle into a way in.
	//
	// Absent for a roster that grants no CI entitlement, which is also how
	// every roster published before this field existed behaves — absence must
	// not be read as anything but "no CI seat".
	CIAccounts      map[string]CIEntitlementValue `json:"ci,omitempty"`
	// CIRelaxed restores the historical fall-through: inside GitHub Actions a
	// failed CI seat stops being a refusal and the device path answers next.
	//
	// The DEFAULT is the strict direction, which is the inversion this field
	// records. A CI seat used to be tried first and every failure swallowed,
	// so a device key dropped onto a runner bought unlimited CI without any CI
	// entitlement — the one hole a licence scheme aimed at CI cannot leave
	// open. Requiring the seat is now what happens unless this says otherwise.
	//
	// It lives in the signed roster, and that placement is the whole design.
	// "A run in Actions need not prove it is one" is a statement only the
	// vendor is entitled to make, and it has to travel somewhere the party
	// being checked cannot rewrite — a flag or an environment variable would
	// be set by that very party. Note the direction: this field can only
	// RELAX, so forging it is the one thing an attacker would want and the one
	// thing the signature prevents; absence, which is what any tampering
	// produces, is the strict reading.
	//
	// The strictness applies only when InCI reports both runner variables
	// present. A laptop never had a seat to prove, and refusing one would be a
	// far worse defect than the hole this closes.
	CIRelaxed       bool                          `json:"cilax,omitempty"`
}

func (RosterValue) CIEntitlementFor
func (*RosterValue) CIEntitlementFor(accountID string, now time.Time) (entitlement CIEntitlementValue, err error)

CIEntitlementFor returns what the roster grants an account's CI runs.

Absence is not an error worth distinguishing from a refusal here: either way the caller falls back to the device path, which is what an unentitled CI run should do.

All four refusals render the SAME sentence, which is deliberate: a refusal naming the account it refused hands the party being kept out a way to read the roster one probe at a time. Which account, and which deadline, travel as FIELDS — errs.FieldsOf reaches them, err.Error() does not — and the condition field is what keeps "no roster at all" distinguishable from "this account has no seat" for the operator who is entitled to the difference.

func (RosterValue) SubjectFor
func (*RosterValue) SubjectFor(uuid string) (subject SubjectValue, err error)

SubjectFor returns the roster's record for a subject. A missing entry reports ErrRevoked, but the roster is a stateless snapshot with no history: absence is exactly how a withdrawn licence looks, and it is indistinguishable here from a subject whose enrolment was never approved in the first place. Callers surfacing this to a human must name both possibilities rather than assert the more alarming one as fact.

The refused subject travels as a FIELD and not in the sentence. A revocation that quotes back the uuid it refused confirms that uuid to whoever presented it, which is a thing a refusal has no business doing; errs.FieldsOf still hands it to the operator's log.

It is total on a nil receiver, which its sibling CIEntitlementFor has always been. Reachable through the Roster alias in framework/entitlement, where a consumer holding a nil roster got a panic from one method and a refusal from the other.

type SubjectValue

SubjectValue is one enrolled client's entry: the fingerprint that proves which key is theirs, and the date past which their own entitlement closes regardless of how fresh the roster itself is.

type SubjectValue struct {
	// Fingerprint is the SHA256 fingerprint of the subject's public key.
	// Recording the fingerprint rather than the key itself keeps the roster
	// small and makes the comparison exact.
	Fingerprint string    `json:"fp"`
	// ExpiresAt closes this subject's individual term. The zero value means
	// no term was recorded, which is treated as no expiry rather than an
	// instantly-closed one — a subject published before this field existed
	// must not be locked out by its mere absence.
	ExpiresAt   time.Time `json:"exp"`
}

Generated from docs/api by tools/genindex.

Documentation

Overview

Package entitlement — the error-code range this domain owns (framework/connectors/ssh block).

Package entitlement is the contract for machine entitlement: turning local key material plus a vendor-signed roster into a grant, or a typed refusal.

The trust model has exactly one SIGNER: the vendor. What a consuming binary links in is that signer's ed25519 public key — or, while a signing key is being rotated, an ordered list of the keys it will accept, which is a property of the verifier and lives in framework/internal/service/entitlement. Several accepted keys are still one signer: authentication establishes that the vendor issued these bytes and nothing more, so no quorum is being taken. Everything else — the roster, the per-subject keys, the host serving them — is untrusted input. A hostile endpoint can serve whatever it likes; it cannot forge a signature made with the vendor's private half.

What that anchor cannot do is survive a patched binary. A check running on someone else's machine is removable by definition. This domain stops casual sharing and makes revocation real for cooperative installs; it is not a wall against a determined attacker, and is deliberately not built as if it were.

Why Identity is a port

Proving possession needs key material the user already has, in a format the standard library cannot parse — OpenSSH, via golang.org/x/crypto/ssh, which brings golang.org/x/sys and is banned SDK-wide (ADR 0078). Naming the capability as a port keeps the MECHANISM here, stdlib-only, and puts the one implementation that needs a vendor dependency where a vendor dependency is allowed: the framework's connector module framework/connectors/ssh, which a consumer opts into (ADR 0158).

A consumer that does not want that dependency supplies its own Identity. That is not a theoretical escape hatch — the product this was versed from does exactly that, keeping its 327 lines of SSH handling rather than pulling the connector's graph for them.

There is **no registry**. A registry's key would name an identity scheme, and the whole trust chain is anchored to one vendor for one product.

Package entitlement — the sentinels every implementation of this domain returns.

Each names a distinct operator situation rather than a generic failure, because callers map them to exit codes and an operator acts on the difference: a revoked subject, an expired window and an unreachable roster need three different responses.

Package entitlement - the record of a verification that succeeded. A daemon ages against it: memory of a past check must not outlive the roster window, otherwise revocation would never reach a long-running process.

Package entitlement - where the roster is fetched from. Authority comes from the vendor signature, never from the origin that served the bytes, so the binary is free to ask several places and keep whichever answers.

Package entitlement - the signed roster: the only statement the binary trusts, and only because the vendor signed it. Origin is irrelevant here; a substituted endpoint can serve any bytes but cannot forge the signature.

Index

Constants

View Source
const CodeAmbiguousLicence errs.Code = 0x00_02_23_08 // 0.2.35.8

CodeAmbiguousLicence identifies more than one entitlement identity on one machine, which no automatic choice can resolve safely.

View Source
const CodeCINotEntitled errs.Code = 0x00_02_23_0B // 0.2.35.11

CodeCINotEntitled identifies a CI account the roster does not cover.

View Source
const CodeCIUnknownKey errs.Code = 0x00_02_23_0A // 0.2.35.10

CodeCIUnknownKey identifies a CI token signed by a key the issuer does not publish.

View Source
const CodeCIUnverifiable errs.Code = 0x00_02_23_09 // 0.2.35.9

CodeCIUnverifiable identifies a CI run whose provenance could not be proven.

View Source
const CodeClockRegressed errs.Code = 0x00_02_23_0D // 0.2.35.13

CodeClockRegressed identifies a machine whose clock is behind the last signed document it verified — the anti-rollback ratchet.

View Source
const CodeKeyMismatch errs.Code = 0x00_02_23_06 // 0.2.35.6

CodeKeyMismatch identifies a local key that does not match the fingerprint the roster publishes for this subject.

View Source
const CodeLicenceExpired errs.Code = 0x00_02_23_05 // 0.2.35.5

CodeLicenceExpired identifies a subject whose own validity window has closed.

View Source
const CodeNoLicence errs.Code = 0x00_02_23_01 // 0.2.35.1

CodeNoLicence identifies a machine with no entitlement key at all.

View Source
const CodeNoPossession errs.Code = 0x00_02_23_0C // 0.2.35.12

CodeNoPossession identifies a holder who could not prove possession of the private half of the published key.

View Source
const CodeProductInvalid errs.Code = 0x00_02_23_0F // 0.2.35.15

CodeProductInvalid identifies a ProductValue whose origin list could not survive the day it is needed — see ProductValue.Validate.

View Source
const CodeRevoked errs.Code = 0x00_02_23_04 // 0.2.35.4

CodeRevoked identifies a subject absent from a roster that was itself valid.

View Source
const CodeRosterStale errs.Code = 0x00_02_23_03 // 0.2.35.3

CodeRosterStale identifies a roster that verified but whose validity window has closed.

View Source
const CodeRosterUnreachable errs.Code = 0x00_02_23_07 // 0.2.35.7

CodeRosterUnreachable identifies a fetch that could not decide either way. It reports "cannot decide", never "no" — the distinction is the whole point.

View Source
const CodeRosterUnsigned errs.Code = 0x00_02_23_02 // 0.2.35.2

CodeRosterUnsigned identifies a roster carrying no valid vendor signature. It is the spoofing signal: a substituted endpoint cannot produce one.

View Source
const CodeUpdateRequired errs.Code = 0x00_02_23_0E // 0.2.35.14

CodeUpdateRequired identifies a build below the floor the roster mandates.

View Source
const RosterLifetime time.Duration = 24 * time.Hour

RosterLifetime bounds how long a verified grant survives without a fresh fetch. It is the single dial of the whole scheme: it caps both how long a revoked subject keeps working and how long a hostile endpoint can replay a genuine roster.

Variables

View Source
var (
	// ErrNoLicense reports that no licence key was found on this machine.
	// The caller has never enrolled, or the key was removed.
	ErrNoLicense = errs.Static(CodeNoLicence, "NO_LICENCE",
		"no entitlement key was found on this machine",
		"core/entitlement: no key material for any subject",
		0,
		0)

	// ErrRosterUnsigned reports that the roster carried no valid vendor
	// signature. This is the spoofing signal: a substituted endpoint cannot
	// produce one.
	ErrRosterUnsigned = errs.Static(CodeRosterUnsigned, "ROSTER_UNSIGNED",
		"the roster is not signed by the expected vendor",
		"core/entitlement: the roster signature did not verify against the vendor key",
		0,
		0)

	// ErrRosterStale reports that the roster verified but no longer
	// authorises anything. It bounds how long a revoked client keeps working
	// offline, and how long a hostile endpoint can replay a genuine roster.
	//
	// Those are the same bound, and cache.go is what finally made the first
	// half of that sentence describe something: an offline client re-reads
	// the last bundle it authenticated and is refused HERE the moment that
	// bundle's window closes — at most RosterLifetime after it was signed,
	// whoever is replaying it and from wherever.
	//
	// TWO conditions reach it, and the second is the other half of that
	// replay bound rather than a separate situation. A window that has
	// CLOSED is the absolute one. A roster SUPERSEDED by a newer signed
	// decision this machine has already accepted is the relative one: its
	// own window may be wide open, and it is still the replay of a statement
	// the vendor has since replaced. Only the `condition` field says which,
	// because the resolving action does not differ — publish, or fetch, a
	// current roster.
	ErrRosterStale = errs.Static(CodeRosterStale, "ROSTER_STALE",
		"the roster has expired",
		"core/entitlement: the roster verified but no longer authorises — its own window has closed, or a newer signed decision has superseded it",
		0,
		0)

	// ErrRevoked reports that the licence UUID is absent from a roster that
	// was itself valid — the subject was removed upstream.
	ErrRevoked = errs.Static(CodeRevoked, "REVOKED",
		"this entitlement has been revoked",
		"core/entitlement: the subject is absent from a roster that was itself valid",
		0,
		0)

	// ErrLicenseExpired reports that the subject's own validity window has
	// closed, even though the roster itself is fresh and still lists them.
	// Distinct from ErrRosterStale (the whole roster's signing freshness)
	// and from ErrRevoked (removed from the roster entirely): renewal, not
	// rotation or re-enrolment, is what resolves this one.
	ErrLicenseExpired = errs.Static(CodeLicenceExpired, "LICENCE_EXPIRED",
		"this entitlement has expired",
		"core/entitlement: the subject's own validity window has closed",
		0,
		0)

	// ErrKeyMismatch reports that the local key does not match the
	// fingerprint the roster records for this UUID, which is what a copied
	// .pub file looks like: the public half is published, so possession of
	// it proves nothing.
	ErrKeyMismatch = errs.Static(CodeKeyMismatch, "KEY_MISMATCH",
		"the local key does not match the published fingerprint",
		"core/entitlement: the local key fingerprint differs from the roster's",
		0,
		0)

	// ErrRosterUnreachable reports that no origin could be reached AND no
	// usable bundle was cached, so no authorization decision could be made at
	// all. It is deliberately distinct from a refusal: the caller knows
	// nothing, rather than knowing the answer is no.
	//
	// It names the NETWORK even when the cache is what failed last. The cache
	// is a fallback nobody configured and most operators have never heard of;
	// pointing them at it would send them to fix the wrong thing, when what
	// happened is that every publication point was unreachable.
	ErrRosterUnreachable = errs.Static(CodeRosterUnreachable, "ROSTER_UNREACHABLE",
		"the roster could not be reached",
		"core/entitlement: no origin answered and nothing was cached — cannot decide, which is not the same as no",
		0,
		0)

	// ErrAmbiguousLicense reports that the key directory holds more than one
	// usable licence identity, so which one this machine presents cannot be
	// decided here. It is deliberately distinct from ErrNoLicense: the
	// machine is enrolled, possibly several times over, and the fix is to
	// remove the identities that do not belong rather than to enrol.
	//
	// Guessing was the previous behaviour — the lexicographically first UUID
	// won — which let a stray .pub decide which licence got verified, which
	// one a rotation overwrote, and which one `license status` reported.
	ErrAmbiguousLicense = errs.Static(CodeAmbiguousLicence, "AMBIGUOUS_LICENCE",
		"more than one entitlement identity is present on this machine",
		"core/entitlement: several subjects are enrolled and no automatic choice is safe",
		0,
		0)

	// ErrCIUnverifiable reports that a claim of running inside CI could not
	// be authenticated: no token, a malformed one, a bad signature, or one
	// minted for someone else. It never means "not in CI" — it means the
	// claim was not provable, and the caller falls back to the ordinary
	// device path rather than granting a free seat.
	ErrCIUnverifiable = errs.Static(CodeCIUnverifiable, "CI_UNVERIFIABLE",
		"this run could not be proven to be a CI run",
		"core/entitlement: the CI provenance token was absent or unverifiable",
		0,
		0)

	// ErrCIUnknownKey reports that the token names a signing key the fetched
	// key set does not publish — a key rotated out from under a token still in
	// flight, or a forgery. Distinct from ErrCIUnverifiable because the two
	// send an operator to different places: this one says the issuer's key set
	// and the token disagree, which is the issuer's side of the exchange.
	//
	// It used to promise a remedy the client applies itself — "refresh the key
	// set once, since GitHub rotates keys, then refuse if it still does not
	// appear". No implementation does that and none is expected to: the
	// verifier holds no key-set cache, so it already fetches the published set
	// fresh on every verification and a rotation is picked up by the next one.
	// A second fetch inside one verification would re-read a URL it read
	// seconds earlier, which cannot make an endpoint publish a kid it does not
	// publish. See the service's publishedJWKS for the argument in full; a
	// sentinel must not advertise a mechanism nobody implements.
	ErrCIUnknownKey = errs.Static(CodeCIUnknownKey, "CI_UNKNOWN_KEY",
		"the CI token is signed by a key the issuer does not publish",
		"core/entitlement: no JWKS entry matched the token's kid",
		0,
		0)

	// ErrCINotEntitled reports that the CI run authenticated, but the account
	// it belongs to is not one the roster covers. The token is genuine; the
	// licence simply does not extend to it.
	ErrCINotEntitled = errs.Static(CodeCINotEntitled, "CI_NOT_ENTITLED",
		"this CI account is not covered by an entitlement",
		"core/entitlement: the roster carries no CI seat for this account",
		0,
		0)

	// ErrNoPossession reports that the private half could not be exercised,
	// so holding the public key was never turned into proof of ownership.
	ErrNoPossession = errs.Static(CodeNoPossession, "NO_POSSESSION",
		"possession of the private key could not be proven",
		"core/entitlement: the possession challenge was not answered correctly",
		0,
		0)

	// ErrClockRegressed reports that this machine's clock reads EARLIER than
	// the newest vendor-signed instant it has ever authenticated.
	//
	// It is the one statement about time this package can make without the
	// network, and it is worth making because the alternative is to believe a
	// clock the holder controls. A roster is signed evidence that time was at
	// least its IssuedAt; a clock that has since moved backwards past that
	// point is either broken or being moved, and both are the holder's to fix.
	//
	// What it does NOT catch is a FROZEN clock: parked exactly on the mark,
	// nothing local can tell that time has passed. Nor does it survive
	// deletion — the mark lives on a disk its holder owns. It bounds the
	// careless case and the casual one, which is this package's whole remit.
	ErrClockRegressed = errs.Static(CodeClockRegressed, "CLOCK_REGRESSED",
		"this machine's clock is behind the last signed document it verified",
		"core/entitlement: the anti-rollback ratchet refused a clock earlier than the recorded high-water mark",
		0,
		0)

	// ErrUpdateRequired reports that the roster demands a newer binary than
	// this one. It is NOT a licence failure — the subject is perfectly
	// entitled — so it carries its own sentinel and its own exit code: the
	// resolving action is an upgrade, and telling somebody their licence is
	// broken when it is not would send them to the wrong place entirely.
	ErrUpdateRequired = errs.Static(CodeUpdateRequired, "UPDATE_REQUIRED",
		"a newer version is required",
		"core/entitlement: the running build is below the floor the roster mandates",
		0,
		0)
)

Sentinel errors distinguishing why authorization failed. Callers map them to exit codes, so each one names a distinct operator situation rather than a generic failure.

Functions

func GrantDeadline

func GrantDeadline(verifiedAt time.Time, bounds ...time.Time) time.Time

GrantDeadline computes NotAfter from the verification's own lifetime and however many further bounds the caller can name.

A zero expiry means "not recorded" throughout this package — an absent subject term, or a roster field a predecessor never wrote — and must never be read as "already expired". Zero values are therefore skipped rather than minimised over: otherwise the earliest bound would always be the zero time and every grant would be born dead.

Why the bounds are variadic

Because "a grant may not outlive the document that authorised it" is a rule about however many documents there were, and the CI path has three. It was written as two fixed parameters — the roster's window and the subject's term — which are exactly the two a DEVICE grant rests on. A CI seat rests on a third: the Actions token that proved the run is real, whose own expiry GitHub sets minutes out. ciseat.go therefore bounded the seat by the roster and the account's term and by nothing else, so a grant established by a 30-minute proof survived it by up to a day — the invariant NotAfter's own comment states, contradicted by the one caller that had a document the signature said the least about.

Variadic rather than a fourth parameter so the two existing call sites compile unchanged and read unchanged; a caller with nothing extra to name passes nothing extra.

No skew here, deliberately

checkTiming allows clockSkew when ADMITTING an Actions token, which is the permissive direction and the right one there: refusing a genuine token over two minutes of drift would break a working runner. Adding the same allowance to a BOUND would run the opposite way — it would extend the grant past the proof — so the bound is the claim as written.

Types

type BoundProver

type BoundProver interface {
	// ProvePossessionFor demonstrates that this machine holds the private half
	// of the key whose PUBLISHED fingerprint is authorised — the value the
	// roster records for this subject, in the roster's own spelling.
	//
	// A nil error is the proof, for the same reason it is on ProvePossession.
	// An implementation that finds it holds different material refuses with
	// ErrKeyMismatch: that is what "a local key that does not match the
	// published fingerprint" means, so the refusal taxonomy does not grow.
	//
	// authorised is compared however the implementation compares fingerprints —
	// byte equality, in the roster's spelling, is what Fingerprint's own
	// contract already requires.
	ProvePossessionFor(subject, authorised string) error
}

BoundProver is Identity's sibling for the one claim the three methods cannot make: possession of the key the ROSTER authorised, rather than of whatever this machine holds at the moment it is asked.

The gap it closes

The verification engine asks Fingerprint what this machine presents, compares the answer ITSELF against the roster's entry, and then asks ProvePossession for a proof. The authorised fingerprint never traverses the port, so an implementation cannot bind the proof to it: between the two calls the material may be replaced — a rotation, a mounted volume remounted, a deliberate swap — and the implementation signs with what it finds on the second call. A consumer can DETECT that by remembering the key object across the two calls and refusing a proof with nothing remembered, which is as far as the three methods reach; it cannot be told which key it was supposed to be holding.

Why a sibling and not a fourth parameter

Identity is reachable through a pkg/v1 type alias, so ADR 0039's rule binds and ADR 0040 §4 says the version does not matter: a published interface is extended by a sibling, never by widening, because Go satisfies interfaces STRUCTURALLY and every downstream implementation breaks at compile time with no deprecation window. Widening ProvePossession would be worse than adding a method — it invalidates a method implementers have already written — and it would not even buy the guarantee that tempts it: an implementation can satisfy a two-parameter signature and ignore the second argument, so binding is the implementation's choice under either shape. The sibling buys the same real property and breaks nobody.

What its ABSENCE means, stated rather than left to be discovered

An Identity that does not implement this keeps the three-method behaviour exactly: the engine falls back to ProvePossession and the window between the two calls stays open. That is not a mechanism this package can close from here, and pretending otherwise would be the claim that outruns the code.

type CIEntitlementValue

type CIEntitlementValue struct {
	// ExpiresAt closes the entitlement, and is the licence's own term so CI
	// stops exactly when the devices do. The zero value means no term was
	// recorded, treated as no expiry rather than an instantly-closed one —
	// the same rule SubjectValue follows, for the same reason.
	ExpiresAt time.Time `json:"exp"`
}

CIEntitlementValue is what the roster grants one account's CI.

type GrantValue

type GrantValue struct {
	// Subject is the client UUID that was verified.
	Subject string
	// VerifiedAt is when the roster was last successfully authenticated.
	VerifiedAt time.Time
	// NotAfter is the instant this grant stops authorising anything: the
	// EARLIEST of the verification's own lifetime and every document the
	// verification rested on — the roster's expiry, the subject's term, and
	// for a CI seat the expiry of the Actions token that proved the run.
	// Each bounds something different, so the tightest is the only correct
	// answer — a grant may not outlive the document that authorised it.
	//
	// The zero value means "no deadline was computed", which is how a grant
	// seeded from a bare timestamp behaves: serve.go does exactly that at
	// start-up, from the root gate's verification, with no roster in scope.
	// The fallback is the verification's own lifetime rather than reading a
	// zero deadline as already-past, which would refuse a daemon at the very
	// instant it starts.
	//
	// Which is why a caller that wants to ACT on the deadline should read
	// Deadline() and not this field: the method resolves that fallback and the
	// field cannot. Honouring either is the consumer's job — nothing in this
	// SDK revokes anything when the instant passes. See Expired.
	NotAfter time.Time
	// Offline records that no origin answered and the decision rested on the
	// last bundle this machine authenticated.
	//
	// It changes nothing about the grant's authority: an offline roster
	// passed the identical signature and window checks, and NotAfter above
	// already bounds it by that roster's own expiry. It exists so the fact can
	// be SAID. A binary quietly running on a copy of a document it can no
	// longer fetch is the one state where "it worked" and "it is still true"
	// come apart, and the operator is the only party who can act on that.
	Offline bool
}

GrantValue records a verification that succeeded, and how long that verification may be remembered.

The timestamp alone is not enough to age against, which is the defect this type carried until NotAfter existed. A grant used to expire at VerifiedAt+RosterLifetime and nothing else, so a daemon that happened to verify late in a roster's window kept serving for a further 24 hours after that window closed — up to 47 hours after the vendor last signed anything, in a scheme whose single dial is a 24-hour bound. It outlived the subject's OWN term too: a licence expiring at noon left a daemon that verified at 11:59 serving until the following day.

func (*GrantValue) Deadline

func (g *GrantValue) Deadline() time.Time

Deadline reports the instant this grant stops authorising anything — the EFFECTIVE one, which is not always the NotAfter field.

Why the field is not enough

NotAfter's zero value means "no deadline was computed", and Expired then ages the grant against VerifiedAt+RosterLifetime instead. That fallback lived inside Expired and nowhere else, so a caller reading the FIELD to schedule its next check read the zero instant and had no way to derive the deadline the SDK would actually apply. Its only remaining option was to poll — which is precisely how a grant that expires a second after a check keeps authorising until the next tick, whatever the tick is.

With the effective instant readable, a consumer can wake AT the deadline rather than sometime after it: arm a timer on Deadline(), or pass it to a context. That is the SDK's whole contribution to the problem, and the rest is stated in Expired's own comment.

Expired is defined in terms of this method rather than repeating the fallback, because two copies of one rule is how a field and a method come to disagree about when a grant died.

A POINTER receiver for the same reason Expired has one — see there.

func (*GrantValue) Expired

func (g *GrantValue) Expired(now time.Time) bool

Expired reports whether a grant may no longer be relied on. A daemon that cannot refresh must stop trusting its own memory eventually, otherwise revocation would never reach it.

Honouring the deadline is the CONSUMER's job, and nothing here enforces it

This is a value type. Verify computes a grant, hands it back and keeps no reference to it: there is no goroutine, no timer and no callback anywhere in this SDK that revokes anything when Deadline passes, and a process holding an expired grant is not interrupted. Whatever gates work on a grant has to ask.

So a grant expiring a second after a check keeps authorising until the consumer looks again, and how long that is, is the consumer's tick — not a property of this domain. What the domain owes is a deadline that can be acted on WITHOUT polling, which is what Deadline is for: schedule on it, and the question is asked when the answer changes rather than on a cadence.

Said here rather than only in an ADR, because a consumer reading this method is exactly the reader who might otherwise assume something upstream is watching the clock for them.

A POINTER receiver on a read-only method, which is unusual and deliberate: the struct outgrew KTN-VAR-BIGSTRUCT's by-value threshold when Offline was added, and every caller passes this as a METHOD VALUE (`licenseStillValidFrom(svc, refusal, grant.Expired)`) that would otherwise copy the whole struct into the closure on every watchdog tick.

It is a method-set change and therefore a source-level API change, named here rather than left to be discovered: Expired is no longer in GrantValue's VALUE method set, so a NON-ADDRESSABLE grant — a map entry, a composite literal used inline — stops compiling against it. Every caller in this repository holds an addressable grant, and pkg/license has no consumer outside the module, which is what makes the trade payable; a released SDK would not.

type Identity

type Identity interface {
	// Discover returns the subject identifier this machine is enrolled as.
	//
	// It refuses rather than choosing when more than one identity is present:
	// picking one silently would make revocation unverifiable, since the
	// operator could not tell which identity was checked.
	Discover() (subject string, err error)
	// Fingerprint returns the published fingerprint of the subject's public
	// key, in whatever spelling the roster uses. It is compared by byte
	// equality against the roster's entry and never parsed.
	Fingerprint(subject string) (fingerprint string, err error)
	// ProvePossession demonstrates that this machine holds the private half of
	// the key whose fingerprint Fingerprint returned. A nil error is the proof;
	// there is deliberately no value to inspect, because anything returned here
	// would be a second thing to verify.
	//
	// It proves possession of whatever material this implementation holds NOW,
	// which is not the same claim as "possession of the key the roster
	// authorised" — nothing the roster said reaches this method. BoundProver is
	// the sibling that carries the missing half; an implementation that can make
	// the stronger claim implements it, and the engine prefers it when present.
	ProvePossession(subject string) error
}

Identity is the machine's half of the proof: which subject this machine claims to be, and evidence that it holds the private key the roster publishes a fingerprint for.

It is FROZEN at three methods, and the split is what makes the refusals distinguishable. Discover answers "who are we", Fingerprint answers "what do we present", ProvePossession answers "can we back it up" — and a caller that fails the third after passing the second has key material it cannot use, which is a different operator situation from having none at all.

Frozen means frozen: it is reachable through a pkg/v1 type alias, so ADR 0039 binds and ADR 0040 §4 removes the v0 licence from it — the version does not matter, because structural satisfaction breaks downstream implementations at any version. It is extended by a SIBLING, and BoundProver below is the first one.

type OriginValue

type OriginValue struct {
	// Name identifies the origin in diagnostics. It is not part of the
	// trust decision — nothing here is — but an operator staring at a
	// refusal needs to know which endpoints were tried.
	Name string
	// BundleURL serves the signed bundle: roster and signature in one
	// document. ONE url, not two, because two objects cannot be fetched
	// atomically — see BundleValue for the five-minute cache window that made
	// a correct client refuse a correct roster.
	BundleURL string
}

OriginValue is one publication point for the roster pair.

It carries no trust of its own: the vendor signature over the bytes is what authorises anything, so an origin is only ever an availability choice. That is precisely why the list can be extended freely — a hostile entry gains nothing, and a dead one is skipped.

type RosterValue

type RosterValue struct {
	// IssuedAt is when the vendor signed this roster.
	IssuedAt time.Time `json:"iat"`
	// ExpiresAt closes the roster's own signing-freshness window. Past it
	// the roster is refused even though its signature still verifies. This
	// bounds replay and revocation latency for the roster as a whole; it is
	// independent of any one subject's ExpiresAt below.
	ExpiresAt time.Time `json:"exp"`
	// Subjects maps a client UUID to what the roster records for it.
	Subjects map[string]SubjectValue `json:"subjects"`
	// RequiredVersion is the lowest binary version allowed to run, as a
	// SemVer tag ("v1.5.14"). Empty means no floor, which is how a roster
	// published before this field existed behaves — absence must not lock
	// out every client that predates it.
	//
	// It lives HERE, in the signed roster, rather than being read from a
	// release API, and that placement is the whole design. The roster is
	// already fetched on every cold start and already authenticated against
	// the compiled-in anchor, so a mandatory update inherits both properties
	// for free: it cannot be skipped by staying offline (the fetch is
	// mandatory), and it cannot be forged by redirecting the endpoint (the
	// signature is checked first). A version read from an unauthenticated
	// API would be defeated by one line in /etc/hosts.
	RequiredVersion string `json:"minv,omitempty"`
	// CIAccounts maps a GitHub account's NUMERIC id to what the roster grants
	// its CI runs. A run inside that account's Actions may be authorised
	// without spending one of the licence's device seats.
	//
	// Keyed by id and never by login: a login can be renamed, and a released
	// one can be claimed by somebody else, so matching on the name would turn
	// a freed handle into a way in.
	//
	// Absent for a roster that grants no CI entitlement, which is also how
	// every roster published before this field existed behaves — absence must
	// not be read as anything but "no CI seat".
	CIAccounts map[string]CIEntitlementValue `json:"ci,omitempty"`
	// CIRelaxed restores the historical fall-through: inside GitHub Actions a
	// failed CI seat stops being a refusal and the device path answers next.
	//
	// The DEFAULT is the strict direction, which is the inversion this field
	// records. A CI seat used to be tried first and every failure swallowed,
	// so a device key dropped onto a runner bought unlimited CI without any CI
	// entitlement — the one hole a licence scheme aimed at CI cannot leave
	// open. Requiring the seat is now what happens unless this says otherwise.
	//
	// It lives in the signed roster, and that placement is the whole design.
	// "A run in Actions need not prove it is one" is a statement only the
	// vendor is entitled to make, and it has to travel somewhere the party
	// being checked cannot rewrite — a flag or an environment variable would
	// be set by that very party. Note the direction: this field can only
	// RELAX, so forging it is the one thing an attacker would want and the one
	// thing the signature prevents; absence, which is what any tampering
	// produces, is the strict reading.
	//
	// The strictness applies only when InCI reports both runner variables
	// present. A laptop never had a seat to prove, and refusing one would be a
	// far worse defect than the hole this closes.
	CIRelaxed bool `json:"cilax,omitempty"`
}

RosterValue is the signed statement of who may run the linter. It carries no secret: every field is publishable, which is why it can live in a public repository. Authority comes from the detached signature, never from the origin that served the bytes.

func (*RosterValue) CIEntitlementFor

func (r *RosterValue) CIEntitlementFor(accountID string, now time.Time) (entitlement CIEntitlementValue, err error)

CIEntitlementFor returns what the roster grants an account's CI runs.

Absence is not an error worth distinguishing from a refusal here: either way the caller falls back to the device path, which is what an unentitled CI run should do.

All four refusals render the SAME sentence, which is deliberate: a refusal naming the account it refused hands the party being kept out a way to read the roster one probe at a time. Which account, and which deadline, travel as FIELDS — errs.FieldsOf reaches them, err.Error() does not — and the condition field is what keeps "no roster at all" distinguishable from "this account has no seat" for the operator who is entitled to the difference.

func (*RosterValue) SubjectFor

func (r *RosterValue) SubjectFor(uuid string) (subject SubjectValue, err error)

SubjectFor returns the roster's record for a subject. A missing entry reports ErrRevoked, but the roster is a stateless snapshot with no history: absence is exactly how a withdrawn licence looks, and it is indistinguishable here from a subject whose enrolment was never approved in the first place. Callers surfacing this to a human must name both possibilities rather than assert the more alarming one as fact.

The refused subject travels as a FIELD and not in the sentence. A revocation that quotes back the uuid it refused confirms that uuid to whoever presented it, which is a thing a refusal has no business doing; errs.FieldsOf still hands it to the operator's log.

It is total on a nil receiver, which its sibling CIEntitlementFor has always been. Reachable through the Roster alias in framework/entitlement, where a consumer holding a nil roster got a panic from one method and a refusal from the other.

type SubjectValue

type SubjectValue struct {
	// Fingerprint is the SHA256 fingerprint of the subject's public key.
	// Recording the fingerprint rather than the key itself keeps the roster
	// small and makes the comparison exact.
	Fingerprint string `json:"fp"`
	// ExpiresAt closes this subject's individual term. The zero value means
	// no term was recorded, which is treated as no expiry rather than an
	// instantly-closed one — a subject published before this field existed
	// must not be locked out by its mere absence.
	ExpiresAt time.Time `json:"exp"`
}

SubjectValue is one enrolled client's entry: the fingerprint that proves which key is theirs, and the date past which their own entitlement closes regardless of how fresh the roster itself is.

Jump to

Keyboard shortcuts

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