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(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(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(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(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(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.
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)
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(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.
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"`
}
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"`
}
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.
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() 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(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.
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() (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(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(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.
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
}
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() 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() []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() 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.
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(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(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.
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
}
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(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(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(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(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(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(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.
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"`
}
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.