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

ThingForm
ids<prefix>_<16 Crockford base32, lower case>: acc_, app_, pol_, lic_, act_, key_, evt_
license keySGL-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX, Crockford base32, 125 random bits
key hashhex(SHA-256("sigil-key:v1:" ‖ normalize(key)))
fingerprintbase64url(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_…"}.

ClaimTypeMeaning
iss"sigil"
audstringapplication id
substringlicense id
jtistringactivation id
iat, nbf, expint (s)issued, not before, expiry. exp = min(iat + ttl, license.expires_at)
lty"lease" | "offline"online activation or air-gapped issuance
fpstringfingerprint
plt"chrome-extension" | "server" | "browser"platform
entstring[]entitlements; "*" grants all
grint (s)grace after exp
lexint (s), optionallicense term end; hard stop
licstring, optionallicensee display name
sv1spec version

Verification, in this order

Inputs: the lease, the keyset compiled in, raw_id, the platform and now, all supplied by the host.

  1. Three segments, the header parses, alg == "EdDSA", typ == "sigil+jwt". Else SIGIL_MALFORMED.
  2. kid is in the keyset. Else SIGIL_UNKNOWN_KEY.
  3. The Ed25519 signature over segment0 "." segment1 verifies. Else SIGIL_BAD_SIGNATURE. Nothing in the payload is used before this.
  4. sv == 1, iss == "sigil", exp present. Else SIGIL_MALFORMED.
  5. aud == the keyset's app. Else SIGIL_WRONG_APP.
  6. fp == fingerprint(aud, raw_id). Else SIGIL_WRONG_MACHINE.
  7. plt == platform. Else SIGIL_WRONG_PLATFORM.
  8. now + 300 ≥ nbf and ≥ iat. Else SIGIL_NOT_YET_VALID.
  9. lex absent or now ≤ lex. Else SIGIL_EXPIRED.
  10. now ≤ exp → valid; now ≤ exp + gr → grace; else SIGIL_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.