v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
# API reference
|
||||
|
||||
All endpoints are JSON in / JSON out. Errors return a body of the form:
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
|
||||
```
|
||||
|
||||
Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## Public endpoints
|
||||
|
||||
### `GET /`
|
||||
|
||||
Service metadata including the Ed25519 public key. Useful for SDKs to fetch the key at build time.
|
||||
|
||||
```json
|
||||
{
|
||||
"service": "keysat",
|
||||
"version": "0.1.0",
|
||||
"operator": "Acme Software",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
||||
"key_algorithm": "ed25519",
|
||||
"key_format_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
Liveness probe. Returns `{"ok": true}`.
|
||||
|
||||
### `GET /v1/pubkey`
|
||||
|
||||
Just the public key.
|
||||
|
||||
### `GET /v1/products`
|
||||
|
||||
List all active products.
|
||||
|
||||
### `GET /v1/products/:slug`
|
||||
|
||||
Single product by slug.
|
||||
|
||||
### `POST /v1/purchase`
|
||||
|
||||
Start a purchase.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "my-app",
|
||||
"buyer_email": "alice@example.com",
|
||||
"buyer_note": "optional",
|
||||
"redirect_url": "https://myapp.example.com/thanks"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"invoice_id": "uuid-of-our-row",
|
||||
"btcpay_invoice_id": "...",
|
||||
"checkout_url": "https://btcpay.example.com/i/...",
|
||||
"amount_sats": 50000,
|
||||
"poll_url": "https://license.example.com/v1/purchase/uuid-of-our-row"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/purchase/:invoice_id`
|
||||
|
||||
Poll for license delivery.
|
||||
|
||||
While pending:
|
||||
|
||||
```json
|
||||
{
|
||||
"invoice_id": "...",
|
||||
"status": "pending",
|
||||
"product_id": "...",
|
||||
"amount_sats": 50000,
|
||||
"license_key": null,
|
||||
"license_id": null
|
||||
}
|
||||
```
|
||||
|
||||
Once settled:
|
||||
|
||||
```json
|
||||
{
|
||||
"invoice_id": "...",
|
||||
"status": "settled",
|
||||
"product_id": "...",
|
||||
"amount_sats": 50000,
|
||||
"license_key": "LIC1-...-...",
|
||||
"license_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /v1/validate`
|
||||
|
||||
The hot path. Downstream software calls this at startup (and on a cadence) to check revocation.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "LIC1-...-...",
|
||||
"product_slug": "my-app",
|
||||
"fingerprint": "sha256-of-some-installation-unique-data"
|
||||
}
|
||||
```
|
||||
|
||||
`product_slug` and `fingerprint` are optional. If `fingerprint` is provided and the license row has no fingerprint bound yet, the first caller's fingerprint is locked to the license (trust-on-first-use). Later callers presenting a different fingerprint are rejected with `reason: "fingerprint_mismatch"`.
|
||||
|
||||
Response (always HTTP 200 so middleware doesn't log these as errors):
|
||||
|
||||
```json
|
||||
{ "ok": true, "license_id": "...", "product_id": "...", "product_slug": "my-app", "issued_at": "..." }
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{ "ok": false, "reason": "revoked" }
|
||||
```
|
||||
|
||||
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`.
|
||||
|
||||
### `POST /v1/btcpay/webhook`
|
||||
|
||||
Landing point for BTCPay Server webhook events. Only BTCPay should call this. We verify `BTCPay-Sig` HMAC before trusting anything.
|
||||
|
||||
---
|
||||
|
||||
## Admin endpoints
|
||||
|
||||
All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
||||
|
||||
### `POST /v1/admin/products`
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "my-app",
|
||||
"name": "My App",
|
||||
"description": "...",
|
||||
"price_sats": 50000,
|
||||
"metadata": { "anything": "useful" }
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /v1/admin/products/:id/active`
|
||||
|
||||
Activate or deactivate a product.
|
||||
|
||||
```json
|
||||
{ "active": false }
|
||||
```
|
||||
|
||||
Deactivated products are hidden from public listings and reject new purchases; existing licenses continue to validate.
|
||||
|
||||
### `GET /v1/admin/licenses?product_id=...`
|
||||
|
||||
List licenses for a product.
|
||||
|
||||
### `POST /v1/admin/licenses`
|
||||
|
||||
Manually issue a license outside the purchase flow — for comps, press keys, developer testing.
|
||||
|
||||
```json
|
||||
{ "product_slug": "my-app", "note": "comp for @alice" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"license_id": "...",
|
||||
"product_id": "...",
|
||||
"license_key": "LIC1-...-...",
|
||||
"issued_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /v1/admin/licenses/:id/revoke`
|
||||
|
||||
```json
|
||||
{ "reason": "chargeback" }
|
||||
```
|
||||
|
||||
Idempotent: revoking an already-revoked license returns 404.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Architecture notes
|
||||
|
||||
## Design principles
|
||||
|
||||
**Decentralized by default.** Every licensing-service instance is independent. No phoning home, no shared state. If we vanish, every developer using this keeps running their own.
|
||||
|
||||
**Cryptography before databases.** A license key carries its own proof of legitimacy via an Ed25519 signature. The database is the authority on revocation and binding, but not on authenticity. This means downstream software doesn't break when your server has an outage.
|
||||
|
||||
**Idempotent webhooks.** BTCPay may retry a webhook. Settlement logic is designed so duplicate webhooks can't duplicate licenses (uniqueness enforced at the `licenses.invoice_id` column plus an existence check).
|
||||
|
||||
**Operator-owned secrets.** The signing key lives in SQLite and is covered by StartOS encrypted backups. The admin API key is env-driven and never logged. BTCPay credentials are env-driven. No secrets in git, no secrets in code.
|
||||
|
||||
## Data model
|
||||
|
||||
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
|
||||
|
||||
- `products` — what's for sale. Independent pricing per product.
|
||||
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
|
||||
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns.
|
||||
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
|
||||
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature).
|
||||
|
||||
## License key format
|
||||
|
||||
```
|
||||
LIC1 - <base32(74-byte payload)> - <base32(64-byte signature)>
|
||||
```
|
||||
|
||||
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
|
||||
|
||||
Why base32 Crockford-style (no padding)?
|
||||
|
||||
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
|
||||
- Slightly longer than base64 but less error-prone for humans copying keys.
|
||||
- Case-insensitive accept means users don't get mysteriously rejected keys.
|
||||
|
||||
Why include `issued_at` in the signed payload?
|
||||
|
||||
- Lets SDKs reject keys issued before a known revocation epoch without contacting the server (future feature).
|
||||
- Lets admins spot anomalies in key-age distribution when investigating abuse.
|
||||
|
||||
Why optional `fingerprint_hash` *inside the signature*?
|
||||
|
||||
- If set, the key is cryptographically useless on any other machine even if DB state is somehow lost. Belt-and-suspenders.
|
||||
- Not required — most commercial licenses use trust-on-first-use via the DB column instead, because hard binding breaks legitimate hardware upgrades.
|
||||
|
||||
## Threat model
|
||||
|
||||
Who might attack this?
|
||||
|
||||
1. **Pirate trying to use software without paying.** Must present a valid signed key. Can't mint one without the server's private key. Can't replay a key across machines if fingerprint-bound. Can't modify a revoked key into a fresh one without breaking the signature.
|
||||
|
||||
2. **Someone who compromises the licensing server.** Can mint keys, revoke keys, read the DB. That's the intended failure mode — the server is the trust root. Mitigations: run on a hardened StartOS instance, use encrypted backups, don't expose admin endpoints to the clearnet (use LAN-only or Tor-only exposure in the manifest).
|
||||
|
||||
3. **Someone MITM-ing the /v1/validate call.** Can't forge successful responses because legitimate clients also did offline signature verification first. Can serve stale "revoked" responses — denial of service at worst, not a bypass.
|
||||
|
||||
4. **BTCPay webhook spoofer.** Must know the shared HMAC secret. We verify in constant time and reject bad signatures with 401.
|
||||
|
||||
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
|
||||
|
||||
## What's deliberately NOT in v0.1
|
||||
|
||||
- **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
||||
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
|
||||
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
|
||||
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
|
||||
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
|
||||
- **Admin UI.** Everything is API-driven. Wrap it in whatever UI you like — or just use `curl`.
|
||||
|
||||
## Notes on Start9 dependencies
|
||||
|
||||
When you write the s9pk manifest, `btcpayserver` is a declared dependency. StartOS resolves it to a `.startos` hostname that only works on the same server. If you ever want to run licensing-service pointing at a *remote* BTCPay, you can override `BTCPAY_URL` — the client is a plain HTTPS client, not bound to the StartOS mesh.
|
||||
|
||||
For webhooks going the other way (BTCPay → licensing), the webhook URL BTCPay calls will be your licensing service's `.local` or `.onion` hostname. Same-server Tor hop works fine.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Developer integration guide
|
||||
|
||||
This guide is for developers who want their software to validate against a licensing-service instance. It doesn't matter whether your software is a Start9 package, a desktop app, or a server — the flow is the same.
|
||||
|
||||
## Core idea: two-phase validation
|
||||
|
||||
Licensing-service separates verification into two concerns:
|
||||
|
||||
1. **Signature verification** (offline, fast, deterministic) — prove the key was actually issued by the server. Needs only the server's Ed25519 public key, which you ship with your client.
|
||||
2. **Revocation check** (online, authoritative) — confirm the server hasn't revoked the license. Requires a network call.
|
||||
|
||||
For most software, you should do both on startup, then **cache the revocation result** for some period (hours to a day) and fall back to the cached result if the server is briefly unreachable. That way:
|
||||
|
||||
- A bad or forged key is rejected instantly, without a network call.
|
||||
- A legitimately paying user isn't locked out if the licensing server has a 10-minute hiccup.
|
||||
- A revoked key is detected within your cache window.
|
||||
|
||||
## Bundling the public key
|
||||
|
||||
When you set up your licensing-service instance, fetch the public key once:
|
||||
|
||||
```bash
|
||||
curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
|
||||
```
|
||||
|
||||
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
|
||||
|
||||
## Reference integration in Rust
|
||||
|
||||
This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand:
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
|
||||
// Pinned at compile time from the licensing server's /v1/pubkey output.
|
||||
const SERVER_PUBLIC_KEY_PEM: &str = r#"
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA...your-public-key...
|
||||
-----END PUBLIC KEY-----
|
||||
"#;
|
||||
|
||||
const LICENSING_URL: &str = "https://license.example.com";
|
||||
const PRODUCT_SLUG: &str = "my-app";
|
||||
|
||||
pub struct LicenseCheck {
|
||||
pub license_id: String,
|
||||
pub product_id: String,
|
||||
}
|
||||
|
||||
pub fn offline_verify(license_key: &str) -> Result<()> {
|
||||
let vk = VerifyingKey::from_public_key_pem(SERVER_PUBLIC_KEY_PEM)
|
||||
.context("bundled public key is invalid")?;
|
||||
|
||||
let mut parts = license_key.trim().splitn(3, '-');
|
||||
let prefix = parts.next().context("empty key")?;
|
||||
anyhow::ensure!(prefix == "LIC1", "unknown key prefix");
|
||||
let payload_b32 = parts.next().context("no payload")?;
|
||||
let sig_b32 = parts.next().context("no signature")?;
|
||||
|
||||
let payload = BASE32_NOPAD.decode(payload_b32.to_ascii_uppercase().as_bytes())?;
|
||||
let sig_bytes = BASE32_NOPAD.decode(sig_b32.to_ascii_uppercase().as_bytes())?;
|
||||
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into()
|
||||
.context("signature length != 64")?;
|
||||
let sig = Signature::from_bytes(&sig_array);
|
||||
|
||||
vk.verify(&payload, &sig).context("signature invalid")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_online(
|
||||
license_key: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<LicenseCheck> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Resp {
|
||||
ok: bool,
|
||||
reason: Option<String>,
|
||||
license_id: Option<String>,
|
||||
product_id: Option<String>,
|
||||
}
|
||||
|
||||
let resp: Resp = reqwest::Client::new()
|
||||
.post(format!("{LICENSING_URL}/v1/validate"))
|
||||
.json(&serde_json::json!({
|
||||
"key": license_key,
|
||||
"product_slug": PRODUCT_SLUG,
|
||||
"fingerprint": fingerprint,
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if !resp.ok {
|
||||
anyhow::bail!("license rejected: {}", resp.reason.unwrap_or_default());
|
||||
}
|
||||
Ok(LicenseCheck {
|
||||
license_id: resp.license_id.unwrap(),
|
||||
product_id: resp.product_id.unwrap(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Reference integration in TypeScript
|
||||
|
||||
```ts
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const SERVER_PUBLIC_KEY_PEM = `
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA...your-public-key...
|
||||
-----END PUBLIC KEY-----
|
||||
`;
|
||||
const LICENSING_URL = "https://license.example.com";
|
||||
const PRODUCT_SLUG = "my-app";
|
||||
|
||||
function base32NoPadDecode(s: string): Uint8Array {
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const out: number[] = [];
|
||||
let bits = 0, value = 0;
|
||||
for (const c of s.toUpperCase()) {
|
||||
const idx = ALPHABET.indexOf(c);
|
||||
if (idx < 0) throw new Error("bad base32 char: " + c);
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
async function importPubKey(): Promise<CryptoKey> {
|
||||
const pem = SERVER_PUBLIC_KEY_PEM
|
||||
.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
const der = Uint8Array.from(Buffer.from(pem, "base64"));
|
||||
return webcrypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
|
||||
}
|
||||
|
||||
export async function offlineVerify(key: string): Promise<void> {
|
||||
const [prefix, payloadB32, sigB32] = key.trim().split("-");
|
||||
if (prefix !== "LIC1") throw new Error("bad prefix");
|
||||
const payload = base32NoPadDecode(payloadB32);
|
||||
const sig = base32NoPadDecode(sigB32);
|
||||
const pk = await importPubKey();
|
||||
const ok = await webcrypto.subtle.verify("Ed25519", pk, sig, payload);
|
||||
if (!ok) throw new Error("signature invalid");
|
||||
}
|
||||
|
||||
export async function validateOnline(key: string, fingerprint: string) {
|
||||
const r = await fetch(`${LICENSING_URL}/v1/validate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ key, product_slug: PRODUCT_SLUG, fingerprint }),
|
||||
});
|
||||
const body = await r.json();
|
||||
if (!body.ok) throw new Error(`license rejected: ${body.reason}`);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
|
||||
## Graceful degradation pattern
|
||||
|
||||
```
|
||||
on startup:
|
||||
key = read_license_from_storage()
|
||||
if key is None:
|
||||
prompt_user_for_license_or_start_trial()
|
||||
return
|
||||
|
||||
try offline_verify(key) # instant; fail closed on bad signature
|
||||
except BadSignature:
|
||||
mark_installation_unlicensed()
|
||||
return
|
||||
|
||||
try online_validate(key, fingerprint)
|
||||
except NetworkError:
|
||||
cached = read_cache()
|
||||
if cached is valid and < 7 days old:
|
||||
proceed()
|
||||
else:
|
||||
warn_user("licensing server unreachable for > 7 days")
|
||||
proceed() # or refuse, if you prefer strict
|
||||
except Rejected(reason):
|
||||
handle_rejection(reason)
|
||||
|
||||
on every N hours in background:
|
||||
re-run online_validate, refresh cache
|
||||
```
|
||||
|
||||
Choosing the cache TTL is a business decision: long TTL = better uptime resilience, slower revocation propagation. A day to a week covers most sane cases.
|
||||
|
||||
## Fingerprint strategy
|
||||
|
||||
A fingerprint is any string that uniquely identifies an installation. Common choices, roughly from stable to less stable:
|
||||
|
||||
- A random 256-bit value you generate and persist in your app's data directory on first run. **Recommended** — stable across reboots, you control it, doesn't leak anything about the host.
|
||||
- On Start9: the service's `TOR_ADDRESS` env var, hashed.
|
||||
- Machine UUID from `/etc/machine-id` on Linux. Leaks a real identifier but is available without any state.
|
||||
- Combination of MAC + hostname — avoid; user-visible and changes on network moves.
|
||||
|
||||
Whatever you pick, hash it before sending if you want to avoid exposing the underlying identifier in network traffic.
|
||||
|
||||
## Reasoning about failure modes
|
||||
|
||||
| Scenario | What happens |
|
||||
|------------------------------------------|------------------------------------------------------------|
|
||||
| Licensing server down, user has valid key | Your software uses cached result and keeps working. |
|
||||
| Licensing server down, first-ever startup | Offline verification passes; online validation fails; you decide whether to proceed or block. |
|
||||
| Forged key | Offline verification rejects instantly, no network call. |
|
||||
| Valid key but revoked | Online validation returns `reason: "revoked"`; block or downgrade. |
|
||||
| Valid key but user swaps hardware | Online validation returns `fingerprint_mismatch`; user contacts you to transfer. |
|
||||
| Network censorship in user's region | Consider shipping a Tor client so they can reach your `.onion`. |
|
||||
|
||||
## Tor / `.onion` support
|
||||
|
||||
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent.
|
||||
Reference in New Issue
Block a user