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:
Keysat
2026-06-12 17:51:40 -05:00
commit 843ff0e5d7
55 changed files with 6887 additions and 0 deletions
+266
View File
@@ -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 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.