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.
8.6 KiB
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:
- 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.
- 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:
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:
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
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_ADDRESSenv var, hashed. - Machine UUID from
/etc/machine-idon 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.