entitlement

package
v0.44.2 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 4 Imported by: 0

README

entitlement

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

Package entitlement verifies a machine's entitlement to run a product: local key material plus a vendor-signed roster, turned into a grant or a typed refusal.

It is the framework's public package over framework/internal/service/entitlement (ADR 0158): a distribution mechanism, built on pkg/v1 and nothing below it.

The trust model, and its ceiling

One signer: the vendor. What the consuming binary links in is that signer's ed25519 public key, or — while a key is being rotated — an ordered list of the keys it accepts. Everything else — the roster, the per-subject keys, the host serving them — is untrusted input. A hostile endpoint can serve what it likes; it cannot forge a signature made with the vendor's private half.

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

The anchor is a list, because one key has no way out

A binary linking in exactly one anchor cannot be moved to another: rosters signed by a new key are refused against the anchor it holds — before anything reads the version floor that would have told it to upgrade — and the release carrying the new anchor is refused by the installation that needs it. Blocked from both sides, with no path left in band.

NewWithAnchors takes an ORDERED list and a roster is authentic when it verifies against any entry, which is all rotation needs: publish under B, installations carrying {A, B} take it, installations carrying only {A} keep reading A until they are updated. A bundle already cached under either key is still a valid proof of time, so the anti-rollback ratchet is not reset by a rotation.

The cost is real and is not hidden: an anchor on the list is a key whose compromise is accepted while it is listed. The list is therefore capped and is a BUILD decision — nothing at runtime can extend it — and an empty one refuses every document rather than accepting every document.

Identity is yours to supply

Proving possession needs key material in a format the standard library cannot parse. Rather than pull golang.org/x/crypto/ssh — and through it golang.org/x/sys, which the SDK and its framework ban — into every consumer's graph, the machine's half of the proof is a PORT:

type Identity interface {
	Discover() (subject string, err error)
	Fingerprint(subject string) (fingerprint string, err error)
	ProvePossession(subject string) error
}

An ssh implementation ships as the connector module framework/connectors/ssh, for consumers who want one and accept the dependency: requiring it is what brings golang.org/x/crypto and golang.org/x/sys into a graph. A consumer that already handles its own key material — which is the common case for a product that enrols its users — implements three methods instead and never requires that module. ADR 0078 has the original measurement, ADR 0079 the split and ADR 0158 the move into the framework.

A proof can be bound to the key the roster authorised

Identity's three methods cannot express "prove possession of the key the roster approved": the engine asks what you present, compares that answer itself, and then asks for a proof, so the authorised value never reaches your code. Anything that replaced the material in between is signed for.

BoundProver is the sibling that carries it. Implement ProvePossessionFor(subject, authorised) and the verifier prefers it; do not, and nothing changes — the three-method port is frozen and stays supported. The SDK's own ssh implementation implements both.

Cannot decide is never no

A cold Verify reaches the network FIRST. Only when no origin answers does the cached bundle substitute, and it goes back through the signature check on every read, so a frozen copy stops authorising at its own expiry. Failure with nothing cached reports RosterUnreachable and names the NETWORK, because reporting an outage as a revocation is the one wrong answer.

What is NOT answered, and cannot be locally, is a frozen CLOCK: every source of time an offline process can read belongs to the party being checked. The ratchet raises the cost; it does not close the hole.

An older genuine roster cannot undo a newer decision

The ratchet also bears on ACCEPTANCE, not only on what gets cached. A roster the vendor signed but has since replaced is refused before it reaches the decision, so a lagging mirror or a substituted origin cannot restore a revoked subject, a rotated-out fingerprint, a lower mandatory-update floor, a withdrawn CI account or a relaxed CI policy. Equal signing instants are accepted when the signed payload is identical — which is every ordinary re-verification — and refused when it is not.

It is CONDITIONAL on the cached bundle persisting, because that bundle IS the mark: the guarantee lapses for a Service built with no cache, for an install the filesystem refused, and for one that stood down on a contended lock. None of the three refuses instead; a contended lock turned into a refused licence would be worse than the replay it would prevent.

Honouring the deadline is the consumer's job

Verify computes a Grant, hands it back and keeps no reference to it. There is no goroutine, no timer and no callback in this SDK that revokes anything when a grant's 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 you look again, and how long that is, is your tick rather than a property of this package. What the package owes you is a deadline you can act on WITHOUT polling, and that is Grant.Deadline(): the EFFECTIVE instant, with the zero-NotAfter fallback already resolved. Schedule on it — a timer, a context deadline — and the question gets asked when the answer changes instead of on a cadence. Grant.Expired(now) is the same rule asked the other way round.

A CI seat cannot outlive its token

BEHAVIOUR CHANGE. A grant issued for a proven GitHub Actions run is now bounded by the Actions token's own expiry as well as by the roster's window and the account's term. The effective bound on a CI seat therefore drops from up to 24 h to at most 30 minutes — maxTokenLifetime, and in practice the few minutes a real token carries. A long-running process on a runner that aged against a CI grant for hours must now re-verify inside that window.

No clock skew is added to the bound. The two-minute allowance exists to ADMIT a token whose clock disagrees slightly, which is permissive and correct; applying it to a bound would extend the grant past the proof. Together they are what makes the exposure of a leaked token statable at all: one free seat PER VERIFIER for up to 32 minutes. A short token bounds the DURATION a stolen proof keeps working, never the NUMBER of verifiers that will accept it at once.

