Harden self-license tier refresh
This commit is contained in:
@@ -201,43 +201,31 @@ fn log_licensed(tier: &Tier) {
|
|||||||
|
|
||||||
/// Live-refresh the daemon's self-tier from the local `licenses` row.
|
/// Live-refresh the daemon's self-tier from the local `licenses` row.
|
||||||
///
|
///
|
||||||
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
|
/// `check_at_boot` verifies the on-disk LIC1 key against the embedded
|
||||||
/// extracts entitlements from the SIGNED PAYLOAD. Those entitlements
|
/// trust root and reads its entitlements from the signed payload. That
|
||||||
/// are immutable for the life of that key — the operator can't ever
|
/// signed set is the ceiling. This function lets issuer-applied changes
|
||||||
/// downgrade themselves by editing the DB row, because the daemon
|
/// reach a running daemon without a restart — revocations, suspensions,
|
||||||
/// trusts the signature, not the DB.
|
/// and downgrades — by re-reading the `licenses` row by license_id and
|
||||||
///
|
/// applying its current state. The signed key stays authoritative: the
|
||||||
/// In practice that means tier upgrades / downgrades / revocations
|
/// DB row may *narrow* the tier but never *widen* it beyond what the
|
||||||
/// applied via admin (or eventually, via an upstream master) don't
|
/// signature grants (see `clamp_to_signed_ceiling`).
|
||||||
/// propagate to a running daemon — even though the daemon is online
|
|
||||||
/// and the data is right there in its own DB. This function is the
|
|
||||||
/// fix: re-read the licenses row by license_id and use the LIVE
|
|
||||||
/// entitlements + revocation status. The on-disk signed key is kept
|
|
||||||
/// as proof-of-authenticity (signature still verifies) but the live
|
|
||||||
/// DB row is the source of tier truth.
|
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
/// - If the on-disk tier is `Unlicensed`, do nothing — there's no
|
/// - On-disk tier is `Unlicensed` → no-op (no license_id to look up).
|
||||||
/// license_id to look up.
|
/// - `licenses` row missing → keep the signed-payload tier as last-known
|
||||||
/// - If the licenses row is missing in the DB (legitimate for a
|
/// (legitimate for a daemon that's never synced its row).
|
||||||
/// daemon that's never been online to sync, e.g.), keep the
|
/// - Row revoked or suspended → demote to `Unlicensed`.
|
||||||
/// signed-payload tier as last-known.
|
/// - Otherwise → keep the signed product/expiry, with entitlements taken
|
||||||
/// - If the row is revoked, demote to `Unlicensed { reason: "revoked" }`.
|
/// from the DB row clamped to the signed ceiling.
|
||||||
/// - Otherwise, replace the entitlements vec with whatever the DB
|
|
||||||
/// row currently says.
|
|
||||||
///
|
///
|
||||||
/// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour
|
/// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour
|
||||||
/// interval thereafter. Also surfaced as an admin "Refresh
|
/// interval thereafter. Also surfaced as an admin "Refresh self-license
|
||||||
/// self-license tier" action for operators who want to trigger
|
/// tier" action for an immediate pass instead of waiting for the tick.
|
||||||
/// immediately after a change instead of waiting for the next tick.
|
|
||||||
///
|
///
|
||||||
/// Non-master operators in v0.3+ can extend this to call
|
/// Non-master operators in v0.3+ can extend this to consult
|
||||||
/// `https://licensing.keysat.xyz/v1/validate` instead of (or in
|
/// `https://licensing.keysat.xyz/v1/validate` in addition to the local
|
||||||
/// addition to) the local DB. For v0.2.x, local-DB-only — which is
|
/// DB. For v0.2.x it is local-DB-only; an honest downstream operator's
|
||||||
/// the right thing for the master Keysat (which is selling its own
|
/// DB row matches its signed key, so the clamp is a no-op there.
|
||||||
/// licenses) and a no-op-but-safe for downstream operators (their
|
|
||||||
/// own DB row hasn't been mutated, so live read returns the same
|
|
||||||
/// thing as the boot-time signed-payload extraction).
|
|
||||||
pub async fn refresh_self_tier_from_db(
|
pub async fn refresh_self_tier_from_db(
|
||||||
pool: &sqlx::SqlitePool,
|
pool: &sqlx::SqlitePool,
|
||||||
current: &Tier,
|
current: &Tier,
|
||||||
@@ -281,10 +269,21 @@ pub async fn refresh_self_tier_from_db(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the LIVE entitlements from the DB. These can differ from
|
// The signed key is the ceiling. Re-derive the entitlements it
|
||||||
// the signed payload's entitlements (which were baked at signing
|
// grants — re-verifying it against the embedded trust root — and
|
||||||
// time) if an admin has done a Change Tier on this license.
|
// clamp the live DB row to that set: the local row may narrow the
|
||||||
let entitlements = row.entitlements.clone();
|
// tier (a downgrade applied by the issuer) but must never widen it
|
||||||
|
// beyond what the signature authorizes. Activation keeps the on-disk
|
||||||
|
// key in sync, so this tracks the current license at boot and at
|
||||||
|
// runtime. If the key can't be re-read mid-run, fall back to the
|
||||||
|
// in-effect entitlements — themselves already clamped on a prior
|
||||||
|
// pass — so a DB edit still can't widen the tier.
|
||||||
|
let signed = read_license_string().and_then(|s| verify_license(&s).ok());
|
||||||
|
let ceiling = match &signed {
|
||||||
|
Some(tier) => entitlements_of(tier),
|
||||||
|
None => entitlements_of(current),
|
||||||
|
};
|
||||||
|
let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling);
|
||||||
|
|
||||||
// Same product / license / expiry — only the entitlement set is
|
// Same product / license / expiry — only the entitlement set is
|
||||||
// live. Cheap rebuild.
|
// live. Cheap rebuild.
|
||||||
@@ -308,3 +307,79 @@ pub async fn refresh_self_tier_from_db(
|
|||||||
current.clone()
|
current.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entitlements a tier carries; `Unlicensed` carries none.
|
||||||
|
fn entitlements_of(tier: &Tier) -> Vec<String> {
|
||||||
|
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<String>, signed: &[String]) -> Vec<String> {
|
||||||
|
db_entitlements
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| signed.iter().any(|s| s == e))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn v(items: &[&str]) -> Vec<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user