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:
LIC1: literal envelope tag. Future format revisions get a new tag (LIC2etc.). Parsers MUST reject unknown tags.<base32 payload>: the signed payload bytes, RFC 4648 base32 without padding (case-insensitive on decode). Variable length depending on payload version and number of entitlements.<base32 signature>: the 64-byte Ed25519 signature over the raw payload bytes, base32-encoded the same way.
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.
| Offset | Length | Field | Notes |
|---|---|---|---|
0 | 1 | version | 0x01 |
1 | 1 | flags | Bit 0: fingerprint-bound. Other bits reserved. |
2 | 16 | product_id | UUID, big-endian bytes. |
18 | 16 | license_id | UUID, big-endian bytes. |
34 | 8 | issued_at | Unix seconds, u64 big-endian. |
42 | 32 | fingerprint_hash | SHA-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).
| Offset | Length | Field | Notes |
|---|---|---|---|
0 | 1 | version | 0x02 |
1 | 1 | flags | Bit 0: fingerprint-bound. Bit 1: trial (best-effort hint for clients). |
2 | 16 | product_id | UUID, big-endian bytes. |
18 | 16 | license_id | UUID, big-endian bytes. |
34 | 8 | issued_at | Unix seconds, u64 big-endian. |
42 | 8 | expires_at | Unix seconds, u64 big-endian. 0 means perpetual. |
50 | 32 | fingerprint_hash | SHA-256 of the machine fingerprint; all zeros if not bound. |
82 | 1 | entitlements_count | N, 0–255. |
83.. | variable | entitlements | N 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:
- 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. - Implement RFC 4648 base32 decode (most languages have this in stdlib).
- Implement the binary unmarshal for both v1 and v2 payloads (~80 lines total, mostly big-endian integer reads).
- Wire it up to your language’s Ed25519 verifier from a vetted crypto library (libsodium, ring, ed25519-dalek, the Node/Python stdlib, etc.).
- 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:
- Never silently changing an existing layout. Any field-shape change ⇒ new version byte.
- 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.
- The wire-envelope tag (
LIC1-…) 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. - Publishing test vectors for every payload version under
tests/crosscheck/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.