# 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--` 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_.` 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 1–6 in `vector.json` via a new `tests/crosscheck/run_.` 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.