Files
keysat/licensing-service/docs/INTEGRATION.md
T
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
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.
2026-05-11 08:45:25 -05:00

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:

  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:

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_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.