Index

Constants

const CodeAmbiguousLicence errs.Code = 140040

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeAmbiguousLicence.

Code 0.2.35.8

const CodeCINotEntitled errs.Code = 140043

CodeCINotEntitled identifies a CI account the roster does not cover.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeCINotEntitled.

Code 0.2.35.11

const CodeCIUnknownKey errs.Code = 140042

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeCIUnknownKey.

Code 0.2.35.10

const CodeCIUnverifiable errs.Code = 140041

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeCIUnverifiable.

Code 0.2.35.9

const CodeClockRegressed errs.Code = 140045

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeClockRegressed.

Code 0.2.35.13

const CodeKeyMismatch errs.Code = 140038

CodeKeyMismatch identifies a local key that does not match the published fingerprint.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeKeyMismatch.

Code 0.2.35.6

const CodeLicenceExpired errs.Code = 140037

CodeLicenceExpired identifies a subject whose own term has closed.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeLicenceExpired.

Code 0.2.35.5

const CodeNoLicence errs.Code = 140033

CodeNoLicence identifies a machine with no entitlement key at all.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeNoLicence.

Code 0.2.35.1

const CodeNoPossession errs.Code = 140044

CodeNoPossession identifies a holder who could not prove possession.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeNoPossession.

Code 0.2.35.12

const CodeProductInvalid errs.Code = 140047

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeProductInvalid.

Code 0.2.35.15

const CodeRevoked errs.Code = 140036

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeRevoked.

Code 0.2.35.4

const CodeRosterStale errs.Code = 140035

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeRosterStale.

Code 0.2.35.3

const CodeRosterUnreachable errs.Code = 140039

CodeRosterUnreachable identifies a check that could not decide either way. It is NOT a refusal, and a caller that treats it as one turns an outage into a revocation.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeRosterUnreachable.

Code 0.2.35.7

const CodeRosterUnsigned errs.Code = 140034

CodeRosterUnsigned identifies a roster carrying no valid vendor signature — the spoofing signal, since a substituted endpoint cannot produce one.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeRosterUnsigned.

Code 0.2.35.2

const CodeUpdateRequired errs.Code = 140046

CodeUpdateRequired identifies a build below the floor the roster mandates.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.CodeUpdateRequired.

Code 0.2.35.14

const RosterLifetime time.Duration = 86400000000000

RosterLifetime is how long a signed roster stays usable. It bounds how long a revoked subject keeps working offline, and how long a captured roster can be replayed — the same bound, read from either side.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.RosterLifetime.

Variables

var ErrAmbiguousLicense *errs.Error

ErrAmbiguousLicense identifies more than one identity on one machine.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrAmbiguousLicense.

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

var ErrCINotEntitled *errs.Error

ErrCINotEntitled identifies a CI account the roster does not cover.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrCINotEntitled.

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

var ErrCIUnknownKey *errs.Error

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrCIUnknownKey.

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 identifies a CI run whose provenance could not be proven.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrCIUnverifiable.

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 identifies a clock behind the last signed document this machine verified.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrClockRegressed.

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 identifies a local key that does not match the published fingerprint.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrKeyMismatch.

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

var ErrLicenseExpired *errs.Error

ErrLicenseExpired identifies a subject whose own term has closed.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrLicenseExpired.

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

var ErrNoLicense *errs.Error

ErrNoLicense identifies a machine with no entitlement key at all.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrNoLicense.

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

var ErrNoPossession *errs.Error

ErrNoPossession identifies a holder who could not prove possession.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrNoPossession.

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

var ErrRevoked *errs.Error

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

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrRevoked.

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

var ErrRosterStale *errs.Error

ErrRosterStale identifies a roster that verified but no longer authorises: its own window has closed, or a newer signed decision has superseded it.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrRosterStale.

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

var ErrRosterUnreachable *errs.Error

ErrRosterUnreachable identifies a check that could not decide either way.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrRosterUnreachable.

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

var ErrRosterUnsigned *errs.Error

ErrRosterUnsigned identifies a roster carrying no valid vendor signature.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrRosterUnsigned.

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

var ErrUpdateRequired *errs.Error

ErrUpdateRequired identifies a build below the floor the roster mandates.

Initialised with github.com/kitsunium/sdk/framework/internal/core/entitlement.ErrUpdateRequired.

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

func New

func New(identity Identity, vendor []byte, product *Product) *Service

New returns a verifier for the given identity, vendor key and product.

One anchor, which is the one-element list NewWithAnchors takes — not a second representation of the same thing, so no path can disagree with the multi-anchor one about what a single key means.

func NewWithAnchors

func NewWithAnchors(identity Identity, anchors [][]byte, product *Product) *Service

NewWithAnchors returns a verifier that accepts SEVERAL vendor keys, in the order given, so a signing key can be rotated in band.

It takes identity, the machine's half of the proof; anchors, the ed25519 public keys the binary links in, most-current first; and product, the vendor-specific facts. A roster is authentic when it verifies against ANY of the anchors, which is what lets a vendor publish under a new key while installations carrying only the old one keep working — neither side has to be updated first.

What it costs, said here rather than only in the ADR

