Permit:   Signed Product Rights
1 Trust stores
read-trust-store
jsexpr->trust-store
trust-store?
trust-store-issuer
2 Verifying credentials
verify-credential
verified-credential?
verified-credential-type
verified-credential-id
verified-credential-issuer
verified-credential-issued-at
verified-credential-entitlement-id
verified-credential-revision
verified-credential-subject-type
verified-credential-subject-id
verified-credential-subject-display
verified-credential-channel
verified-credential-use-expires-at
verified-credential-updates-through
verified-credential-features-through
2.1 Verification failures
exn:  fail:  permit
exn:  fail:  permit:  verify
verify-failure-reasons
3 Evaluating policy
credential-usable?
credential-status
activation-valid?
trial-extension-valid?
edition-entitled?
feature-entitled?
update-entitled?
capability-value
limit-value
version-allowed?
select-latest-entitlement
4 Issuing credentials
generate-ed25519-keypair
read-ed25519-private-key
read-ed25519-public-key
ed25519-private-key->der
ed25519-public-key->der
public-key->base64
issued-credential?
issue-credential
write-issued-credential
4.1 Rights snapshots
subject
grant
constraints
entitlement-payload
successor-payload
payload->bytes
4.2 Issuance failures
exn:  fail:  permit:  issue
issue-failure-reasons
5 Command line
6 Local trials and installation identity
generate-installation-id
make-local-trial
evaluate-local-trial
local-trial-evaluation-state
local-trial-evaluation-active?
local-trial-evaluation-reason
local-trial-evaluation-remaining-seconds
local-trial->jsexpr
jsexpr->local-trial
write-local-trial
read-local-trial
9.4.0.2

Permit: Signed Product Rights🔗ℹ

 (require permit) package: permit

Permit is a framework-neutral protocol and Racket reference implementation for answering one product question: what may this person, organization, or installation use right now? Long-lived entitlements describe commercial rights, optional short-lived activation leases bind a right to an installation, and local first-run trials are their own convenience state. Everything is verified offline against an Ed25519 trust store that ships inside the application; issuance and rotation are online concerns that never change the verification protocol.

Permit is deliberately not anti-tamper DRM, and a permanently offline credential cannot learn about a revocation that happened elsewhere — see the threat model for the honest boundaries. The normative protocol lives in Protocol v1; this manual documents the Racket surface.

1 Trust stores🔗ℹ

Applications ship public keys only. A trust store is JSON application configuration, delivered through the application’s authenticated release path:

{
  "protocol": "permit-trust-store",
  "version": 1,
  "issuer": "com.example",
  "keys": {
    "license-2027": {
      "algorithm": "Ed25519",
      "public_key": "<Base64 SubjectPublicKeyInfo DER>",
      "status": "active",
      "purposes": ["entitlement", "trial", "trial-extension"],
      "not_before": "2027-01-01T00:00:00Z",
      "not_after": null
    }
  }
}

Key statuses are next (pre-distributed, must have not_before), active (normal issuance), retired (no new issuance, must have an not_after issuance cutoff — old credentials stay authentic), and revoked (every credential under the key fails). Each key declares the credential types it may sign; an activation-signing key therefore cannot mint a perpetual entitlement. The time windows constrain the credential’s authenticated issued_at, not the wall clock at verification — retiring a key never kills a perpetual entitlement.

procedure

(read-trust-store source) → trust-store?

  source : (or/c path-string? input-port?)
Parses and validates a trust store document. Unknown key statuses, missing purposes, and wrong algorithms are rejected here.

procedure

(jsexpr->trust-store value) → trust-store?

  value : jsexpr?
The same validation for an already-parsed document.

procedure

(trust-store? v) → boolean?

  v : any/c

procedure

(trust-store-issuer store) → string?

  store : trust-store?
The only credential issuer this store will accept.

2 Verifying credentials🔗ℹ

A credential is a small JSON envelope whose payload is base64 bytes signed exactly with Ed25519. Verification authenticates those bytes first and only then parses authorization claims — the verifier never re-serializes JSON, which removes canonicalization ambiguity across languages.

procedure

(verify-credential source store) → verified-credential?

  source : (or/c path-string? input-port?)
  store : trust-store?
