257669092b
Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs.
223 lines
8.6 KiB
Markdown
223 lines
8.6 KiB
Markdown
# 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-native and privacy-adjacent.
|