Every anchor on the list is a key whose compromise is ACCEPTED while it is listed, so this is strictly more surface than New's single key. What bounds it is that the list is ordered, capped, and a BUILD decision: nothing at runtime can extend it — not a roster field, not an environment variable, not the cache — and a key leaves the list the way it entered, by shipping a build without it. Past the cap the tail is dropped and a line is logged, because a build mistake answered by refusing every verification would take a product down over a misconfiguration.

An EMPTY list is not a way to disable the signature check: every document is then refused with ErrRosterUnsigned, since with no anchor no signature can be valid.

Service.WithAnchors sets the same list on a verifier already built, which is what a test or a build assembling its anchors from elsewhere reaches for.

func NewWithGetter

func NewWithGetter(client Getter, identity Identity, vendor []byte, product *Product) *Service

NewWithGetter returns a verifier whose roster fetches go through client.

It takes client, the HTTP surface the roster is fetched over; identity, the machine's half of the proof; vendor, the ed25519 public key the binary links in; and product, the vendor-specific facts — a nil product uses the documented fallbacks.

func RequiresUpdate

func RequiresUpdate(current string, floor string) bool

RequiresUpdate reports whether current is below the floor a roster mandates.

It takes the running build's version and the minimum the roster requires — an empty floor requires nothing — and reports whether the build is below it.

func UpdateRefusal

func UpdateRefusal(current string, floor string) error

UpdateRefusal builds the typed refusal for a build below the roster's floor.

It takes the running build's version and the minimum the roster requires, and returns an *UpdateRequiredError carrying both.

type BearerFetch

BearerFetch performs the one request a Service makes with a credential: the GitHub Actions ID-token mint on the CI path, which sends the runner's bearer token. It is a function rather than a Getter method because the roster fetch must never carry one.

Service.WithBearerFetch replaces the default, and only a test should need to: the default refuses every redirect, so the runner's credential cannot be forwarded to a destination nobody vouched for, and a replacement takes that duty on. It aliases the service type (ADR 0074).

type BearerFetch = svcent.BearerFetch

BearerFetch is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.BearerFetch, declared as:

type BearerFetch func(url string, bearer string) (resp *http.Response, err error)

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 when it is asked.

Implement it when your key custody can bind, and the verifier will prefer it:

func (k *MyKeys) ProvePossessionFor(subject, authorised string) error

Without it the engine asks Fingerprint what you present, compares that answer itself, and then asks ProvePossession — so material replaced between the two calls is signed for, and the strongest thing an implementation can do unaided is notice that something changed without learning what it should have been.

It is a SIBLING rather than a parameter on ProvePossession because Identity is published through this alias and Go satisfies interfaces structurally: widening it would break every implementation at compile time with no deprecation window, and would not even guarantee the binding — an implementation can accept the parameter and ignore it. ADR 0039, ADR 0040 §4, ADR 0092.

An Identity that does not implement this is fully supported and unchanged. The window stays open for it, which is said here rather than left to be found.

type BoundProver = coreent.BoundProver

BoundProver is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.BoundProver, declared as:

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 Bundle

Bundle is the one-document roster form: the raw roster and its detached signature in a single object, because two documents cannot be fetched atomically. It aliases the service type (ADR 0074) — a caller BUILDS one to serve or to cache, which is why it is public at all.

type Bundle = svcent.BundleValue

Bundle is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.BundleValue, declared as:

type Bundle struct {
	// Payload is the roster document, base64. Carrying the bytes rather than
	// a nested object is what keeps the signature verifiable: it covers this
	// exact byte sequence, and re-serialising a decoded struct would not
	// reproduce it.
	Payload   string `json:"payload"`
	// Signature is the vendor's detached ed25519 signature over the decoded
	// payload, base64.
	Signature string `json:"sig"`
}

type CIEntitlement

CIEntitlement is a roster's seat for a continuous-integration account.

type CIEntitlement = coreent.CIEntitlementValue

CIEntitlement is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.CIEntitlementValue, declared as:

type CIEntitlement 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 Getter

Getter performs the roster HTTP GETs. A consumer substitutes it to exercise the admission logic without a network, which is the only way to test a refusal path that depends on what an origin answered.

type Getter = svcent.Getter

Getter is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.Getter, declared as:

type Getter interface {
	// Get retrieves a URL.
	Get(url string) (*http.Response, error)
}

func (Getter) Get
func (Getter) Get(url string) (*http.Response, error)

Get retrieves a URL.

type Grant

Grant is a verified entitlement, with the deadline past which it must be re-verified and whether it was decided offline.

Read Deadline() rather than the NotAfter field when you intend to ACT on the deadline: NotAfter's zero value means "no deadline was computed" — the shape a grant seeded from a bare timestamp carries — and the method resolves the fallback the field cannot express. Expired(now) is the same question asked the other way round, and the two are one rule by construction.

Honouring it is YOURS. See the package comment.

type Grant = coreent.GrantValue

Grant is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.GrantValue, declared as:

type Grant 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 (Grant) Deadline
func (*Grant) 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 (Grant) Expired
func (*Grant) 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. It aliases the core port.

type Identity = coreent.Identity

Identity is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.Identity, declared as:

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 Origin

Origin is one place a roster is published.

type Origin = coreent.OriginValue

Origin is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.OriginValue, declared as:

type Origin 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 Product