Verifies signature, trust key, issuer, key purpose, and key issuance window, then parses and schema-checks the payload. Any refusal raises exn:fail:permit:verify — an invalid credential is an error, not a boolean — with a reason naming the guard; see Verification failures.

procedure

(verified-credential? v) → boolean?

  v : any/c
Only values returned by verify-credential satisfy this predicate; the constructor is sealed so unverified claims cannot flow into policy checks.

The claims accessors (all (-> verified-credential? ...)):

procedure

(verified-credential-type c) → string?

  c : verified-credential?
"entitlement", "trial", "trial-extension", or "activation".

procedure

(verified-credential-id c) → string?

  c : verified-credential?

procedure

(verified-credential-issuer c) → string?

  c : verified-credential?

procedure

(verified-credential-issued-at c) → string?

  c : verified-credential?

procedure

(verified-credential-entitlement-id c) → (or/c string? #f)

  c : verified-credential?

procedure

(verified-credential-revision c) → exact-nonnegative-integer?

  c : verified-credential?

procedure

(verified-credential-subject-type c) → (or/c string? #f)

  c : verified-credential?

procedure

(verified-credential-subject-id c) → (or/c string? #f)

  c : verified-credential?

procedure

(verified-credential-subject-display c) → (or/c string? #f)

  c : verified-credential?
The customer-facing name (an email address, for example). Display only — never an authorization input.

procedure

(verified-credential-channel c) → (or/c string? #f)

  c : verified-credential?

procedure

(verified-credential-use-expires-at c) → (or/c string? #f)

  c : verified-credential?
Canonical UTC timestamp; #f means the right does not expire.

procedure

(verified-credential-updates-through c) → (or/c string? #f)

  c : verified-credential?

procedure

(verified-credential-features-through c) → (or/c string? #f)

  c : verified-credential?

2.1 Verification failures🔗ℹ

A refusal is always an exception — fail-closed never returns a value — and carries a reason so a product can tell the customer what was wrong with a license file without matching message text. Time outcomes (expiry, not-yet-valid, installation mismatch, missing activation) are not refusals; they come back from credential-status as status reasons. See decision 0002.

struct

(struct exn:fail:permit exn:fail (reason))

  reason : symbol?
The parent of every Permit refusal, on the verifier and the issuer side. exn:fail:permit-reason is the one accessor for both.

struct

(struct exn:fail:permit:verify exn:fail:permit ())

Raised by verify-credential. Its reason is one of verify-failure-reasons. Messages keep the raise-arguments-error layout, so callers that matched message text keep working. A store that is not a trust-store? is a plain contract error, not a refusal.

value

verify-failure-reasons : (listof symbol?)

'(envelope key-unknown key-revoked signature schema issuer key-window key-purpose) in pipeline order — the wrapper could not be read or is not a Protocol v1 envelope; the key_id is not in the store; the key is revoked; the Ed25519 signature does not verify; the authenticated payload failed strict parsing or schema validation; the signed issuer differs from the store’s; issued_at falls outside the key’s issuance window; the key’s purposes do not include the credential type. Because the order is the order a conforming verifier checks in, the reason also tells you which guard refused.

3 Evaluating policy🔗ℹ

Policy evaluation is total and offline: every check takes the verified credential, an evaluation instant ((current-rfc3339) by default), and optional binding parameters.

procedure

(credential-usable? c 
  [#:at at 
  #:installation-id installation-id 
  #:activation activation 
  #:trial-extensions trial-extensions]) 
 → boolean?
  c : verified-credential?
  at : string? = (current-rfc3339)
  installation-id : (or/c string? #f) = #f
  activation : (or/c verified-credential? #f) = #f
  trial-extensions : (listof verified-credential?) = '()
True when the credential is currently within its use window, matches the installation binding if any, and carries a valid activation lease if the entitlement demands one.

procedure

(credential-status c 
  [#:at at 
  #:installation-id installation-id 
  #:activation activation 
  #:trial-extensions trial-extensions]) 
 → (hash/c symbol? any/c)
  c : verified-credential?
  at : string? = (current-rfc3339)
  installation-id : (or/c string? #f) = #f
  activation : (or/c verified-credential? #f) = #f
  trial-extensions : (listof verified-credential?) = '()
The full verdict: 'usable?, 'reasons (a list of symbols such as 'use-expired or 'not-active-yet), and 'effective_use_expires_at after trial extensions.

procedure

(activation-valid? entitlement    
  activation    
  installation-id    
  [#:at at]) → boolean?
  entitlement : verified-credential?
  activation : verified-credential?
  installation-id : string?
  at : string? = (current-rfc3339)
Checks that a lease is current and bound to the exact entitlement id, revision, credential id, issuer, and installation.

procedure

(trial-extension-valid? trial    
  extension    
  [#:at at]) → boolean?
  trial : verified-credential?
  extension : verified-credential?
  at : string? = (current-rfc3339)
Checks the signed relationship and activation time of a trial extension.

procedure

(edition-entitled? c 
  product 
  edition 
  [#:at at 
  #:product-family product-family 
  #:version version 
  #:installation-id installation-id 
  #:activation activation 
  #:trial-extensions trial-extensions]) 
 → boolean?
  c : verified-credential?
  product : string?
  edition : string?
  at : string? = (current-rfc3339)
  product-family : (or/c string? #f) = #f
  version : (or/c string? #f) = #f
  installation-id : (or/c string? #f) = #f
  activation : (or/c verified-credential? #f) = #f
  trial-extensions : (listof verified-credential?) = '()
True when a grant covers the product at the edition and the credential is usable. The same keyword shape applies to feature-entitled?, addon-entitled?, and update-entitled?.

procedure

(feature-entitled? c    
  product    
  feature    
  [#:published-at published-at    
  #:at at ...]) → boolean?
  c : verified-credential?
  product : string?
  feature : string?
  published-at : (or/c string? #f) = #f
  at : string? = (current-rfc3339)
Product-feature check with the published-through window: a feature published after features_through is not covered, so a perpetual customer keeps everything published inside the window they bought. Requires #:published-at — the application’s honest build-time or manifest timestamp for that feature.

procedure

(update-entitled? c    
  product    
  update-published-at    
  [#:at at ...]) → boolean?
  c : verified-credential?
  product : string?
  update-published-at : string?
  at : string? = (current-rfc3339)
Update-rights check against updates_through.

procedure

(capability-value c    
  product    
  capability    
  [#:default default    
  #:at at ...]) → any/c
  c : verified-credential?
  product : string?
  capability : symbol?
  default : any/c = #f
  at : string? = (current-rfc3339)

procedure

(limit-value c    
  limit    
  [#:default default    
  #:at at ...]) → any/c
  c : verified-credential?
  limit : symbol?
  default : any/c = #f
  at : string? = (current-rfc3339)
Scalar capabilities and seat limits (named seats, device activations, concurrent seats) from the credential’s constraints.

procedure

(version-allowed? c version) → boolean?

  c : verified-credential?
  version : string?
SemVer 2.0 check against the credential’s version bounds.

procedure

(select-latest-entitlement credentials) → verified-credential?

  credentials : (and/c (listof verified-credential?) pair?)
Deterministic replacement among credentials actually received: highest revision for the same entitlement_id. This is replacement, not offline revocation.

4 Issuing credentials🔗ℹ

 (require permit/issuer) package: permit

The issuer surface is an explicit opt-in; the client facade above never exports private-key operations. It re-exports trust-store?, read-trust-store, and jsexpr->trust-store so an issuer adapter needs only this module.

procedure

(generate-ed25519-keypair) → 
pk-key? pk-key?

procedure

(read-ed25519-private-key source) → pk-key?

  source : path-string?

procedure

(read-ed25519-public-key source) → pk-key?

  source : path-string?

procedure

(ed25519-private-key->der key) → bytes?

  key : pk-key?

procedure

(ed25519-public-key->der key) → bytes?

  key : pk-key?

procedure

(public-key->base64 key) → string?

  key : pk-key?

procedure

(issued-credential? value) → boolean?

  value : any/c
Recognizes the opaque result of issue-credential.

procedure

(issue-credential payload    
  private-key    
  key-id    
  store) → issued-credential?
  payload : bytes?
  private-key : pk-key?
  key-id : string?
  store : trust-store?
The safe issuance path: schema-validates the payload, pins issuer, active key status, key purpose, and the key’s issuance window, and verifies the private key matches the trust-store public key. The raco permit issue command goes through this function; raco permit sign is the deliberate low-level escape hatch.

procedure

(write-issued-credential credential    
  destination) → void?
  credential : issued-credential?
  destination : (or/c path-string? output-port?)
Serializes an issued credential as its Protocol v1 JSON envelope. This is the supported programmatic path from safe issuance to storage or delivery.

4.1 Rights snapshots🔗ℹ

An issuer builds a complete rights snapshot, serializes it once, and hands those bytes to issue-credential. These constructors spell the closed entitlement schema so that adapters never do; every field is an explicit keyword with a 'null or empty default wherever the schema allows one. See decision 0001 for the boundary this layer defines.

procedure

(subject #:type type    
  #:id id    
  [#:display display]) → jsexpr?
  type : string?
  id : string?
  display : (or/c string? 'null) = 'null
A credential subject. type is one of "person", "organization", "account", "device", "installation", or "anonymous"; id is the issuer’s stable opaque identifier.

procedure

(grant [#:products products    
  #:product-families product-families    
  #:edition edition    
  #:feature-policy feature-policy    
  #:features features    
  #:addons addons    
  #:capabilities capabilities]) → jsexpr?
  products : (listof string?) = '()
  product-families : (listof string?) = '()
  edition : (or/c string? 'null) = 'null
  feature-policy : (or/c "explicit" "published-through")
   = "explicit"
  features : (listof string?) = '()
  addons : (listof string?) = '()
  capabilities : (hash/c symbol? (or/c string? boolean? rational?))
   = (hasheq)
One grant. The scope must name at least one product or product family; "published-through" grants leave features empty and let features_through decide.

procedure

(constraints [#:use-expires-at use-expires-at 
  #:updates-through updates-through 
  #:features-through features-through 
  #:version-min version-min 
  #:version-max version-max 
  #:named-seats named-seats 
  #:device-activations device-activations 
  #:concurrent-seats concurrent-seats 
  #:installation-id installation-id 
  #:activation-required? activation-required?]) 
 → jsexpr?
  use-expires-at : (or/c string? 'null) = 'null
  updates-through : (or/c string? 'null) = 'null
  features-through : (or/c string? 'null) = 'null
  version-min : (or/c string? 'null) = 'null
  version-max : (or/c string? 'null) = 'null
  named-seats : (or/c exact-nonnegative-integer? 'null) = 'null
  device-activations : (or/c exact-nonnegative-integer? 'null)
   = 'null
  concurrent-seats : (or/c exact-nonnegative-integer? 'null)
   = 'null
  installation-id : (or/c string? 'null) = 'null
  activation-required? : boolean? = #f
The three independent windows, version policy, limits, and installation binding. All defaults describe a perpetual, unlimited, unbound right.

procedure

(entitlement-payload #:issuer issuer    
  #:credential-id credential-id    
  #:entitlement-id entitlement-id    
  #:subject subject    
  #:grants grants    
  #:issued-at issued-at    
  [#:type type    
  #:revision revision    
  #:channel channel    
  #:constraints constraints    
  #:not-before not-before    
  #:extensions extensions]) → jsexpr?
  issuer : string?
  credential-id : string?
  entitlement-id : string?
  subject : jsexpr?
  grants : (listof jsexpr?)
  issued-at : string?
  type : (or/c "entitlement" "trial") = "entitlement"
  revision : exact-nonnegative-integer? = 0
  channel : string?
   = (if (equal? type "trial") "trial" "commercial")
  constraints : jsexpr? = (constraints)
  not-before : (or/c string? 'null) = 'null
  extensions : jsexpr? = (hasheq)
A complete entitlement-like payload. The result has already passed validate-credential-claims: an invalid snapshot raises the schema error at the call site instead of inside the signer.

procedure

(successor-payload previous    
  #:credential-id credential-id    
  #:issued-at issued-at    
  [#:subject subject    
  #:channel channel    
  #:grants grants    
  #:constraints constraints    
  #:not-before not-before    
  #:extensions extensions]) → jsexpr?
  previous : jsexpr?
  credential-id : string?
  issued-at : string?
  subject : (or/c jsexpr? #f) = #f
  channel : (or/c string? #f) = #f
  grants : (or/c (listof jsexpr?) #f) = #f
  constraints : (or/c jsexpr? #f) = #f
  not-before : (or/c string? 'null) = 'null
  extensions : (or/c jsexpr? #f) = #f
The next revision of an existing lineage, the only supported spelling of renewal, plan change, seat change, and withdrawal. entitlement_id, issuer, and type are inherited and cannot be replaced; revision becomes previous’s plus one; each #f keyword keeps the previous value. A withdrawal is a successor whose constraints already expired — Permit does not pretend to revoke offline.

procedure

(payload->bytes payload) → bytes?

  payload : jsexpr?
The one serialization step. Sign and store exactly these bytes; the protocol’s exact-byte rule forbids re-serializing the jsexpr afterwards.

4.2 Issuance failures🔗ℹ

struct

(struct exn:fail:permit:issue exn:fail:permit ())

Every refusal by issue-credential after its argument checks; read the reason with exn:fail:permit-reason (see Verification failures for the parent). The reason is one of issue-failure-reasons; the message keeps the raise-arguments-error layout, so callers that matched message text keep working. Argument-shape errors (a non-bytes? payload, a non-trust-store? store) remain ordinary contract errors.

value

issue-failure-reasons : (listof symbol?)

'(schema issuer key-unknown key-status key-window key-purpose key-mismatch) — the payload failed schema validation; its issuer differs from the trust store’s; the key-id is not in the store; the key is not "active"; issued_at falls outside the key’s issuance window; the key’s purposes do not include the credential type; the private key does not correspond to the store’s public key.

5 Command line🔗ℹ

raco permit keygen --private k.der --public pub.der \

           --trust-store trust.json --key-id license-2027 \

           --issuer com.example

raco permit issue  --key k.der --key-id license-2027 \

           --trust-store trust.json --payload ent.json \

           --output customer.permit.json

raco permit verify --trust-store trust.json \

           --credential customer.permit.json

raco permit check  --trust-store trust.json \

           --credential customer.permit.json \

           --product example-app --edition pro

raco permit inspect --credential customer.permit.json

#

inspect is deliberately labelled unverified; never authorize a feature from inspected claims alone.

6 Local trials and installation identity🔗ℹ

procedure

(generate-installation-id) → string?

A random, privacy-preserving installation identifier — not a hardware fingerprint.

procedure

(make-local-trial [#:duration-seconds duration-seconds 
  #:at at 
  #:id id]) 
 → local-trial?
  duration-seconds : exact-positive-integer? = (* 14 24 60 60)
  at : string? = (current-rfc3339)
  id : string? = (generate-local-trial-id)

procedure

(evaluate-local-trial trial 
  [#:at at 
  #:extensions extensions 
  #:issuer issuer 
  #:rollback-tolerance-seconds tolerance]) 
 → local-trial-evaluation?
  trial : local-trial?
  at : string? = (current-rfc3339)
  extensions : (listof verified-credential?) = '()
  issuer : (or/c string? #f) = #f
  tolerance : exact-nonnegative-integer? = 300
Evaluates the trial against the stored clock high-water mark and any verified trial-extension credentials. The result carries the updated state to persist.

procedure

(local-trial-evaluation-state r) → local-trial?

  r : local-trial-evaluation?

procedure

(local-trial-evaluation-active? r) → boolean?

  r : local-trial-evaluation?

procedure

(local-trial-evaluation-reason r) → (or/c symbol? #f)

  r : local-trial-evaluation?

procedure

(local-trial-evaluation-remaining-seconds r)

 → (or/c exact-integer? #f)
  r : local-trial-evaluation?

procedure

(local-trial->jsexpr t) → jsexpr?

  t : local-trial?

procedure

(jsexpr->local-trial v) → local-trial?

  v : jsexpr?

procedure

(write-local-trial t destination) → void?

  t : local-trial?
  destination : path-string?

procedure

(read-local-trial source) → local-trial?

  source : path-string?
A local trial is signed nowhere — it is convenience state with a random id and a last-seen clock high-water mark. That is nuisance resistance, not tamper resistance; a user controlling the machine can reset it.