Files
keysat-docs/wire-format.html
T
Keysat 348a0b9f13 docs: add /license page + Project sidebar group across all pages
- New license.html: plain-English summary of the Keysat
  Source-Available License 1.0 (daemon) and MIT (SDKs +
  template). TL;DR table, "what you can do / can't do" lists,
  contribution-flow explainer, links to each repo's LICENSE
  file on GitHub. Anchor sections + on-this-page TOC.
- New "Project" sidebar group (Pricing + License) inserted
  above the existing Operate group on every docs page so the
  /license page is discoverable from anywhere in the docs.
2026-05-11 21:51:54 -05:00

167 lines
11 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs — Wire format reference</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css">
</head>
<body>
<div class="topnav">
<a href="https://keysat.xyz" class="brand" title="Back to keysat.xyz"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
<span class="docs-tag">Docs</span>
</div>
<div class="layout">
<aside class="side">
<div class="group">
<div class="glabel">Get started</div>
<a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a>
<a href="agent.html">Agent integration</a>
</div>
<div class="group">
<div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a>
</div>
<div class="group">
<div class="glabel">Reference</div>
<a href="wire-format.html" class="active">Wire format</a>
<a href="integrate.html#api">Admin API</a>
<a href="integrate.html#sdks">SDKs</a>
</div>
<div class="group">
<div class="glabel">Project</div>
<a href="pricing.html">Pricing</a>
<a href="license.html">License</a>
</div>
<div class="group">
<div class="glabel">Operate</div>
<a href="operate.html#backups">Backups</a>
<a href="operate.html#migrate">Migrate hardware</a>
<a href="operate.html#troubleshooting">Troubleshooting</a>
</div>
</aside>
<main class="prose">
<div class="crumb">Reference · Wire format</div>
<h1>Wire format reference.</h1>
<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 on a receipt looks like this:</p>
<pre class="code">LIC1-&lt;base32 payload&gt;-&lt;base32 signature&gt;</pre>
<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="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 &mdash; perpetual only, fingerprint binding optional. Still accepted on parse so old customer keys don&rsquo;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>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>
<h3>v2 (current default, variable length)</h3>
<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">
<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&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>
</tbody>
</table>
<h2 id="signature">Signature</h2>
<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>
<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>
<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>
<pre class="code">-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
-----END PUBLIC KEY-----</pre>
<p>This is the same encoding that <code>openssl pkey -pubout</code> produces. Keysat exposes it at <code>GET /v1/issuer/public-key</code>:</p>
<pre class="code">{
<span class="s">"public_key_pem"</span>: <span class="s">"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"</span>,
<span class="s">"public_key_b64"</span>: <span class="s">"mz7q8r4t1v…h3k2pXq9wL"</span>,
<span class="s">"fingerprint_hex"</span>: <span class="s">"feed face cafe babe …"</span>
}</pre>
<h2 id="porting">Porting to a new language</h2>
<p>The wire format is small enough to port in an afternoon. The order is:</p>
<ol>
<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&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 all three vector cases pass byte-for-byte, you&rsquo;re wire-compatible.</li>
</ol>
<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>
<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>
<li>Never silently changing an existing layout. Any field-shape change ⇒ new version byte.</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>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>
</main>
<aside class="toc">
<div class="label">On this page</div>
<a href="#overview">Overview</a>
<a href="#versions">Payload versions</a>
<a href="#signature">Signature</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>
</aside>
</div>
<script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script>
</body>
</html>