Product names the vendor whose roster this binary trusts. It aliases the service type: these are one engine's construction parameters (ADR 0074).

type Product = svcent.ProductValue

Product is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.ProductValue, declared as:

type Product struct {
	// Name is the product's binary name. It scopes the cache directory and
	// labels generated key material.
	Name       string
	// Origins are the roster publication locations, tried in order. The first
	// that answers with an authentic bundle wins.
	Origins    []coreent.OriginValue
	// CIAudience is the OIDC audience a CI provenance token must carry. It must
	// be unique to this product: a token minted for one audience must not
	// satisfy another's gate.
	CIAudience string
	// EnrolURL is where a new subject is directed to request enrolment. Empty
	// means the product offers no self-service path.
	EnrolURL   string
}

func (Product) DefaultCacheDir
func (*Product) DefaultCacheDir() string

DefaultCacheDir returns where the last authenticated roster is kept.

An empty string means "no cache", which disables the offline fallback entirely rather than guessing at a location. That is the safe direction: a machine that cannot say where its cache lives falls back to requiring the network, which is the behaviour this package had before the cache existed.

func (Product) Label
func (*Product) Label() string

Label is the name this product is known by, falling back to the package name when no product was configured.

The receiver is a pointer on every method here because ProductValue is 72 bytes — four strings and a slice header — and copying all of it to read one field is what KTN-VAR-BIGSTRUCT exists to catch. Hold the product in a variable and call on that.

It tolerates a nil receiver, as every method here does: the one code path that runs when nothing else is working must not be the one that panics.

It returns the product's name, or "entitlement" when there is none.

func (Product) PublishedOrigins
func (*Product) PublishedOrigins() []coreent.OriginValue

PublishedOrigins returns the places this product's roster is published, tolerating a nil receiver as every accessor here does.

It exists because the FIELD cannot: reading p.Origins off a nil product panics, and the two constructors did exactly that while the package documented the opposite. A nil product publishes nowhere, which is a verifier that will refuse with RosterUnreachable — the documented fallback, and a very different outcome from a panic at construction.

It returns the product's publication points, or nil when there is no product.

func (Product) Validate
func (*Product) Validate() error

Validate reports the ways a product's origin list would fail it when it matters, which is the one moment nobody is watching: the day the roster cannot be fetched.

The source implementation asserted these as a test over its own shipped constants. Moving the origins to the caller would have moved the property out of reach with them, so the assertions become a method any consumer can call — at construction, or in its own suite.

Four things are checked, and the last is the one that is easy to get wrong:

  • no empty Name or BundleURL, since an origin that cannot be named cannot be reported when it fails;
  • no duplicate name;
  • no duplicate URL, because two entries at one URL are one origin listed twice rather than a fallback;
  • at least two DISTINCT hosts. Independent publication paths within one repository do count — that is what survives a ruleset refusing the signing bot's push to the default branch — but they must not all resolve to the same host.

It returns a joined error naming every failure rather than the first, because a caller fixing an origin list wants the whole list.

type Roster

Roster is a published, vendor-signed entitlement roster.

type Roster = coreent.RosterValue

Roster is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.RosterValue, declared as:

type Roster 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 (Roster) CIEntitlementFor
func (*Roster) 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 (Roster) SubjectFor
func (*Roster) 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 RoughtimeServer

RoughtimeServer is one Roughtime server a Service may ask for a signed statement of the current time, and the ed25519 key its answer must be signed with: the key, not the address, is the whole trust decision.

Service.WithTimeServers takes the list. The check is off by default, and ADVISORY: a server that does not answer, or whose answer does not verify, is no signal and the verification carries on, while one that answers verifiably and disagrees with the local clock by more than five minutes, widened by its own stated uncertainty, refuses with ErrClockRegressed. No server ships with the SDK: this client has been checked against its own test fixture and never against a live server, so adding one is a decision for whoever can watch it verify a real answer. It aliases the service type (ADR 0074).

type RoughtimeServer = svcent.RoughtimeServerValue

RoughtimeServer is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.RoughtimeServerValue, declared as:

type RoughtimeServer struct {
	// Name identifies the server in diagnostics.
	Name      string
	// Address is "host:port", reached over UDP.
	Address   string
	// PublicKey is the server's long-term ed25519 public half, which signs
	// the delegation that in turn signs each response.
	PublicKey ed25519.PublicKey
}

type Service

Service verifies entitlement. It aliases the service handle (ADR 0074).

type Service = svcent.Service

Service is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.Service, declared as:

type Service struct{}

func (Service) Verify
func (*Service) Verify(now time.Time) (grant coreent.GrantValue, err error)

Verify performs a full cold verification: fetch, authenticate, match, and prove possession. Every call reaches for the network first — this is the path a fresh process takes — and when no origin answers it falls back to the last bundle this machine authenticated, which is refused by the same signature and window rules that would refuse it over HTTPS. A machine that has never fetched one successfully still cannot start.

The ORDER below is load-bearing, and neither the offline fallback nor the clock ratchet disturbs it. The ratchet runs FIRST, before anything reads a date: a function that is about to compare four deadlines against the local clock should establish that the clock has not been moved before it starts, not after. The mandatory-update floor runs BEFORE the subject is looked up on purpose: an out-of-date binary must be told to upgrade whether or not its licence is also in order, and the upgrade path is the same either way. Because the cached roster is substituted inside currentRoster rather than around it, that floor applies to a cached document exactly as it does to a fetched one — which is what stops an out-of-date binary from skipping it by going offline, including by going offline with a copy in hand.

