Files
keysat-root/PORTING_SDK_TO_NEW_LANGUAGES.md
Keysat 843ff0e5d7 Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo,
keysat-design-system, and crosscheck tests. Subproject folders are
gitignored (each has its own Gitea remote).
2026-06-12 17:51:40 -05:00

13 KiB
Raw Permalink Blame History

Porting Keysat SDK to New Languages — v0.2 Roadmap

This document is a working spec for adding first-class language support beyond the v0.1 Rust + TypeScript SDKs. Use it as the contributor brief when you (or someone you hire) sit down to write the Python SDK, the Go SDK, etc.

Scope: v0.1 ships with licensing-client (Rust crate) and @keysat/licensing-client (npm). Anyone integrating Keysat into software written in another language has to either write their own thin verifier against the documented wire format, or wait for an official SDK. v0.2's goal is to remove that friction for the languages where most paid software actually ships.


Language priorities for v0.2

In rough order of how often each comes up in indie / small-team paid software, and how complete the Ed25519 + base32 ecosystem is:

Priority Language Rationale Crypto status
1 Python Server-side software, CLIs, ML tools, scientific software. cryptography mature, Ed25519 native
2 Go Backend services, CLIs, infrastructure tools. crypto/ed25519 in stdlib
3 C# / .NET Windows desktop apps, games (Unity), enterprise tools. NSec or BouncyCastle
4 Swift macOS + iOS apps. Largest paid-app market. CryptoKit Ed25519 native (iOS 13+)
5 Java/Kotlin Android apps, enterprise software. BouncyCastle
6 C++ Native games, audio/video tools. libsodium

Picking just two as the first batch: Python (already has the reference signer code, already proven to interoperate) and Go (most-requested language for self-hosted server software).


Wire format — the single source of truth

Every SDK must implement the exact byte layout already documented and test-fixtured in this repo. Do not invent a new layout.

Authoritative source files. Read all three before writing a line of code:

  • licensing-service/src/crypto/mod.rs — Rust impl, comment-heavy. Defines LicensePayload, encoding, decoding, and the v1↔v2 boundary.
  • licensing-client-ts/src/key.ts — second independent impl in TS.
  • tests/crosscheck/reference_signer.py — third independent impl in Python. Uses cryptography rather than pynacl — different underlying primitive impl, so agreement is meaningful.

Format summary (read the source for byte-exact details):

  • Key string shape: LIC1-<base32-payload>-<base32-signature> with Crockford base32 alphabet (uppercase, no padding, with the standard Crockford alphabet substitutions: I↔1, L↔1, O↔0).
  • Signature: Ed25519 over the raw payload bytes.
  • Payload: a fixed-prefix binary structure with version | flags | product_id (UUID, 16 bytes) | license_id (UUID, 16 bytes) | issued_at (i64 unix seconds) | expires_at (i64 unix seconds, 0 means perpetual) | fingerprint_hash (32 bytes, all zeros if unbound). v2 adds a variable-length tail of UTF-8 entitlements. v1 (legacy) is fixed at 74 bytes with no entitlements.
  • Flags: bit 0 = FINGERPRINT_BOUND, bit 1 = TRIAL. Reserve all other bits for future expansion; SDKs must preserve unknown flags on parse rather than rejecting.
  • Fingerprint: client-supplied raw string, hashed by the SDK to SHA-256 before being sent to the server. The server stores only the hash.

If the v0.1 Rust source and this doc ever disagree on a byte, the Rust source wins and this doc gets fixed.


Functional parity matrix — what every SDK must do

Ordered roughly by integration value:

Feature Required Notes
Parse a LIC1-...-... key Required Both v1 and v2 layouts.
Verify Ed25519 signature offline Required Given the issuer's PEM-encoded public key.
Detect expiry (isExpiredAt) Required Pure-function helper, no clock dep — caller supplies time.
Detect tamper / bad signature Required Distinct error type from "expired" or "wrong product".
Check entitlements Required Boolean helper given a key + entitlement slug.
Compute fingerprint hash Required SHA-256 hex; raw input never leaves the host (server-side hashed too).
HTTP validate call Required Returns reason on rejection (revoked, fingerprint_mismatch, not_found, bad_signature, product_mismatch).
HTTP machines/heartbeat Recommended Updates last_heartbeat_at on the bound machine.
HTTP machines/activate Recommended Explicit seat activation.
HTTP machines/deactivate Recommended Free a seat.
HTTP purchase/start + poll Recommended Drives the BTCPay flow from inside the app. Optional but very common.
HTTP redeem (free codes) Recommended One-shot redemption of free_license codes.
Public key from PEM Required Should accept the exact PEM string GET /v1/pubkey returns.
Public key from raw bytes Optional Convenience for compiled-in keys.
Browser/server agnostic Where possible TS SDK runs in both. Python & Go are server-only by nature; that's fine.

Two boolean rules of thumb:

  1. Network failures must not propagate as license-invalid. Every SDK's online methods must distinguish "could not reach server" from "server rejected the key." Callers will treat the first as "status unknown, fall back to offline check" and the second as "actually invalid."
  2. Offline check must be the default code path. All SDKs put the offline verifier on the easy path. Online validation is opt-in. This is what protects the seller's customers from the seller's downtime.

Cross-language test vectors

tests/crosscheck/vector.json is the canonical fixture. Every new SDK must pass every fixture in there before being released. The current contents (read the file for the source-of-truth values):

  • v1 — legacy fixed-74 layout, fingerprint-bound. Tests the legacy parser branch.
  • v2 — trial key, fingerprint-bound, explicit expiry, two entitlements. Tests the variable-length tail parser.
  • v2_perpetual_unbound — the common case for a paid purchase. v2, no expiry, no binding, no entitlements.

