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:
+53
-63
@@ -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>
|
||||
|
||||
<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-<base32 payload>-<base32 signature></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> — literal envelope tag. Future format revisions get a new tag (<code>LIC2</code> etc.). Parsers MUST reject unknown tags.</li>
|
||||
<li><code><base32 payload></code> — 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><base32 signature></code> — 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’s Ed25519 public key.</p>
|
||||
|
||||
<h2 id="layout">Binary layout</h2>
|
||||
<p>All multi-byte integers are big-endian.</p>
|
||||
<h2 id="versions">Two payload versions</h2>
|
||||
<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 — perpetual only, fingerprint binding optional. Still accepted on parse so old customer keys don’t break.</p>
|
||||
<table class="t">
|
||||
<thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
|
||||
<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>4</code></td><td>1</td><td>Version</td><td>Currently <code>0x01</code>. Decoders MUST reject unknown versions.</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–7 reserved.</td></tr>
|
||||
<tr><td><code>6</code></td><td>16</td><td>License ID</td><td>UUIDv4 binary form.</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>38</code></td><td>8</td><td>Issued-at</td><td>Unix seconds, signed.</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>
|
||||
<tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x01</code></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>2</code></td><td>16</td><td>product_id</td><td>UUID, big-endian bytes.</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>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>32</td><td>fingerprint_hash</td><td>SHA-256 of the machine fingerprint; all zeros if not bound.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="encoding">Crockford base32</h2>
|
||||
<p>Keysat uses <a href="https://www.crockford.com/base32.html">Crockford’s base32 alphabet</a> (<code>0123456789ABCDEFGHJKMNPQRSTVWXYZ</code>) without checksum, without padding, and case-insensitive on decode.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h2 id="grouping">Dash grouping & prefix</h2>
|
||||
<p>For display, keys are upper-cased, then grouped into 4-character chunks separated by dashes, and prefixed with <code>KS-</code>:</p>
|
||||
|
||||
<pre class="code"><span class="c">// raw base32, length depends on payload size</span>
|
||||
9F2A7C41XK226D8ELM77PQ91RR54VV01
|
||||
|
||||
<span class="c">// grouped + prefixed for display</span>
|
||||
KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-RR54-VV01</pre>
|
||||
|
||||
<p>Decoders MUST strip the <code>KS-</code> prefix (case-insensitive), strip whitespace and dashes, and case-fold to upper before base32-decoding.</p>
|
||||
<h3>v2 (current default, variable length)</h3>
|
||||
<p>83-byte fixed head + variable-length entitlements table. v2 adds expiry, trial flag, and entitlements — 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’t produce).</p>
|
||||
<table class="t">
|
||||
<thead><tr><th>Offset</th><th>Length</th><th>Field</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>0</code></td><td>1</td><td>version</td><td><code>0x02</code></td></tr>
|
||||
<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>
|
||||
<tr><td><code>18</code></td><td>16</td><td>license_id</td><td>UUID, big-endian 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>42</code></td><td>8</td><td>expires_at</td><td>Unix seconds, u64 big-endian. <code>0</code> means perpetual.</td></tr>
|
||||
<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>
|
||||
<tr><td><code>82</code></td><td>1</td><td>entitlements_count</td><td><code>N</code>, 0–255.</td></tr>
|
||||
<tr><td><code>83..</code></td><td>variable</td><td>entitlements</td><td><code>N</code> entries, each <code><len: u8><ascii bytes></code>. Each entitlement string is ≤255 bytes.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="signature">Signature</h2>
|
||||
<p>The signature covers the entire envelope from offset <code>0</code> through the end of the payload — 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> — 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’s Ed25519 public key (PEM-encoded, SubjectPublicKeyInfo). The SDKs ship the public key bundled in your app at build time; they don’t fetch it at runtime. (The whole point of offline verification is that a network-level attacker can’t hand your software a different key.)</p>
|
||||
|
||||
<p>Verify with the issuer’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’t match, the verifier MUST reject before attempting signature check — this gives a clear "wrong issuer" error rather than a generic "bad signature".</p>
|
||||
|
||||
<h2 id="example">Worked example</h2>
|
||||
<p>Test vector for the Python SDK’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="encoding">Base32 alphabet</h2>
|
||||
<p>Standard RFC 4648 base32 (alphabet <code>A–Z, 2–7</code>), no padding, case-insensitive on decode. The daemon emits uppercase. Decoders MUST strip whitespace and case-fold to upper before decoding.</p>
|
||||
<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>
|
||||
|
||||
<h2 id="public-key">Issuer public key format</h2>
|
||||
<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>
|
||||
|
||||
<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>Implement Crockford base32 decode (~30 lines).</li>
|
||||
<li>Implement the binary unmarshal (~40 lines, mostly offset arithmetic).</li>
|
||||
<li>Wire it up to your language’s Ed25519 verifier from a vetted crypto library.</li>
|
||||
<li>Run the cross-check tests — if they pass, you’re wire-compatible.</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 RFC 4648 base32 decode (most languages have this in stdlib).</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’s Ed25519 verifier from a vetted crypto library (libsodium, ring, ed25519-dalek, the Node/Python stdlib, etc.).</li>
|
||||
<li>Run the cross-check tests — if all three vector cases pass byte-for-byte, you’re wire-compatible.</li>
|
||||
</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’s test suite asserts each implementation round-trips them identically before a release ships.</p>
|
||||
|
||||
<h2 id="versioning">Versioning policy</h2>
|
||||
<p>The version byte at offset 4 is a hard gate. Decoders MUST reject any version they don’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’t implement (no graceful skip-over). We commit to:</p>
|
||||
|
||||
<ul>
|
||||
<li>Never silently changing the v1 layout. Any change ⇒ new version byte.</li>
|
||||
<li>Maintaining v1 verifier support indefinitely — even if v2 ships, your existing customer keys stay verifiable.</li>
|
||||
<li>Publishing test vectors for every new version under <code>tests/fixtures/</code> in the canonical SDK.</li>
|
||||
<li>Never silently changing an existing layout. Any field-shape change ⇒ new version byte.</li>
|
||||
<li>Maintaining v1 + v2 verifier support indefinitely — if v3 ever ships, your existing customer keys still verify against the daemon and the SDKs they shipped with.</li>
|
||||
<li>The wire-envelope tag (<code>LIC1-…</code>) bumps only on a breaking envelope change — 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>
|
||||
</main>
|
||||
|
||||
<aside class="toc">
|
||||
<div class="label">On this page</div>
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#layout">Binary layout</a>
|
||||
<a href="#encoding">Crockford base32</a>
|
||||
<a href="#grouping">Dash grouping</a>
|
||||
<a href="#versions">Payload versions</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="#porting">Porting</a>
|
||||
<a href="#versioning">Versioning policy</a>
|
||||
|
||||
Reference in New Issue
Block a user