func (Service) WithAnchors
func (*Service) WithAnchors(anchors [][]byte) *Service

WithAnchors replaces the vendor keys this verifier accepts.

It takes the full ORDERED list and does not add to what the constructor recorded: an additive setter would make the order depend on the call sequence, and the order is the build's statement of which anchor is current. The list the caller passes is the list, and the constructor's key is not implicitly first in it.

A separate call rather than a parameter on every constructor because the single-key form is the common one and must keep reading as it does; this is what a test, or a build assembling its anchors from somewhere other than a constant, reaches for. NewServiceWithAnchors is the production spelling.

An empty list is not a way to disable authentication: parseBundleAnyAnchor refuses every document with coreent.ErrRosterUnsigned, because with no anchor no signature can be valid.

func (Service) WithBearerFetch
func (*Service) WithBearerFetch(fetch BearerFetch) *Service

WithBearerFetch replaces the transport used for the Actions token mint.

Only a test should need this. The default refuses redirects, which is what keeps the runner's credential from being forwarded to a destination checkTokenURL never vouched for.

func (Service) WithCache
func (*Service) WithCache(dir string) *Service

WithCache points the offline fallback at a directory, or disables it with "".

Only NewService sets this in production, and only the test constructors leave it empty: a Service built with an injected getter must not touch a real filesystem unless the test asked it to.

func (Service) WithOrigins
func (*Service) WithOrigins(origins []coreent.OriginValue) *Service

WithOrigins replaces the publication points this verifier will try.

It changes WHERE the roster is looked for and nothing about whether the answer is believed: authority comes from the vendor signature, never from the origin that served the bytes, so pointing this at a hostile endpoint gains that endpoint nothing — it can serve whatever it likes and still cannot forge the anchor. That is what makes the setter safe to expose at all.

An empty list is a construction error rather than a way to disable the fetch, and currentRoster says so rather than reporting an unreachable roster nobody asked for.

func (Service) WithTimeServers
func (*Service) WithTimeServers(servers []RoughtimeServerValue) *Service

WithTimeServers points the clock corroboration at a set of Roughtime servers, or disables it with an empty list.

Separate from the constructor because it is off by default and because the list is a trust decision — each entry pins a public key — that belongs to whoever assembled the binary rather than to this package.

func (Service) WithVersion
func (*Service) WithVersion(version string) *Service

WithVersion records the binary's own version so Verify can apply the roster's mandatory-update floor.

It is a separate call rather than a constructor parameter because pkg/license must not import the command package that owns the version string, and because a caller with nothing to declare — a test, a tool embedding the check — should not have to invent one.

type Subject

Subject is one entry in a roster: an identity and what it is entitled to.

type Subject = coreent.SubjectValue

Subject is an alias of github.com/kitsunium/sdk/framework/internal/core/entitlement.SubjectValue, declared as:

type Subject 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"`
}

type UpdateRequiredError

UpdateRequiredError carries the two versions a version-floor refusal is about. It exists as a TYPE rather than a message because a caller that cannot read the required version out of the refusal re-runs the upgrade, gets the same refusal, and loops forever.

type UpdateRequiredError = svcent.UpdateRequiredError

UpdateRequiredError is an alias of github.com/kitsunium/sdk/framework/internal/service/entitlement.UpdateRequiredError, declared as:

type UpdateRequiredError struct {
	// Current is the version of the binary that was refused.
	Current  string
	// Required is the lowest version the roster accepts.
	Required string
}

func (UpdateRequiredError) Error
func (*UpdateRequiredError) Error() string

Error renders both versions: "from what, to what" is the only question anyone asks at that point.

func (UpdateRequiredError) Unwrap
func (*UpdateRequiredError) Unwrap() error

Unwrap ties the typed error to the sentinel so errors.Is keeps working through the exit-code dispatch.

Generated from docs/api by tools/genindex.

Documentation

Overview

Package entitlement verifies a machine's entitlement to run a product: local key material plus a vendor-signed roster, turned into a grant or a typed refusal.

It is the framework's public package over framework/internal/service/entitlement (ADR 0158): a distribution mechanism, built on pkg/v1 and nothing below it.

The trust model, and its ceiling

One signer: the vendor. What the consuming binary links in is that signer's ed25519 public key, or — while a key is being rotated — an ordered list of the keys it accepts. Everything else — the roster, the per-subject keys, the host serving them — is untrusted input. A hostile endpoint can serve what it likes; it cannot forge a signature made with the vendor's private half.

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

The anchor is a list, because one key has no way out

A binary linking in exactly one anchor cannot be moved to another: rosters signed by a new key are refused against the anchor it holds — before anything reads the version floor that would have told it to upgrade — and the release carrying the new anchor is refused by the installation that needs it. Blocked from both sides, with no path left in band.

NewWithAnchors takes an ORDERED list and a roster is authentic when it verifies against any entry, which is all rotation needs: publish under B, installations carrying {A, B} take it, installations carrying only {A} keep reading A until they are updated. A bundle already cached under either key is still a valid proof of time, so the anti-rollback ratchet is not reset by a rotation.