For every new SDK, tests/crosscheck/ should grow a new run_<lang>.<ext> runner that loads vector.json and asserts:

  1. Each key parses cleanly and field-for-field matches the fixture.
  2. Each key's signature verifies against the fixture's PEM public key.
  3. Tampering with one byte of the key string produces a verification error.
  4. Fingerprint binding succeeds with the matching fingerprint and fails with a different one.
  5. Entitlement lookup returns true for declared entitlements and false for absent ones.
  6. isExpiredAt flips at the documented boundary.
  7. hashFingerprint("hello") matches Python's hashlib.sha256("hello".encode()).hexdigest().

If a runner can pass all seven, the SDK is wire-compatible.


Per-language porting checklist

Apply this checklist when starting a new SDK in language L. It's intentionally generic — adapt to L's conventions.

Phase 0 — read

  • Read this document end to end.
  • Read licensing-service/src/crypto/mod.rs (the byte-exact source).
  • Read licensing-client-rust/src/lib.rs and licensing-client-ts/src/key.ts to see two existing impls.
  • Read tests/crosscheck/reference_signer.py for a third.
  • Run the existing TypeScript cross-check (tests/crosscheck/run_ts.mjs) to confirm the fixtures in your local clone are valid.

Phase 1 — offline verifier (the bulk of the value)

  • Pick / vendor an Ed25519 verifier library for L. Use the most mainstream option (stdlib if L has it, otherwise the most-used package). Avoid hand-rolling crypto.
  • Pick / vendor a Crockford base32 decoder. If none exists in L's ecosystem, write ~30 lines (the alphabet is fixed; decoding is a simple table lookup).
  • Implement PublicKey::from_pem (or idiomatic equivalent) that accepts the exact string GET /v1/pubkey returns.
  • Implement parseLicenseKey(string) -> LicenseKey that handles both v1 and v2 layouts, preserves unknown flag bits, and rejects malformed inputs with a clear error type.
  • Implement Verifier::verify(key) -> VerifyOk | Error that does base32 decode → split sig from payload → ed25519 verify → return parsed payload.
  • Implement isExpiredAt(key, time) and hasEntitlement(key, slug) as pure helpers.
  • Implement hashFingerprint(string) -> hex string.
  • Pass fixtures 16 in vector.json via a new tests/crosscheck/run_<lang>.<ext> runner.

Phase 2 — online client (optional features)

  • Decide on an HTTP library that's idiomatic for L. Avoid pinning to something exotic — most users will already have a default.
  • Implement Client::new(baseUrl). Strip trailing slashes defensively.
  • Implement Client::baseUrl() getter (we use this in v0.1 to compute redirect URLs).
  • Implement Client::fetchPubkeyPem().
  • Implement Client::validate(key, productSlug, fingerprint). Distinguish network errors from server-side rejections in the return type.
  • Implement Client::heartbeat(key, fingerprint).
  • Implement Client::activate(key, fingerprint, opts).
  • Implement Client::deactivate(key, fingerprint, reason?).
  • Implement Client::startPurchase(productSlug, opts) — opts includes optional code, buyerEmail, redirectUrl.
  • Implement Client::pollPurchase(invoiceId).
  • Implement Client::waitForLicense(invoiceId, opts?) — convenience polling loop. Throw on terminal states (expired, invalid).
  • Implement Client::redeemFreeLicense(productSlug, code, opts?) — the no-BTCPay path for free_license codes.

Phase 3 — packaging + release

  • Pick a package name. Keep it close to keysat-licensing-client modulo language conventions.
  • Pin compatible language version. Document it (Python ≥ 3.10, Go ≥ 1.21, etc.).
  • Write a README mirroring the structure of licensing-client-ts/README.md (5-line offline check + 10-line online check + purchase flow + browser usage if applicable).
  • Add an examples/ directory with at least an offline-verify example and an online-validate example.
  • Hook the SDK's tests into CI in this monorepo so a v1 wire-format change automatically breaks every SDK.
  • Publish to the language registry (PyPI / pkg.go.dev / Maven / SwiftPM / NuGet).

Distribution & naming

Language Package name Registry
Rust licensing-client crates.io
TypeScript @keysat/licensing-client npm
Python keysat-licensing-client PyPI
Go github.com/keysat-xyz/keysat-go/client pkg.go.dev (Go modules use the import path)
Java/Kotlin xyz.keysat.licensing-client Maven Central
Swift KeysatLicensingClient Swift Package Manager (Git tag)
C# / .NET Keysat.LicensingClient NuGet
C++ keysat-licensing-client vcpkg / Conan

Versioning

Every SDK starts at 0.1.0 and tracks the wire-format version independently of its own SemVer. Wire-format breaking changes (a new required field in the payload, e.g.) bump every SDK's minor version simultaneously. Wire-compatible additions (a new optional flag bit) bump the patch version.

A wire-format change requires:

  1. Update licensing-service/src/crypto/mod.rs first.
  2. Add new fixtures to tests/crosscheck/vector.json.
  3. Update every SDK to pass the new fixtures.
  4. Release coordinated minor versions.

This is a real coordination tax. The mitigation is to keep wire-format changes infrequent and additive.


Maintenance burden — be honest about it

Each SDK is a long-tail liability. Every breaking change in the wire format is N times the work, where N is the number of SDKs. Every CVE in a transitive HTTP or crypto dependency is a release per language. Be realistic about how many SDKs the team can credibly maintain. Two or three first-class is plausibly forever; six or seven becomes a not-getting-around-to-it problem.

The escape valve: keep the wire format documented well enough that an abandoned SDK is straightforward to fork or replace. If the wire-format section of this doc is ever harder to read than the code, we've regressed and need to fix it.