//! Keysat-licenses-Keysat: dogfooded self-licensing layer. //! //! The Keysat package ships with the master public key embedded in //! `TRUST_ROOT_PUBKEY_PEM` below. On every boot we look for a license //! at `SELF_LICENSE_PATH` (or the `KEYSAT_LICENSE` env var), parse it //! using the same wire-format machinery the daemon uses to issue //! customer licenses, and verify its signature against the master //! public key. //! //! Missing or invalid self-licenses log a warning and the daemon starts in //! `Tier::Unlicensed`, which the admin UI labels "Creator" — the free tier //! with the Creator caps applied (5 products, 5 policies per product, 10 //! active codes). The daemon is always functional out of the box; paying //! lifts the caps and unlocks `recurring_billing` + `zaprite_payments`. //! //! The master pubkey is the *public* half of an Ed25519 keypair held by //! the operator who issues Keysat-product licenses. It is not secret — //! embedding it in source on GitHub is fine. Anyone with the *private* //! half can mint Keysat self-licenses. On the master Keysat instance //! that owner runs, the private half doubles as the per-instance //! license-signing key (stored in the `server_keys` table); on every //! other Keysat install the private half doesn't exist and the daemon //! only ever verifies, never signs. use crate::crypto::{parse_key, verify_payload}; use anyhow::{bail, Context, Result}; use ed25519_dalek::pkcs8::DecodePublicKey; use ed25519_dalek::VerifyingKey; use std::time::{SystemTime, UNIX_EPOCH}; /// Master public key for Keysat self-licensing. PEM-encoded Ed25519, /// SubjectPublicKeyInfo wrapped (the format `openssl pkey -pubout` /// emits). To rotate this in a future release: replace the const, /// ship a new build, distribute fresh licenses to existing customers. /// Existing customers' licenses won't verify against the new key — /// that's the breaking event. Plan rotations carefully. pub const TRUST_ROOT_PUBKEY_PEM: &str = "-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA= -----END PUBLIC KEY-----"; /// Where the daemon expects a self-license file. Single line, the raw /// license-key string in `LIC1-…-…` format. Mounted from the /// persistent data volume so it survives package upgrades. pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt"; #[derive(Debug, Clone)] pub enum Tier { /// No self-license file, or verify failed. Surfaces as "Creator" /// in the admin UI — the free tier with the Creator caps applied. /// `reason` is for logs and the admin `/v1/admin/tier` payload, not /// shown to end users. Unlicensed { reason: String }, /// Valid license verified against the trust-root. Licensed { license_id: uuid::Uuid, product_id: uuid::Uuid, /// Unix seconds; 0 means perpetual. expires_at: i64, entitlements: Vec, }, } impl Tier { /// String form for log / metrics labels. `Unlicensed` surfaces as /// "creator" since that's how the admin UI presents it — operators /// see one consistent name across logs and dashboard. pub fn as_str(&self) -> &'static str { match self { Tier::Unlicensed { .. } => "creator", Tier::Licensed { .. } => "licensed", } } } /// Boot-time check. Always returns `Ok` — Keysat boots into the Creator /// (free) tier when no valid self-license is present, never refuses to /// start. Logs a one-line info or warn line for operator visibility. pub fn check_at_boot() -> Result { let license_str = match read_license_string() { Some(s) => s, None => { let reason = format!( "no license at {} or KEYSAT_LICENSE env var; running Creator (free) tier", SELF_LICENSE_PATH ); tracing::info!(tier = "creator", "Keysat self-license: {}", reason); return Ok(Tier::Unlicensed { reason }); } }; match verify_license(&license_str) { Ok(tier) => { log_licensed(&tier); Ok(tier) } Err(e) => { let reason = format!( "verification failed: {e:#} — falling back to Creator (free) tier" ); tracing::warn!(tier = "creator", "Keysat self-license: {}", reason); Ok(Tier::Unlicensed { reason }) } } } fn read_license_string() -> Option { if let Ok(s) = std::env::var("KEYSAT_LICENSE") { let s = s.trim().to_string(); if !s.is_empty() { return Some(s); } } let path = std::path::Path::new(SELF_LICENSE_PATH); if let Ok(s) = std::fs::read_to_string(path) { let s = s.trim().to_string(); if !s.is_empty() { return Some(s); } } None } /// Verify a license-key string against the embedded trust-root. /// Returns the parsed `Tier::Licensed` on success. pub fn verify_license(license_key: &str) -> Result { let trust_key = parse_trust_root_pubkey()?; let (payload, signature, signed_bytes) = parse_key(license_key).context("license key parse failed")?; verify_payload(&trust_key, &signed_bytes, &signature) .context("license signature does not verify against master pubkey")?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); if payload.is_expired_at(now) { bail!( "license expired at unix={} (now unix={})", payload.expires_at, now ); } Ok(Tier::Licensed { license_id: payload.license_id, product_id: payload.product_id, expires_at: payload.expires_at, entitlements: payload.entitlements, }) } /// Persist a verified license string to `SELF_LICENSE_PATH`. Caller /// is expected to have run `verify_license` first. pub fn write_license_file(license_key: &str) -> Result<()> { let path = std::path::Path::new(SELF_LICENSE_PATH); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("creating parent directory {}", parent.display()))?; } std::fs::write(path, format!("{}\n", license_key.trim())) .with_context(|| format!("writing license to {}", path.display()))?; Ok(()) } fn parse_trust_root_pubkey() -> Result { let pem = TRUST_ROOT_PUBKEY_PEM.trim(); if pem.is_empty() { bail!("trust-root pubkey not embedded in this build"); } let vk = VerifyingKey::from_public_key_pem(pem) .context("trust-root pubkey PEM parse failed")?; Ok(vk) } fn log_licensed(tier: &Tier) { if let Tier::Licensed { license_id, product_id, expires_at, entitlements, } = tier { let exp = if *expires_at == 0 { "perpetual".to_string() } else { format!("expires_at_unix={expires_at}") }; let ents = if entitlements.is_empty() { "(none)".to_string() } else { entitlements.join(",") }; tracing::info!( tier = "licensed", license = %license_id, product = %product_id, "Keysat self-license: VERIFIED — {exp}, entitlements={ents}" ); } } /// Live-refresh the daemon's self-tier from the local `licenses` row. /// /// `check_at_boot` verifies the on-disk LIC1 key against the embedded /// trust root and reads its entitlements from the signed payload. That /// signed set is the ceiling. This function lets issuer-applied changes /// reach a running daemon without a restart — revocations, suspensions, /// downgrades, and the key's own expiry — by re-verifying the on-disk /// key and re-reading the `licenses` row by license_id. The signed key /// stays authoritative: the DB row may *narrow* the tier but never /// *widen* it beyond what the signature grants (see /// `clamp_to_signed_ceiling`). /// /// Behavior: /// - On-disk tier is `Unlicensed` → no-op (no license_id to look up). /// - Signed key no longer verifies (expired, tampered, corrupt) → demote /// to `Unlicensed`. /// - `licenses` row missing → keep the signed-payload tier as last-known /// (legitimate for a daemon that's never synced its row). /// - Row revoked or suspended → demote to `Unlicensed`. /// - Otherwise → keep the signed product/expiry, with entitlements taken /// from the DB row clamped to the signed ceiling. /// /// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour /// interval thereafter. Also surfaced as an admin "Refresh self-license /// tier" action for an immediate pass instead of waiting for the tick. /// /// Non-master operators in v0.3+ can extend this to consult /// `https://licensing.keysat.xyz/v1/validate` in addition to the local /// DB. For v0.2.x it is local-DB-only; an honest downstream operator's /// DB row matches its signed key, so the clamp is a no-op there. pub async fn refresh_self_tier_from_db( pool: &sqlx::SqlitePool, current: &Tier, ) -> Tier { let license_id = match current { Tier::Licensed { license_id, .. } => license_id.to_string(), Tier::Unlicensed { .. } => return current.clone(), }; // Re-read and re-verify the on-disk/env self-license key on every // pass. This is what makes the key's own EXPIRY (and any tampering or // corruption) take effect on a *running* daemon, not just at the next // restart — mirroring how the licenses we issue are re-checked on // every `/v1/validate`. Done before the DB lookup so an expired key // demotes even when the daemon has no synced `licenses` row. The // verified entitlements double as the ceiling the DB row is clamped // to below. let signed_ceiling = match read_license_string() { Some(key) => match verify_license(&key) { Ok(tier) => Some(entitlements_of(&tier)), // Present but no longer verifies — expired, tampered, or // corrupt. Demote to Creator (free), same as revoked/suspended. // A read racing a concurrent `activate` file-write could trip // this transiently; it self-heals on the next pass. Err(e) => { tracing::warn!( license_id = %license_id, "self-tier refresh: self-license no longer verifies ({e:#}); demoting to Creator (free) tier" ); return Tier::Unlicensed { reason: format!("self-license re-verification failed: {e:#}"), }; } }, // No key on disk or in env though we booted Licensed — the source // was removed. Keep last-known entitlements as the ceiling (offline // grace), but log it. None => { tracing::warn!( license_id = %license_id, "self-tier refresh: self-license source missing; keeping last-known entitlements" ); None } }; let row = match crate::db::repo::get_license_by_id(pool, &license_id).await { Ok(Some(row)) => row, Ok(None) => { // Unknown to local DB — keep signed-payload tier. Could // happen if the daemon was issued elsewhere and only has // the on-disk key, no row in `licenses`. return current.clone(); } Err(e) => { tracing::warn!(error = %e, "self-tier refresh: DB lookup failed; keeping last-known"); return current.clone(); } }; if row.revoked_at.is_some() { let reason = format!( "license revoked at {}", row.revoked_at.as_deref().unwrap_or("?") ); tracing::warn!( license_id = %license_id, "self-tier refresh: license is revoked; demoting to Unlicensed" ); return Tier::Unlicensed { reason }; } if row.suspended_at.is_some() { return Tier::Unlicensed { reason: format!( "license suspended at {}", row.suspended_at.as_deref().unwrap_or("?") ), }; } // Clamp the live DB row to the signed ceiling derived above: the row // may narrow the tier (an issuer-applied downgrade) but must never // widen it beyond what the signature authorizes. If the key source // was missing, fall back to the in-effect entitlements — themselves // already clamped on a prior pass — so a DB edit still can't widen. let ceiling = match &signed_ceiling { Some(c) => c.clone(), None => entitlements_of(current), }; let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling); // Same product / license / expiry — only the entitlement set is // live. Cheap rebuild. let product_id = uuid::Uuid::parse_str(&row.product_id).ok(); let license_id_uuid = uuid::Uuid::parse_str(&row.id).ok(); let expires_at_unix = row .expires_at .as_deref() .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) .map(|t| t.timestamp()) .unwrap_or(0); if let (Some(product_id), Some(license_id)) = (product_id, license_id_uuid) { Tier::Licensed { license_id, product_id, expires_at: expires_at_unix, entitlements, } } else { current.clone() } } /// Entitlements a tier carries; `Unlicensed` carries none. fn entitlements_of(tier: &Tier) -> Vec { match tier { Tier::Licensed { entitlements, .. } => entitlements.clone(), Tier::Unlicensed { .. } => Vec::new(), } } /// Restrict a DB-sourced entitlement set to the signed ceiling. /// /// The signed self-license key bounds what the tier may grant. The /// local `licenses` row may *narrow* the tier — an issuer-applied /// downgrade — but anything in it that the signature does not grant is /// dropped, so the row can never *widen* the tier past the ceiling. /// Kept standalone so the invariant is unit-testable without the /// offline signing key needed to mint a verifiable self-license. fn clamp_to_signed_ceiling(db_entitlements: Vec, signed: &[String]) -> Vec { db_entitlements .into_iter() .filter(|e| signed.iter().any(|s| s == e)) .collect() } #[cfg(test)] mod tests { use super::*; fn v(items: &[&str]) -> Vec { items.iter().map(|s| s.to_string()).collect() } #[test] fn db_row_cannot_widen_beyond_signed_ceiling() { // Signed key grants only the free tier; a tampered DB row // claiming top-tier entitlements is stripped to the signed set. let signed = v(&["creator_only"]); let tampered = v(&[ "unlimited_products", "unlimited_policies", "recurring_billing", "zaprite_payments", "patron", "creator_only", ]); assert_eq!( clamp_to_signed_ceiling(tampered, &signed), v(&["creator_only"]) ); } #[test] fn db_row_may_narrow_below_signed_ceiling() { // Signed key grants a broad set; an issuer-applied downgrade to // a smaller set in the DB row is honored (narrowing is allowed). let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]); let downgraded = v(&["unlimited_products"]); assert_eq!( clamp_to_signed_ceiling(downgraded, &signed), v(&["unlimited_products"]) ); } #[test] fn matching_entitlements_pass_through_unchanged() { let signed = v(&["unlimited_products", "recurring_billing"]); let db = v(&["unlimited_products", "recurring_billing"]); assert_eq!(clamp_to_signed_ceiling(db.clone(), &signed), db); } #[test] fn empty_signed_ceiling_strips_everything() { let db = v(&["unlimited_products", "patron"]); assert!(clamp_to_signed_ceiling(db, &[]).is_empty()); } #[test] fn partial_downgrade_keeps_the_still_granted_entitlements() { // Multi-entitlement signed key; the DB row drops one of them // (an issuer-applied partial downgrade) and keeps the rest. let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]); let db = v(&["unlimited_products", "zaprite_payments"]); assert_eq!( clamp_to_signed_ceiling(db, &signed), v(&["unlimited_products", "zaprite_payments"]) ); } }