The cost is real and is not hidden: an anchor on the list is a key whose compromise is accepted while it is listed. The list is therefore capped and is a BUILD decision — nothing at runtime can extend it — and an empty one refuses every document rather than accepting every document.

Identity is yours to supply

Proving possession needs key material in a format the standard library cannot parse. Rather than pull golang.org/x/crypto/ssh — and through it golang.org/x/sys, which the SDK and its framework ban — into every consumer's graph, the machine's half of the proof is a PORT:

type Identity interface {
	Discover() (subject string, err error)
	Fingerprint(subject string) (fingerprint string, err error)
	ProvePossession(subject string) error
}

An ssh implementation ships as the connector module framework/connectors/ssh, for consumers who want one and accept the dependency: requiring it is what brings golang.org/x/crypto and golang.org/x/sys into a graph. A consumer that already handles its own key material — which is the common case for a product that enrols its users — implements three methods instead and never requires that module. ADR 0078 has the original measurement, ADR 0079 the split and ADR 0158 the move into the framework.

A proof can be bound to the key the roster authorised

Identity's three methods cannot express "prove possession of the key the roster approved": the engine asks what you present, compares that answer itself, and then asks for a proof, so the authorised value never reaches your code. Anything that replaced the material in between is signed for.

BoundProver is the sibling that carries it. Implement ProvePossessionFor(subject, authorised) and the verifier prefers it; do not, and nothing changes — the three-method port is frozen and stays supported. The SDK's own ssh implementation implements both.

Cannot decide is never no

A cold Verify reaches the network FIRST. Only when no origin answers does the cached bundle substitute, and it goes back through the signature check on every read, so a frozen copy stops authorising at its own expiry. Failure with nothing cached reports RosterUnreachable and names the NETWORK, because reporting an outage as a revocation is the one wrong answer.

What is NOT answered, and cannot be locally, is a frozen CLOCK: every source of time an offline process can read belongs to the party being checked. The ratchet raises the cost; it does not close the hole.

An older genuine roster cannot undo a newer decision

The ratchet also bears on ACCEPTANCE, not only on what gets cached. A roster the vendor signed but has since replaced is refused before it reaches the decision, so a lagging mirror or a substituted origin cannot restore a revoked subject, a rotated-out fingerprint, a lower mandatory-update floor, a withdrawn CI account or a relaxed CI policy. Equal signing instants are accepted when the signed payload is identical — which is every ordinary re-verification — and refused when it is not.

It is CONDITIONAL on the cached bundle persisting, because that bundle IS the mark: the guarantee lapses for a Service built with no cache, for an install the filesystem refused, and for one that stood down on a contended lock. None of the three refuses instead; a contended lock turned into a refused licence would be worse than the replay it would prevent.

Honouring the deadline is the consumer's job

Verify computes a Grant, hands it back and keeps no reference to it. There is no goroutine, no timer and no callback in this SDK that revokes anything when a grant's 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 you look again, and how long that is, is your tick rather than a property of this package. What the package owes you is a deadline you can act on WITHOUT polling, and that is Grant.Deadline(): the EFFECTIVE instant, with the zero-NotAfter fallback already resolved. Schedule on it — a timer, a context deadline — and the question gets asked when the answer changes instead of on a cadence. Grant.Expired(now) is the same rule asked the other way round.

A CI seat cannot outlive its token

BEHAVIOUR CHANGE. A grant issued for a proven GitHub Actions run is now bounded by the Actions token's own expiry as well as by the roster's window and the account's term. The effective bound on a CI seat therefore drops from up to 24 h to at most 30 minutes — maxTokenLifetime, and in practice the few minutes a real token carries. A long-running process on a runner that aged against a CI grant for hours must now re-verify inside that window.

No clock skew is added to the bound. The two-minute allowance exists to ADMIT a token whose clock disagrees slightly, which is permissive and correct; applying it to a bound would extend the grant past the proof. Together they are what makes the exposure of a leaked token statable at all: one free seat PER VERIFIER for up to 32 minutes. A short token bounds the DURATION a stolen proof keeps working, never the NUMBER of verifiers that will accept it at once.

Index

Constants

View Source
const CodeAmbiguousLicence errs.Code = coreent.CodeAmbiguousLicence

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

View Source
const CodeCINotEntitled errs.Code = coreent.CodeCINotEntitled

CodeCINotEntitled identifies a CI account the roster does not cover.

View Source
const CodeCIUnknownKey errs.Code = coreent.CodeCIUnknownKey

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

View Source
const CodeCIUnverifiable errs.Code = coreent.CodeCIUnverifiable

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

View Source
const CodeClockRegressed errs.Code = coreent.CodeClockRegressed

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

View Source
const CodeKeyMismatch errs.Code = coreent.CodeKeyMismatch

CodeKeyMismatch identifies a local key that does not match the published fingerprint.

View Source
const CodeLicenceExpired errs.Code = coreent.CodeLicenceExpired

CodeLicenceExpired identifies a subject whose own term has closed.

View Source
const CodeNoLicence errs.Code = coreent.CodeNoLicence

CodeNoLicence identifies a machine with no entitlement key at all.

View Source
const CodeNoPossession errs.Code = coreent.CodeNoPossession

CodeNoPossession identifies a holder who could not prove possession.

