Re-verify self-license on tier refresh
This commit is contained in:
@@ -205,13 +205,16 @@ fn log_licensed(tier: &Tier) {
|
|||||||
/// trust root and reads its entitlements from the signed payload. That
|
/// trust root and reads its entitlements from the signed payload. That
|
||||||
/// signed set is the ceiling. This function lets issuer-applied changes
|
/// signed set is the ceiling. This function lets issuer-applied changes
|
||||||
/// reach a running daemon without a restart — revocations, suspensions,
|
/// reach a running daemon without a restart — revocations, suspensions,
|
||||||
/// and downgrades — by re-reading the `licenses` row by license_id and
|
/// downgrades, and the key's own expiry — by re-verifying the on-disk
|
||||||
/// applying its current state. The signed key stays authoritative: the
|
/// key and re-reading the `licenses` row by license_id. The signed key
|
||||||
/// DB row may *narrow* the tier but never *widen* it beyond what the
|
/// stays authoritative: the DB row may *narrow* the tier but never
|
||||||
/// signature grants (see `clamp_to_signed_ceiling`).
|
/// *widen* it beyond what the signature grants (see
|
||||||
|
/// `clamp_to_signed_ceiling`).
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
/// - On-disk tier is `Unlicensed` → no-op (no license_id to look up).
|
/// - 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
|
/// - `licenses` row missing → keep the signed-payload tier as last-known
|
||||||
/// (legitimate for a daemon that's never synced its row).
|
/// (legitimate for a daemon that's never synced its row).
|
||||||
/// - Row revoked or suspended → demote to `Unlicensed`.
|
/// - Row revoked or suspended → demote to `Unlicensed`.
|
||||||
@@ -235,6 +238,43 @@ pub async fn refresh_self_tier_from_db(
|
|||||||
Tier::Unlicensed { .. } => return current.clone(),
|
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 {
|
let row = match crate::db::repo::get_license_by_id(pool, &license_id).await {
|
||||||
Ok(Some(row)) => row,
|
Ok(Some(row)) => row,
|
||||||
Ok(None) => {
|
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
|
// Clamp the live DB row to the signed ceiling derived above: the row
|
||||||
// grants — re-verifying it against the embedded trust root — and
|
// may narrow the tier (an issuer-applied downgrade) but must never
|
||||||
// clamp the live DB row to that set: the local row may narrow the
|
// widen it beyond what the signature authorizes. If the key source
|
||||||
// tier (a downgrade applied by the issuer) but must never widen it
|
// was missing, fall back to the in-effect entitlements — themselves
|
||||||
// beyond what the signature authorizes. Activation keeps the on-disk
|
// already clamped on a prior pass — so a DB edit still can't widen.
|
||||||
// key in sync, so this tracks the current license at boot and at
|
let ceiling = match &signed_ceiling {
|
||||||
// runtime. If the key can't be re-read mid-run, fall back to the
|
Some(c) => c.clone(),
|
||||||
// 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),
|
None => entitlements_of(current),
|
||||||
};
|
};
|
||||||
let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling);
|
let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling);
|
||||||
@@ -382,4 +417,16 @@ mod tests {
|
|||||||
let db = v(&["unlimited_products", "patron"]);
|
let db = v(&["unlimited_products", "patron"]);
|
||||||
assert!(clamp_to_signed_ceiling(db, &[]).is_empty());
|
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"])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user