Docs sweep: align install / integrate / operate / pricing / wire-format

Five-page sweep to match the current daemon state.

install.html:
- Step 6 (first product): "Price (sats)" → reflects the
  currency picker (sats / USD / EUR) shipped in migration 0010.
- Step 7 (first policy): drop the "default slug is consumed by
  public flow" myth — buy page renders a tier picker for any
  product with ≥2 public policies. Add references to entitlements
  catalog, hide-on-buy toggles, marketing bullets, recurring
  subscriptions, and the drag-to-reorder policy grid.

integrate.html:
- "Three official SDKs" → "Four official SDKs" + Go tab + install
  snippet. Notes the daemon's cross-check fixtures assert
  byte-for-byte parity across all four.
- Admin API table: drop "by npub" from the licenses search
  description (backend supports it; UI hasn't surfaced it yet
  since the purchase flow doesn't capture npubs).

operate.html:
- Backups section: drop the imaginary `/data/issuer-key.pem`
  file — the signing keypair lives in the `server_keys` SQLite
  table, not in a PEM file on disk. Mention the self-license
  file path (`/data/keysat-license.txt`).
- Rotation: drop the "v0.1 doesn't support / v0.2 will" framing;
  rotation isn't on the v0.2 / v0.3 roadmap and the v0.1 caveat
  is misleading. Update steps to reflect SQLite-as-keystore.
- Webhook troubleshooting: point at the dedicated
  Webhooks → Failed (DLQ) view rather than the audit log.

pricing.html:
- Creator: 21,000 sats one-time → Free forever (matches actual
  master Keysat configuration).
- Pro: 250,000 sats/yr → 100,000 sats/yr (recurring). Note
  recurring + tier upgrades have shipped; only Zaprite remains
  v0.3.
- Patron: 500,000 sats/yr → 250,000 sats one-time perpetual.
  Differentiation rewritten: perpetual license + direct 1:1
  support (not just "Pro with a badge").
- Active discount-code cap: 5 → 10 (real cap).
- New "Prices shown are a snapshot" note pointing at the
  canonical live source (keysat.xyz#tiers + the buy page).
- Updated unlicensed-caps line to show 5/5/10 with units.

wire-format.html:
- Replace the entirely-fabricated "KS-base32-blob with KSAT magic
  bytes" layout with the actual LIC1 envelope:
  `LIC1-<base32 payload>-<base32 signature>` split on dashes.
- Document BOTH payload versions: v1 (legacy 74-byte fixed) and
  v2 (current default, 83-byte head + variable entitlements
  table). Field offsets, flag bits, signature scope all match
  the daemon source.
- Drop the bogus Crockford-base32 + dash-grouping sections —
  the daemon uses RFC 4648 base32 with single-dash structure
  separators, not grouped-dashes for readability.
- Drop the fabricated hex-dump worked example.
- Porting section now points at `licensing-service/tests/crosscheck/`
  (the actual fixtures location) instead of a Python-SDK path.
- Versioning policy: clarify envelope-tag vs payload-version
  cadence.
This commit is contained in:
Keysat
2026-05-11 19:30:47 -05:00
parent 95a11666d7
commit 23aa121afb
5 changed files with 117 additions and 105 deletions
+10 -7
View File
@@ -132,21 +132,24 @@ payment_methods: <span class="s">[BTC-OnChain, BTC-LightningNetwork]</span></pre
<li><strong>Slug</strong> &mdash; lowercase, hyphens, will appear in the public URL. e.g. <code>bitcoin-ticker-pro</code>.</li> <li><strong>Slug</strong> &mdash; lowercase, hyphens, will appear in the public URL. e.g. <code>bitcoin-ticker-pro</code>.</li>
<li><strong>Display name</strong> &mdash; shown on the buyer&rsquo;s purchase page and on receipts.</li> <li><strong>Display name</strong> &mdash; shown on the buyer&rsquo;s purchase page and on receipts.</li>
<li><strong>Description</strong> &mdash; one or two sentences; rendered as plain text.</li> <li><strong>Description</strong> &mdash; one or two sentences; rendered as plain text.</li>
<li><strong>Price (sats)</strong> &mdash; an integer. e.g. <code>50000</code> for ~$30 USD at current rates.</li> <li><strong>Price</strong> &mdash; the currency picker accepts sats, USD, or EUR. For sats, enter an integer (e.g. <code>50000</code>). For USD/EUR, enter the amount in dollars/euros — Keysat converts to BTC at invoice creation and the buyer pays the locked-in BTC amount.</li>
</ul> </ul>
<p>The product is created with no policies attached. Next:</p> <p>The product is created with no policies attached. Next:</p>
<h2 id="first-policy">Step 7 — Define a default policy</h2> <h2 id="first-policy">Step 7 — Define one or more policies</h2>
<p>Go to <strong>Policies &rarr; Create a new policy</strong>. Pick the product, then:</p> <p>Go to <strong>Policies &rarr; Create a new policy</strong>. Pick the product, then fill in:</p>
<ul> <ul>
<li>Set <strong>slug</strong> to <code>default</code>. This is the policy consumed by the public purchase flow; other slugs are reserved for manual issuance.</li> <li><strong>Slug</strong> &mdash; lowercase id (e.g. <code>basic</code>, <code>pro</code>, <code>annual</code>). Not "special" in any way; the buy page renders a tier picker when a product has two or more public policies, with the initial tier chosen by <code>?policy=&lt;slug&gt;</code> in the URL, then by the policy you mark "most popular", then by cheapest.</li>
<li>Set <strong>duration_seconds</strong>. Common choices: <code>0</code> (perpetual), <code>31536000</code> (1 year), <code>2592000</code> (30 days for trials).</li> <li><strong>Duration</strong>. Common choices: perpetual, 30 days (trial), 1 year. Recurring subscriptions are a separate toggle on the same form &mdash; flip "Recurring subscription" + set a renewal cadence and Keysat handles the cycle (invoice → settle → re-sign) automatically.</li>
<li>Set <strong>max_machines</strong>. Use <code>1</code> for single-seat licenses or <code>0</code> for unlimited.</li> <li><strong>Max devices</strong>. <code>1</code> for single-seat, <code>0</code> for unlimited.</li>
<li>Optionally add <strong>entitlements</strong> &mdash; comma-separated feature flags. These are baked into the signed key and your app reads them at verify time.</li> <li><strong>Entitlements</strong> &mdash; pick from the product's catalog (you set up the catalog when you created the product on the previous step). The picked entitlements are baked into the signed license and your app reads them at verify time. Optionally toggle the "hide on buy page" eye icon on any entitlement to drop it from the tier card without un-granting it &mdash; useful for higher tiers that use "Everything in Basic, plus:" marketing copy.</li>
<li><strong>Marketing bullets</strong> (optional) &mdash; operator-authored ✓ items rendered on the tier card alongside the entitlements. Pure marketing copy, not enforced.</li>
</ul> </ul>
<p>If you're selling a multi-tier product (e.g. Basic / Pro / Max), repeat this step for each tier. Drag the cards in the Policies grid to set the order shown to buyers.</p>
<h2 id="purchase-url">Step 8 — Share your purchase URL</h2> <h2 id="purchase-url">Step 8 — Share your purchase URL</h2>
<p>Your public purchase URL is now live at:</p> <p>Your public purchase URL is now live at:</p>
+7 -2
View File
@@ -58,12 +58,13 @@
</ul> </ul>
<h2 id="sdks">Pick an SDK</h2> <h2 id="sdks">Pick an SDK</h2>
<p>Three official SDKs ship today. They are wire-compatible &mdash; a license issued by your Keysat verifies identically in any of them.</p> <p>Four official SDKs ship today. They are wire-compatible &mdash; a license issued by your Keysat verifies identically in any of them. Cross-check fixtures in the daemon repo prove each SDK accepts the same bytes the daemon mints.</p>
<div class="lang-tabs" role="tablist"> <div class="lang-tabs" role="tablist">
<button class="active" data-lang="ts">TypeScript</button> <button class="active" data-lang="ts">TypeScript</button>
<button data-lang="rs">Rust</button> <button data-lang="rs">Rust</button>
<button data-lang="py">Python</button> <button data-lang="py">Python</button>
<button data-lang="go">Go</button>
</div> </div>
<pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span> <pre class="code lang-pane" data-lang="ts"><span class="c"># npm</span>
@@ -79,6 +80,10 @@ pip install keysat-licensing-client
<span class="c"># or with poetry</span> <span class="c"># or with poetry</span>
poetry add keysat-licensing-client</pre> poetry add keysat-licensing-client</pre>
<pre class="code lang-pane" data-lang="go" style="display:none"><span class="c">// go.mod</span>
go get github.com/keysat-xyz/keysat-client-go
<span class="c">// stdlib only — no third-party Go dependencies</span></pre>
<p>If your language isn&rsquo;t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p> <p>If your language isn&rsquo;t covered, see <a href="wire-format.html">Wire format</a>. The format is small and porting takes about an afternoon.</p>
@@ -235,7 +240,7 @@ result = verifier.<span class="f">verify</span>(license_key_from_user)
<tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/products</code></td><td>Create a product.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/policies</code></td><td>Create a policy.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/discount-codes</code></td><td>Create a discount or comp code.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/licenses/search</code></td><td>Find licenses by email, npub, or invoice.</td></tr> <tr><td><code>GET</code></td><td><code>/v1/admin/licenses</code></td><td>List / search licenses by buyer email or BTCPay invoice id. (Backend also supports npub search; the buyer-side npub capture flow is still in progress.)</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/licenses/&lt;id&gt;/revoke</code></td><td>Revoke a license.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/licenses/&lt;id&gt;/revoke</code></td><td>Revoke a license.</td></tr>
<tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr> <tr><td><code>POST</code></td><td><code>/v1/admin/webhook-endpoints</code></td><td>Register an outbound webhook.</td></tr>
<tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr> <tr><td><code>GET</code></td><td><code>/v1/admin/audit</code></td><td>Read audit log.</td></tr>
+8 -8
View File
@@ -55,9 +55,9 @@
<p>The Keysat backup payload is intentionally tiny. It contains:</p> <p>The Keysat backup payload is intentionally tiny. It contains:</p>
<ul> <ul>
<li>The signing keypair (<code>/data/issuer-key.pem</code>).</li> <li>The SQLite database (<code>/data/keysat.db</code>), which holds the Ed25519 signing keypair in the <code>server_keys</code> table along with all products, policies, licenses, invoices, audit log, webhook subscribers, and operator settings.</li>
<li>The SQLite database (<code>/data/keysat.db</code>).</li>
<li>Migration history.</li> <li>Migration history.</li>
<li>The self-license file at <code>/data/keysat-license.txt</code>, if you've activated a paid Keysat tier.</li>
</ul> </ul>
<p>That&rsquo;s it. No log files (those rotate locally), no caches.</p> <p>That&rsquo;s it. No log files (those rotate locally), no caches.</p>
@@ -83,19 +83,19 @@
<p>The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don&rsquo;t need to re-distribute the public key to your customers.</p> <p>The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don&rsquo;t need to re-distribute the public key to your customers.</p>
<h2 id="signing-key">Rotating the signing key</h2> <h2 id="signing-key">Rotating the signing key</h2>
<p>You generally don&rsquo;t want to rotate the signing key &mdash; doing so invalidates every license you&rsquo;ve ever issued. v0.1 doesn&rsquo;t support rotation; the key is generated once at first start and never changed.</p> <p>You generally don&rsquo;t want to rotate the signing key &mdash; doing so invalidates every license you&rsquo;ve ever issued. There is no admin-UI affordance for rotation today; the key is generated once on first start (and persisted to the <code>server_keys</code> SQLite table) and stays there for the life of the instance.</p>
<p>If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):</p> <p>If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):</p>
<ol> <ol>
<li>Stop Keysat.</li> <li>Stop Keysat.</li>
<li>Move <code>/data/issuer-key.pem</code> aside.</li> <li>Drop the row in the <code>server_keys</code> table (or move the database aside entirely if you also want to start clean).</li>
<li>Restart Keysat &mdash; it will generate a fresh keypair on first run.</li> <li>Restart Keysat &mdash; it will generate a fresh keypair on first run.</li>
<li>Re-issue all active licenses to existing customers using the new key. The admin UI doesn&rsquo;t support bulk re-issuance yet; this is a manual SQL exercise.</li> <li>Re-issue all active licenses to existing customers using the new key. The admin UI doesn&rsquo;t support bulk re-issuance yet; this is a manual SQL + scripted-API exercise.</li>
<li>Push a software update that swaps the embedded public key.</li> <li>Push a software update that swaps the embedded public key in your downstream apps.</li>
</ol> </ol>
<p>The cleaner path, for v0.2 onward, will be to support a rolling rotation where both keys verify for a transition period.</p> <p>A future release may support rolling rotation (two keys verifying during a transition window). It's not on the v0.2 / v0.3 roadmap.</p>
<h2 id="troubleshooting">Troubleshooting</h2> <h2 id="troubleshooting">Troubleshooting</h2>
@@ -107,7 +107,7 @@
<p>BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.</p> <p>BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.</p>
<h3 id="t-webhook">Webhook deliveries failing</h3> <h3 id="t-webhook">Webhook deliveries failing</h3>
<p>Check the audit log in the admin UI &mdash; failed deliveries land there with the response status. Common causes:</p> <p>In the admin UI go to <strong>Webhooks</strong> &mdash; failed deliveries past the 10-attempt retry budget land in the "Failed" filter (the DLQ), with the response status and an inline "Retry" button. The audit log is a secondary source. Common causes:</p>
<ul> <ul>
<li>Endpoint URL no longer reachable. Hit it manually with <code>curl</code> from your laptop to confirm.</li> <li>Endpoint URL no longer reachable. Hit it manually with <code>curl</code> from your laptop to confirm.</li>
<li>Endpoint rejecting on signature mismatch. Verify your endpoint is HMAC-validating against the secret you registered with.</li> <li>Endpoint rejecting on signature mismatch. Verify your endpoint is HMAC-validating against the secret you registered with.</li>
+39 -25
View File
@@ -100,16 +100,16 @@
<div class="tier-card"> <div class="tier-card">
<h3>Creator</h3> <h3>Creator</h3>
<div class="price">21,000<span class="unit">sats</span></div> <div class="price">Free<span class="unit">forever</span></div>
<div class="frequency">one-time, perpetual</div> <div class="frequency">no payment required</div>
<ul> <ul>
<li>Up to 5 products</li> <li>Up to 5 products</li>
<li>Up to 5 policies per product</li> <li>Up to 5 policies per product</li>
<li>Up to 5 active discount codes</li> <li>Up to 10 active discount codes</li>
<li>BTCPay payments (Bitcoin / Lightning)</li> <li>BTCPay payments (Bitcoin / Lightning)</li>
<li>One-time purchases</li> <li>All four SDKs · full wire format</li>
<li>Webhooks, audit log, recovery, analytics opt-in</li>
<li>Self-host on Start9 (always)</li> <li>Self-host on Start9 (always)</li>
<li>Distributed via free codes — ask</li>
</ul> </ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=creator">Get Creator</a> <a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=creator">Get Creator</a>
</div> </div>
@@ -117,13 +117,13 @@
<div class="tier-card featured"> <div class="tier-card featured">
<div class="badge">Most popular</div> <div class="badge">Most popular</div>
<h3>Pro</h3> <h3>Pro</h3>
<div class="price">250,000<span class="unit">sats</span></div> <div class="price">100,000<span class="unit">sats</span></div>
<div class="frequency">per year</div> <div class="frequency">per year (recurring)</div>
<ul> <ul>
<li>Unlimited products / policies / codes</li> <li>Unlimited products / policies / codes</li>
<li>Recurring subscriptions <em>(when shipped, v0.3)</em></li> <li>Recurring subscriptions — trials, grace, auto-renew</li>
<li>Zaprite payments — accept BTC + cards <em>(when shipped, v0.3)</em></li> <li>Zaprite payments — accept BTC + cards <em>(shipping in v0.3)</em></li>
<li>Multi-operator admin <em>(when shipped)</em></li> <li>In-place tier upgrades (proration handled)</li>
<li>Everything in Creator</li> <li>Everything in Creator</li>
</ul> </ul>
<a class="cta" href="https://licensing.keysat.xyz/buy/keysat?policy=pro">Upgrade to Pro</a> <a class="cta" href="https://licensing.keysat.xyz/buy/keysat?policy=pro">Upgrade to Pro</a>
@@ -131,13 +131,15 @@
<div class="tier-card"> <div class="tier-card">
<h3>Patron</h3> <h3>Patron</h3>
<div class="price">500,000<span class="unit">sats</span></div> <div class="price">250,000<span class="unit">sats</span></div>
<div class="frequency">per year</div> <div class="frequency">one-time, perpetual</div>
<ul> <ul>
<li>Same features as Pro</li> <li>Everything in Pro</li>
<li>Perpetual license — one-time, never renews</li>
<li>Direct one-on-one support</li>
<li>"Patron" badge in your admin UI</li> <li>"Patron" badge in your admin UI</li>
<li>Funds Keysat development</li> <li>Listed on the Patrons page at keysat.xyz</li>
<li>Honest upsell — no fake feature gate</li> <li>Early access to release-candidate builds</li>
</ul> </ul>
<a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=patron">Become a Patron</a> <a class="cta secondary" href="https://licensing.keysat.xyz/buy/keysat?policy=patron">Become a Patron</a>
</div> </div>
@@ -145,12 +147,22 @@
</div> </div>
<div class="note"> <div class="note">
<strong>Note on what's gated.</strong> Today the only enforced gates are the <strong>Prices shown are a snapshot.</strong> The canonical source is the live
capacity caps — number of products, policies, and active discount codes. Pro tier cards at <a href="https://keysat.xyz#tiers">keysat.xyz</a> (rendered
will gate the new payment-provider features (recurring billing, Zaprite card dynamically from the master Keysat instance) and
payments) when those ship in v0.3. Patron is functionally identical to Pro — <a href="https://licensing.keysat.xyz/buy/keysat">licensing.keysat.xyz/buy/keysat</a>.
its tier exists for operators who want to fund development beyond what Pro Launch-special discounts (when active) show on those pages with a "LAUNCH
needs to stay in the green. SPECIAL" ribbon and the discount auto-applied; they're not represented here.
</div>
<div class="note">
<strong>What's gated.</strong> Capacity caps (products / policies-per-product /
active discount codes) are enforced at create-time on the Creator tier. Pro
unlocks the <code>recurring_billing</code> entitlement (auto-renewing
subscriptions) and will unlock <code>zaprite_payments</code> (card payments
via Zaprite) when that lands in v0.3. Patron differs from Pro by being a
one-time perpetual license rather than an annual subscription, plus direct
one-on-one support — not a feature gate, a different ownership model.
</div> </div>
<h2 id="what-counts">What the caps count</h2> <h2 id="what-counts">What the caps count</h2>
@@ -182,10 +194,12 @@
<h2 id="unlicensed">Running unlicensed</h2> <h2 id="unlicensed">Running unlicensed</h2>
<p> <p>
Keysat works without any license at all — you'll see "Unlicensed" in the Keysat works without any license at all — you'll see "Unlicensed" in the
sidebar and get the same caps as a Creator-tier operator (5/5/5). The sidebar and get the same caps as a Creator-tier operator
license exists primarily so operators get a real "I bought it" experience (5 products / 5 policies per product / 10 active discount codes). The
and so we can offer the upgrade path to Pro. Hobbyists can run Keysat Creator tier is free either way; the self-license flow exists primarily so
indefinitely without paying us a sat. operators get a real "I bought it" experience for the paid tiers and so we
can offer the upgrade path to Pro. Hobbyists can run Keysat indefinitely
without paying us a sat.
</p> </p>
</main> </main>
+53 -63
View File
@@ -50,68 +50,59 @@
<p class="lead">The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.</p> <p class="lead">The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.</p>
<h2 id="overview">Overview</h2> <h2 id="overview">Overview</h2>
<p>A Keysat license key looks like this on a receipt:</p> <p>A Keysat license key on a receipt looks like this:</p>
<pre class="code">KS-9F2A-7C41-XK22-6D8E-LM77-PQ91</pre> <pre class="code">LIC1-&lt;base32 payload&gt;-&lt;base32 signature&gt;</pre>
<p>Strip the <code>KS-</code> prefix and the dashes, and you have a Crockford base32-encoded blob. Base32-decode that blob, and you get the binary <em>license envelope</em>: a fixed-layout struct followed by an Ed25519 signature.</p> <p>Three parts, separated by single dashes:</p>
<ul>
<li><code>LIC1</code> &mdash; literal envelope tag. Future format revisions get a new tag (<code>LIC2</code> etc.). Parsers MUST reject unknown tags.</li>
<li><code>&lt;base32 payload&gt;</code> &mdash; the signed payload bytes, RFC 4648 base32 without padding (case-insensitive on decode). Variable length depending on payload version and number of entitlements.</li>
<li><code>&lt;base32 signature&gt;</code> &mdash; the 64-byte Ed25519 signature over the <em>raw payload bytes</em>, base32-encoded the same way.</li>
</ul>
<p>To verify: split on <code>-</code>, validate the tag is <code>LIC1</code>, base32-decode both chunks (case-fold to upper), parse the payload, and verify the signature bytes against the raw payload bytes using the issuer&rsquo;s Ed25519 public key.</p>
<h2 id="layout">Binary layout</h2> <h2 id="versions">Two payload versions</h2>
<p>All multi-byte integers are big-endian.</p> <p>Keysat ships two payload versions today. v2 is the current default that the daemon issues; v1 verifiers stay in the SDKs forever so legacy keys keep verifying.</p>
<h3>v1 (legacy, fixed 74 bytes)</h3>
<p>Issued by the very early daemon builds. No expiry, no entitlements &mdash; perpetual only, fingerprint binding optional. Still accepted on parse so old customer keys don&rsquo;t break.</p>
<table class="t"> <table class="t">
<thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead> <thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
<tbody> <tbody>
<tr><td><code>0</code></td><td>4</td><td>Magic</td><td>ASCII <code>KSAT</code> (0x4B 0x53 0x41 0x54).</td></tr> <tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x01</code></td></tr>
<tr><td><code>4</code></td><td>1</td><td>Version</td><td>Currently <code>0x01</code>. Decoders MUST reject unknown versions.</td></tr> <tr><td><code>1</code></td><td>1</td><td>flags</td><td>Bit 0: fingerprint-bound. Other bits reserved.</td></tr>
<tr><td><code>5</code></td><td>1</td><td>Flags</td><td>Bit 0: <code>TRIAL</code>. Bit 1: <code>PERPETUAL</code>. Bits 2&ndash;7 reserved.</td></tr> <tr><td><code>2</code></td><td>16</td><td>product_id</td><td>UUID, big-endian bytes.</td></tr>
<tr><td><code>6</code></td><td>16</td><td>License ID</td><td>UUIDv4 binary form.</td></tr> <tr><td><code>18</code></td><td>16</td><td>license_id</td><td>UUID, big-endian bytes.</td></tr>
<tr><td><code>22</code></td><td>16</td><td>Issuer fingerprint</td><td>SHA-256 of the issuer public key, truncated to 16 bytes.</td></tr> <tr><td><code>34</code></td><td>8</td><td>issued_at</td><td>Unix seconds, u64 big-endian.</td></tr>
<tr><td><code>38</code></td><td>8</td><td>Issued-at</td><td>Unix seconds, signed.</td></tr> <tr><td><code>42</code></td><td>32</td><td>fingerprint_hash</td><td>SHA-256 of the machine fingerprint; all zeros if not bound.</td></tr>
<tr><td><code>46</code></td><td>8</td><td>Expires-at</td><td>Unix seconds, signed. <code>0</code> if <code>PERPETUAL</code> flag is set.</td></tr>
<tr><td><code>54</code></td><td>2</td><td>Seats</td><td>Max machines. <code>0</code> = unlimited.</td></tr>
<tr><td><code>56</code></td><td>2</td><td>Payload length</td><td>Length <code>L</code> of the variable-size payload that follows.</td></tr>
<tr><td><code>58</code></td><td><code>L</code></td><td>Payload</td><td>UTF-8 JSON: <code>{ "product": "...", "policy": "...", "entitlements": [...] }</code>.</td></tr>
<tr><td><code>58 + L</code></td><td>64</td><td>Signature</td><td>Ed25519 signature over bytes <code>0 .. (58 + L)</code>.</td></tr>
</tbody> </tbody>
</table> </table>
<h2 id="encoding">Crockford base32</h2> <h3>v2 (current default, variable length)</h3>
<p>Keysat uses <a href="https://www.crockford.com/base32.html">Crockford&rsquo;s base32 alphabet</a> (<code>0123456789ABCDEFGHJKMNPQRSTVWXYZ</code>) without checksum, without padding, and case-insensitive on decode.</p> <p>83-byte fixed head + variable-length entitlements table. v2 adds expiry, trial flag, and entitlements &mdash; all signed so offline verifiers can gate features without contacting the server (a stripped entitlement or pushed-back expiry would have to match a valid signature, which the attacker can&rsquo;t produce).</p>
<table class="t">
<p>The reason for Crockford over standard base32: human-friendly. <code>I</code>, <code>L</code>, <code>O</code>, <code>U</code> are excluded from the alphabet to avoid ambiguity when typing keys off a printed receipt.</p> <thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
<tbody>
<h2 id="grouping">Dash grouping &amp; prefix</h2> <tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x02</code></td></tr>
<p>For display, keys are upper-cased, then grouped into 4-character chunks separated by dashes, and prefixed with <code>KS-</code>:</p> <tr><td><code>1</code></td><td>1</td><td>flags</td><td>Bit 0: fingerprint-bound. Bit 1: trial (best-effort hint for clients).</td></tr>
<tr><td><code>2</code></td><td>16</td><td>product_id</td><td>UUID, big-endian bytes.</td></tr>
<pre class="code"><span class="c">// raw base32, length depends on payload size</span> <tr><td><code>18</code></td><td>16</td><td>license_id</td><td>UUID, big-endian bytes.</td></tr>
9F2A7C41XK226D8ELM77PQ91RR54VV01 <tr><td><code>34</code></td><td>8</td><td>issued_at</td><td>Unix seconds, u64 big-endian.</td></tr>
<tr><td><code>42</code></td><td>8</td><td>expires_at</td><td>Unix seconds, u64 big-endian. <code>0</code> means perpetual.</td></tr>
<span class="c">// grouped + prefixed for display</span> <tr><td><code>50</code></td><td>32</td><td>fingerprint_hash</td><td>SHA-256 of the machine fingerprint; all zeros if not bound.</td></tr>
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-RR54-VV01</pre> <tr><td><code>82</code></td><td>1</td><td>entitlements_count</td><td><code>N</code>, 0&ndash;255.</td></tr>
<tr><td><code>83..</code></td><td>variable</td><td>entitlements</td><td><code>N</code> entries, each <code>&lt;len: u8&gt;&lt;ascii bytes&gt;</code>. Each entitlement string is ≤255 bytes.</td></tr>
<p>Decoders MUST strip the <code>KS-</code> prefix (case-insensitive), strip whitespace and dashes, and case-fold to upper before base32-decoding.</p> </tbody>
</table>
<h2 id="signature">Signature</h2> <h2 id="signature">Signature</h2>
<p>The signature covers the entire envelope from offset <code>0</code> through the end of the payload &mdash; that is, all bytes <em>before</em> the 64-byte signature itself.</p> <p>The signature is computed over the <strong>raw payload bytes</strong> &mdash; the binary head plus any entitlements table, without the version tag, without base32 encoding, without dashes. The two base32 chunks in the wire format are encoded <em>independently</em>; concatenating them and base32-decoding the whole would be wrong.</p>
<p>Verify with the issuer&rsquo;s Ed25519 public key (PEM-encoded, SubjectPublicKeyInfo). The SDKs ship the public key bundled in your app at build time; they don&rsquo;t fetch it at runtime. (The whole point of offline verification is that a network-level attacker can&rsquo;t hand your software a different key.)</p>
<p>Verify with the issuer&rsquo;s Ed25519 public key. The fingerprint at offset 22 lets the verifier confirm that the key it has matches the key the license was signed with: SHA-256 the public key bytes, truncate to 16 bytes, compare. If it doesn&rsquo;t match, the verifier MUST reject before attempting signature check &mdash; this gives a clear "wrong issuer" error rather than a generic "bad signature".</p> <h2 id="encoding">Base32 alphabet</h2>
<p>Standard RFC 4648 base32 (alphabet <code>A&ndash;Z, 2&ndash;7</code>), no padding, case-insensitive on decode. The daemon emits uppercase. Decoders MUST strip whitespace and case-fold to upper before decoding.</p>
<h2 id="example">Worked example</h2> <p>Why not Crockford / hex / base58: standard base32 has wide library support, encodes 5 bytes per 8 characters (slightly tighter than hex), is case-insensitive for type-on-receipt scenarios, and avoids the I/O/0/1 ambiguity of base58.</p>
<p>Test vector for the Python SDK&rsquo;s cross-check tests (issuer fingerprint <code>0xfeed face cafe babe...</code>, single-seat perpetual license):</p>
<pre class="code"><span class="c"># Hex dump of the binary envelope</span>
00000000 4B 53 41 54 01 02 9F 2A 7C 41 XK 22 6D 8E LM 77 <span class="c">|KSAT...*|A.."m..w|</span>
00000010 PQ 91 RR 54 VV 01 FE ED FA CE CA FE BA BE 00 00 <span class="c">|...T....|........|</span>
00000020 00 00 00 00 65 4F 12 34 00 00 00 00 00 00 00 00 <span class="c">|....eO.4|........|</span>
00000030 00 01 00 24 7B 22 70 72 6F 64 75 63 74 22 3A 22 <span class="c">|...${"product":"|</span>
00000040 73 75 6E 64 69 61 6C 22 2C 22 70 6F 6C 69 63 79 <span class="c">|sundial","policy|</span>
00000050 22 3A 22 64 65 66 61 75 6C 74 22 7D ...sig... <span class="c">|":"default"}.....|</span>
<span class="c"># As displayed</span>
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-…</pre>
<p>The full vector lives in <code>licensing-client-python/tests/fixtures/canonical.json</code> and is what every official SDK is tested against.</p>
<h2 id="public-key">Issuer public key format</h2> <h2 id="public-key">Issuer public key format</h2>
<p>Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:</p> <p>Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:</p>
@@ -132,33 +123,32 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
<p>The wire format is small enough to port in an afternoon. The order is:</p> <p>The wire format is small enough to port in an afternoon. The order is:</p>
<ol> <ol>
<li>Copy the test vectors from <a href="https://github.com/keysat-xyz/licensing-client-python/blob/main/tests/fixtures/canonical.json">licensing-client-python/tests/fixtures/canonical.json</a>.</li> <li>Pull the canonical cross-check vectors from the daemon repo at <a href="https://github.com/keysat-xyz/keysat/tree/main/licensing-service/tests/crosscheck"><code>licensing-service/tests/crosscheck/</code></a>. Vectors cover v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.</li>
<li>Implement Crockford base32 decode (~30 lines).</li> <li>Implement RFC 4648 base32 decode (most languages have this in stdlib).</li>
<li>Implement the binary unmarshal (~40 lines, mostly offset arithmetic).</li> <li>Implement the binary unmarshal for both v1 and v2 payloads (~80 lines total, mostly big-endian integer reads).</li>
<li>Wire it up to your language&rsquo;s Ed25519 verifier from a vetted crypto library.</li> <li>Wire it up to your language&rsquo;s Ed25519 verifier from a vetted crypto library (libsodium, ring, ed25519-dalek, the Node/Python stdlib, etc.).</li>
<li>Run the cross-check tests &mdash; if they pass, you&rsquo;re wire-compatible.</li> <li>Run the cross-check tests &mdash; if all three vector cases pass byte-for-byte, you&rsquo;re wire-compatible.</li>
</ol> </ol>
<p>See <a href="https://github.com/keysat-xyz/keysat/blob/main/PORTING_SDK_TO_NEW_LANGUAGES.md">PORTING_SDK_TO_NEW_LANGUAGES.md</a> in the repo for the full contributor guide.</p> <p>The four official SDKs (Rust, TypeScript, Python, Go) all sit on top of these same fixtures and the daemon&rsquo;s test suite asserts each implementation round-trips them identically before a release ships.</p>
<h2 id="versioning">Versioning policy</h2> <h2 id="versioning">Versioning policy</h2>
<p>The version byte at offset 4 is a hard gate. Decoders MUST reject any version they don&rsquo;t implement. We commit to:</p> <p>The version byte at payload offset <code>0</code> is a hard gate. Decoders MUST reject any version they don&rsquo;t implement (no graceful skip-over). We commit to:</p>
<ul> <ul>
<li>Never silently changing the v1 layout. Any change &rArr; new version byte.</li> <li>Never silently changing an existing layout. Any field-shape change ⇒ new version byte.</li>
<li>Maintaining v1 verifier support indefinitely &mdash; even if v2 ships, your existing customer keys stay verifiable.</li> <li>Maintaining v1 + v2 verifier support indefinitely &mdash; if v3 ever ships, your existing customer keys still verify against the daemon and the SDKs they shipped with.</li>
<li>Publishing test vectors for every new version under <code>tests/fixtures/</code> in the canonical SDK.</li> <li>The wire-envelope tag (<code>LIC1-…</code>) bumps only on a breaking envelope change &mdash; new payload versions live inside the same envelope tag as long as the split-on-dash structure stays the same.</li>
<li>Publishing test vectors for every payload version under <code>tests/crosscheck/</code> in the daemon repo. All five implementations (daemon, Rust SDK, TypeScript SDK, Python SDK, Go SDK) are required to round-trip the same vectors byte-for-byte before a release ships.</li>
</ul> </ul>
</main> </main>
<aside class="toc"> <aside class="toc">
<div class="label">On this page</div> <div class="label">On this page</div>
<a href="#overview">Overview</a> <a href="#overview">Overview</a>
<a href="#layout">Binary layout</a> <a href="#versions">Payload versions</a>
<a href="#encoding">Crockford base32</a>
<a href="#grouping">Dash grouping</a>
<a href="#signature">Signature</a> <a href="#signature">Signature</a>
<a href="#example">Worked example</a> <a href="#encoding">Base32 alphabet</a>
<a href="#public-key">Public key format</a> <a href="#public-key">Public key format</a>
<a href="#porting">Porting</a> <a href="#porting">Porting</a>
<a href="#versioning">Versioning policy</a> <a href="#versioning">Versioning policy</a>