View Source
const CodeProductInvalid errs.Code = coreent.CodeProductInvalid

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

View Source
const CodeRevoked errs.Code = coreent.CodeRevoked

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

View Source
const CodeRosterStale errs.Code = coreent.CodeRosterStale

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

View Source
const CodeRosterUnreachable errs.Code = coreent.CodeRosterUnreachable

CodeRosterUnreachable identifies a check that could not decide either way. It is NOT a refusal, and a caller that treats it as one turns an outage into a revocation.

View Source
const CodeRosterUnsigned errs.Code = coreent.CodeRosterUnsigned

CodeRosterUnsigned identifies a roster carrying no valid vendor signature — the spoofing signal, since a substituted endpoint cannot produce one.

View Source
const CodeUpdateRequired errs.Code = coreent.CodeUpdateRequired

CodeUpdateRequired identifies a build below the floor the roster mandates.

RosterLifetime is how long a signed roster stays usable. It bounds how long a revoked subject keeps working offline, and how long a captured roster can be replayed — the same bound, read from either side.

Variables

View Source
var (
	// ErrNoLicense identifies a machine with no entitlement key at all.
	ErrNoLicense = coreent.ErrNoLicense

	// ErrRosterUnsigned identifies a roster carrying no valid vendor signature.
	ErrRosterUnsigned = coreent.ErrRosterUnsigned

	// ErrRosterStale identifies a roster that verified but no longer authorises:
	// its own window has closed, or a newer signed decision has superseded it.
	ErrRosterStale = coreent.ErrRosterStale

	// ErrRevoked identifies a subject absent from a roster that was itself valid.
	ErrRevoked = coreent.ErrRevoked

	// ErrLicenseExpired identifies a subject whose own term has closed.
	ErrLicenseExpired = coreent.ErrLicenseExpired

	// ErrKeyMismatch identifies a local key that does not match the published fingerprint.
	ErrKeyMismatch = coreent.ErrKeyMismatch

	// ErrRosterUnreachable identifies a check that could not decide either way.
	ErrRosterUnreachable = coreent.ErrRosterUnreachable

	// ErrAmbiguousLicense identifies more than one identity on one machine.
	ErrAmbiguousLicense = coreent.ErrAmbiguousLicense

	// ErrCIUnverifiable identifies a CI run whose provenance could not be proven.
	ErrCIUnverifiable = coreent.ErrCIUnverifiable

	// ErrCIUnknownKey identifies a CI token signed by a key the issuer does not publish.
	ErrCIUnknownKey = coreent.ErrCIUnknownKey

	// ErrCINotEntitled identifies a CI account the roster does not cover.
	ErrCINotEntitled = coreent.ErrCINotEntitled

	// ErrNoPossession identifies a holder who could not prove possession.
	ErrNoPossession = coreent.ErrNoPossession

	// ErrClockRegressed identifies a clock behind the last signed document this machine verified.
	ErrClockRegressed = coreent.ErrClockRegressed

	// ErrUpdateRequired identifies a build below the floor the roster mandates.
	ErrUpdateRequired = coreent.ErrUpdateRequired
)

The fourteen sentinels, re-exported so errors.Is answers on them. They are the SAME values core declares, not copies, so a caller may compare across the facade boundary; each also carries the matching Code above, so errs.HasCode and errors.Is are two ways of asking one question.

None is annotated `error`, and that is deliberate. The annotation would erase the concrete type, and with it Code, Reason, Public and ExitCode — every one of which a consumer reads directly off the sentinel to render a message or exit a process. Without it they are reachable only through a type assertion to a type a consumer cannot name, since internal/kernel/errs is internal. pkg/v1/app/lock, pkg/v1/data/cache and pkg/v1/security/authz all declare theirs this way; this package shipped as the outlier, when it was pkg/v1/entitlement. TestTheSentinelsKeepTheirConcreteType fails the BUILD if the annotation comes back.

A consumer that only needs ONE distinction needs this one: ErrRosterUnreachable says "cannot decide", and treating it as "decided no" turns an outage into a revocation.

Functions

func RequiresUpdate

func RequiresUpdate(current, floor string) bool

RequiresUpdate reports whether current is below the floor a roster mandates.

It takes the running build's version and the minimum the roster requires — an empty floor requires nothing — and reports whether the build is below it.

func UpdateRefusal

func UpdateRefusal(current, floor string) error

UpdateRefusal builds the typed refusal for a build below the roster's floor.

It takes the running build's version and the minimum the roster requires, and returns an *UpdateRequiredError carrying both.

Types

type BearerFetch

type BearerFetch = svcent.BearerFetch

BearerFetch performs the one request a Service makes with a credential: the GitHub Actions ID-token mint on the CI path, which sends the runner's bearer token. It is a function rather than a Getter method because the roster fetch must never carry one.

Service.WithBearerFetch replaces the default, and only a test should need to: the default refuses every redirect, so the runner's credential cannot be forwarded to a destination nobody vouched for, and a replacement takes that duty on. It aliases the service type (ADR 0074).

type BoundProver

type BoundProver = coreent.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 when it is asked.

Implement it when your key custody can bind, and the verifier will prefer it:

func (k *MyKeys) ProvePossessionFor(subject, authorised string) error

