Re-verify self-license on tier refresh

This commit is contained in:
Grant
2026-06-19 11:48:25 -05:00
parent 9f08a72619
commit 97bf9cc843
+63 -16
View File
@@ -205,13 +205,16 @@ fn log_licensed(tier: &Tier) {
/// 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,
/// and downgrades — by re-reading the `licenses` row by license_id and
/// applying its current state. 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`).
/// 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`.
@@ -235,6 +238,43 @@ pub async fn refresh_self_tier_from_db(
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) => {
@@ -269,18 +309,13 @@ pub async fn refresh_self_tier_from_db(
};
}
// The signed key is the ceiling. Re-derive the entitlements it
// grants — re-verifying it against the embedded trust root — and
// clamp the live DB row to that set: the local row may narrow the
// 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),
// 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);
@@ -382,4 +417,16 @@ mod tests {
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"])
);
}
}