Reference
Lease protocol v1
This page is normative. sigil-lease implements the verifier side and the Sigil API the issuer side. If the code and this page disagree, one of them has a bug.
Identifiers
| Thing | Form |
|---|---|
| ids | <prefix>_<16 Crockford base32, lower case>: acc_, app_, pol_, lic_, act_, key_, evt_ |
| license key | SGL-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX, Crockford base32, 125 random bits |
| key hash | hex(SHA-256("sigil-key:v1:" ‖ normalize(key))) |
| fingerprint | base64url(SHA-256("sigil-fp:v1:" ‖ app_id ‖ ":" ‖ raw_id)), 43 characters |
normalize(key): upper-case; drop whitespace and -; drop a leading SGL; then map I, L → 1 and O → 0. Test vector: fingerprint("app_demo", "install-123") = 3oQyFQrW_5f_3eBFIiP4w0CRrhY6SOIEraP_iScxxPI.
Lease
A compact JWS with three base64url (unpadded) segments. The header is exactly {"alg":"EdDSA","typ":"sigil+jwt","kid":"key_…"}.
| Claim | Type | Meaning |
|---|---|---|
iss | "sigil" | |
aud | string | application id |
sub | string | license id |
jti | string | activation id |
iat, nbf, exp | int (s) | issued, not before, expiry. exp = min(iat + ttl, license.expires_at) |
lty | "lease" | "offline" | online activation or air-gapped issuance |
fp | string | fingerprint |
plt | "chrome-extension" | "server" | "browser" | platform |
ent | string[] | entitlements; "*" grants all |
gr | int (s) | grace after exp |
lex | int (s), optional | license term end; hard stop |
lic | string, optional | licensee display name |
sv | 1 | spec version |
Verification, in this order
Inputs: the lease, the keyset compiled in, raw_id, the platform and now, all supplied by the host.
- Three segments, the header parses,
alg == "EdDSA",typ == "sigil+jwt". ElseSIGIL_MALFORMED. kidis in the keyset. ElseSIGIL_UNKNOWN_KEY.- The Ed25519 signature over
segment0 "." segment1verifies. ElseSIGIL_BAD_SIGNATURE. Nothing in the payload is used before this. sv == 1,iss == "sigil",exppresent. ElseSIGIL_MALFORMED.aud ==the keyset's app. ElseSIGIL_WRONG_APP.fp == fingerprint(aud, raw_id). ElseSIGIL_WRONG_MACHINE.plt ==platform. ElseSIGIL_WRONG_PLATFORM.now + 300 ≥ nbfand≥ iat. ElseSIGIL_NOT_YET_VALID.lexabsent ornow ≤ lex. ElseSIGIL_EXPIRED.now ≤ exp→ valid;now ≤ exp + gr→ grace; elseSIGIL_EXPIRED.
Per protected call, a Gate re-runs 9–10 against the current now. It fails SIGIL_CLOCK_ROLLBACK if now is earlier than the latest reading minus the skew, and SIGIL_NOT_ENTITLED if the feature isn't granted.
Keyset
GET /v1/client/apps/:app/keys returns { "app", "keys": [{ "kid", "kty": "OKP", "crv": "Ed25519", "x" }] }, with the active, standby and retired keys, unmarked. A build embeds it, and trust is fixed at build time.
Key rotation
Each application has one active key, which signs, and one standby key, which is published but signs nothing. POST /v1/apps/:id/keys/rotate promotes the standby to active, retires the old active key (still published) and creates a new standby. It answers {kid, retired, standby}, or 409 NO_STANDBY if there is none to promote. Builds that embedded a keyset containing the promoted key keep working without a rebuild. Older builds fail SIGIL_UNKNOWN_KEY at their next lease. Ship every release with the current keyset, and ship the new one after each rotation.
A leaked key stays trusted by the builds that embedded it, even after it is retired, until those builds are replaced.