Without it the engine asks Fingerprint what you present, compares that answer itself, and then asks ProvePossession — so material replaced between the two calls is signed for, and the strongest thing an implementation can do unaided is notice that something changed without learning what it should have been.

It is a SIBLING rather than a parameter on ProvePossession because Identity is published through this alias and Go satisfies interfaces structurally: widening it would break every implementation at compile time with no deprecation window, and would not even guarantee the binding — an implementation can accept the parameter and ignore it. ADR 0039, ADR 0040 §4, ADR 0092.

An Identity that does not implement this is fully supported and unchanged. The window stays open for it, which is said here rather than left to be found.

type Bundle

type Bundle = svcent.BundleValue

Bundle is the one-document roster form: the raw roster and its detached signature in a single object, because two documents cannot be fetched atomically. It aliases the service type (ADR 0074) — a caller BUILDS one to serve or to cache, which is why it is public at all.

type CIEntitlement

type CIEntitlement = coreent.CIEntitlementValue

CIEntitlement is a roster's seat for a continuous-integration account.

type Getter

type Getter = svcent.Getter

Getter performs the roster HTTP GETs. A consumer substitutes it to exercise the admission logic without a network, which is the only way to test a refusal path that depends on what an origin answered.

type Grant

type Grant = coreent.GrantValue

Grant is a verified entitlement, with the deadline past which it must be re-verified and whether it was decided offline.

Read Deadline() rather than the NotAfter field when you intend to ACT on the deadline: NotAfter's zero value means "no deadline was computed" — the shape a grant seeded from a bare timestamp carries — and the method resolves the fallback the field cannot express. Expired(now) is the same question asked the other way round, and the two are one rule by construction.

Honouring it is YOURS. See the package comment.

type Identity

type Identity = coreent.Identity

Identity is the machine's half of the proof. It aliases the core port.

type Origin

type Origin = coreent.OriginValue

Origin is one place a roster is published.

type Product

type Product = svcent.ProductValue

Product names the vendor whose roster this binary trusts. It aliases the service type: these are one engine's construction parameters (ADR 0074).

type Roster

type Roster = coreent.RosterValue

Roster is a published, vendor-signed entitlement roster.

type RoughtimeServer

type RoughtimeServer = svcent.RoughtimeServerValue

RoughtimeServer is one Roughtime server a Service may ask for a signed statement of the current time, and the ed25519 key its answer must be signed with: the key, not the address, is the whole trust decision.

Service.WithTimeServers takes the list. The check is off by default, and ADVISORY: a server that does not answer, or whose answer does not verify, is no signal and the verification carries on, while one that answers verifiably and disagrees with the local clock by more than five minutes, widened by its own stated uncertainty, refuses with ErrClockRegressed. No server ships with the SDK: this client has been checked against its own test fixture and never against a live server, so adding one is a decision for whoever can watch it verify a real answer. It aliases the service type (ADR 0074).

type Service

type Service = svcent.Service

Service verifies entitlement. It aliases the service handle (ADR 0074).

func New

func New(identity Identity, vendor []byte, product *Product) *Service

New returns a verifier for the given identity, vendor key and product.

One anchor, which is the one-element list NewWithAnchors takes — not a second representation of the same thing, so no path can disagree with the multi-anchor one about what a single key means.

func NewWithAnchors

func NewWithAnchors(identity Identity, anchors [][]byte, product *Product) *Service

NewWithAnchors returns a verifier that accepts SEVERAL vendor keys, in the order given, so a signing key can be rotated in band.

It takes identity, the machine's half of the proof; anchors, the ed25519 public keys the binary links in, most-current first; and product, the vendor-specific facts. A roster is authentic when it verifies against ANY of the anchors, which is what lets a vendor publish under a new key while installations carrying only the old one keep working — neither side has to be updated first.

What it costs, said here rather than only in the ADR

Every anchor on the list is a key whose compromise is ACCEPTED while it is listed, so this is strictly more surface than New's single key. What bounds it is that the list is ordered, capped, and a BUILD decision: nothing at runtime can extend it — not a roster field, not an environment variable, not the cache — and a key leaves the list the way it entered, by shipping a build without it. Past the cap the tail is dropped and a line is logged, because a build mistake answered by refusing every verification would take a product down over a misconfiguration.

An EMPTY list is not a way to disable the signature check: every document is then refused with ErrRosterUnsigned, since with no anchor no signature can be valid.

Service.WithAnchors sets the same list on a verifier already built, which is what a test or a build assembling its anchors from elsewhere reaches for.

func NewWithGetter

func NewWithGetter(client Getter, identity Identity, vendor []byte, product *Product) *Service

NewWithGetter returns a verifier whose roster fetches go through client.

It takes client, the HTTP surface the roster is fetched over; identity, the machine's half of the proof; vendor, the ed25519 public key the binary links in; and product, the vendor-specific facts — a nil product uses the documented fallbacks.

type Subject

type Subject = coreent.SubjectValue

Subject is one entry in a roster: an identity and what it is entitled to.

type UpdateRequiredError

type UpdateRequiredError = svcent.UpdateRequiredError

UpdateRequiredError carries the two versions a version-floor refusal is about. It exists as a TYPE rather than a message because a caller that cannot read the required version out of the refusal re-runs the upgrade, gets the same refusal, and loops forever.

Jump to

Keyboard shortcuts

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