# 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 1–6 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.
