843ff0e5d7
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).
267 lines
13 KiB
Markdown
267 lines
13 KiB
Markdown
# 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.
|