Keysat Docs
Reference · Wire format

Wire format reference.

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.

Overview

A Keysat license key on a receipt looks like this:

LIC1-<base32 payload>-<base32 signature>

Three parts, separated by single dashes:

To verify: split on -, validate the tag is LIC1, 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.

Two payload versions

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.

v1 (legacy, fixed 74 bytes)

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.

OffsetLengthFieldNotes
01version0x01
11flagsBit 0: fingerprint-bound. Other bits reserved.
216product_idUUID, big-endian bytes.
1816license_idUUID, big-endian bytes.
348issued_atUnix seconds, u64 big-endian.
4232fingerprint_hashSHA-256 of the machine fingerprint; all zeros if not bound.

v2 (current default, variable length)

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).

OffsetLengthFieldNotes
01version0x02
11flagsBit 0: fingerprint-bound. Bit 1: trial (best-effort hint for clients).
216product_idUUID, big-endian bytes.
1816license_idUUID, big-endian bytes.
348issued_atUnix seconds, u64 big-endian.
428expires_atUnix seconds, u64 big-endian. 0 means perpetual.
5032fingerprint_hashSHA-256 of the machine fingerprint; all zeros if not bound.
821entitlements_countN, 0–255.
83..variableentitlementsN entries, each <len: u8><ascii bytes>. Each entitlement string is ≤255 bytes.

Signature

The signature is computed over the raw payload bytes: 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 independently; concatenating them and base32-decoding the whole would be wrong.

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.)

Base32 alphabet

Standard RFC 4648 base32 (alphabet A–Z, 2–7), no padding, case-insensitive on decode. The daemon emits uppercase. Decoders MUST strip whitespace and case-fold to upper before decoding.

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.

Issuer public key format

Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:

-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
-----END PUBLIC KEY-----

This is the same encoding that openssl pkey -pubout produces. Keysat exposes it at GET /v1/issuer/public-key:

{
  "key_algorithm": "ed25519",
  "key_format_version": 2,
  "public_key_pem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
}

The public_key_pem field is the one you embed in your app. Verification needs only the PEM; the SDKs parse it with PublicKey.fromPem(...).

Porting to a new language

The wire format is small enough to port in an afternoon. The order is:

  1. Pull the canonical cross-check vectors from the daemon repo at licensing-service/tests/crosscheck/. Vectors cover v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.
  2. Implement RFC 4648 base32 decode (most languages have this in stdlib).
  3. Implement the binary unmarshal for both v1 and v2 payloads (~80 lines total, mostly big-endian integer reads).
  4. Wire it up to your language’s Ed25519 verifier from a vetted crypto library (libsodium, ring, ed25519-dalek, the Node/Python stdlib, etc.).
  5. Run the cross-check tests. If all three vector cases pass byte-for-byte, you’re wire-compatible.

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.

Versioning policy

The version byte at payload offset 0 is a hard gate. Decoders MUST reject any version they don’t implement (no graceful skip-over). We commit to: