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).
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user