mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
fix(config): fail closed on POW_REQUIRED without FEATURE_POW; harden premium key check
- Reject boot when POW_REQUIRED (or the per-pubkey PoW list) is set while FEATURE_POW is off: the gate would silently never apply and /capabilities would report pow_required:false — a signup endpoint the operator believes is protected but isn't. - Validate the premium PAYMENT_API_KEY through the same require_secret path the pay-to-register flow uses, so a placeholder key cannot reach production. - Advertise the premium-post write policy in MessagingCapability and regenerate openapi.json to match. - Give the premium integration fixtures unique per-run ids so the suite is re-runnable against a persistent local test database.
This commit is contained in:
+1
-1
@@ -2968,7 +2968,7 @@
|
|||||||
},
|
},
|
||||||
"write_policy": {
|
"write_policy": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Write policy: `inbox-outbox` | `author-allowlist` | `open`."
|
"description": "Write policy: `inbox-outbox` | `author-allowlist` | `open` | `premium-post`."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pub struct FeatureCapabilities {
|
|||||||
pub struct MessagingCapability {
|
pub struct MessagingCapability {
|
||||||
/// The ws(s):// relay URL clients connect to.
|
/// The ws(s):// relay URL clients connect to.
|
||||||
pub relay_url: String,
|
pub relay_url: String,
|
||||||
/// Write policy: `inbox-outbox` | `author-allowlist` | `open`.
|
/// Write policy: `inbox-outbox` | `author-allowlist` | `open` | `premium-post`.
|
||||||
pub write_policy: String,
|
pub write_policy: String,
|
||||||
/// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off).
|
/// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off).
|
||||||
pub inbound_pow_bits: u8,
|
pub inbound_pow_bits: u8,
|
||||||
|
|||||||
+24
-5
@@ -865,6 +865,16 @@ impl Config {
|
|||||||
if self.pow.enabled {
|
if self.pow.enabled {
|
||||||
require_secret("ALTCHA_HMAC_KEY", &self.pow.hmac_key, 32)?;
|
require_secret("ALTCHA_HMAC_KEY", &self.pow.hmac_key, 32)?;
|
||||||
}
|
}
|
||||||
|
// Fail closed on the silent-disarm footgun: asking for PoW (POW_REQUIRED or a
|
||||||
|
// per-pubkey list) while the feature master switch is off means the gate never
|
||||||
|
// applies and /capabilities reports pow_required:false — a signup endpoint the
|
||||||
|
// operator believes is protected but isn't.
|
||||||
|
if !self.pow.enabled && (self.pow.required || !self.pow.required_for_pubkeys.is_empty()) {
|
||||||
|
return Err(cfg_err(
|
||||||
|
"POW_REQUIRED (or TEST_POW_REQUIRED_FOR_PUBKEYS) is set but FEATURE_POW is off \
|
||||||
|
— the PoW gate would never apply; set FEATURE_POW=true",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Admin surface: dedicated secrets + a real public URL when enabled.
|
// Admin surface: dedicated secrets + a real public URL when enabled.
|
||||||
if self.admin.enabled {
|
if self.admin.enabled {
|
||||||
@@ -1116,11 +1126,7 @@ impl Config {
|
|||||||
if p.store_id.trim().is_empty() {
|
if p.store_id.trim().is_empty() {
|
||||||
return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_STORE_ID"));
|
return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_STORE_ID"));
|
||||||
}
|
}
|
||||||
if p.api_key.len() < 8 {
|
require_secret("PAYMENT_API_KEY", &p.api_key, 8)?;
|
||||||
return Err(cfg_err(
|
|
||||||
"PREMIUM_ENABLED needs PAYMENT_API_KEY (>= 8 bytes)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// The same processor-safety floors the pay-to-register path enforces —
|
// The same processor-safety floors the pay-to-register path enforces —
|
||||||
// premium's common case is require_payment=false, which skips that
|
// premium's common case is require_payment=false, which skips that
|
||||||
// block. A 0-conf invoice settles on first mempool sighting and can
|
// block. A 0-conf invoice settles on first mempool sighting and can
|
||||||
@@ -1388,6 +1394,19 @@ mod tests {
|
|||||||
assert!(c.validate().is_err());
|
assert!(c.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pow_required_without_feature_pow_rejected() {
|
||||||
|
// POW_REQUIRED=true while FEATURE_POW is off silently disarms the gate.
|
||||||
|
let mut c = valid();
|
||||||
|
c.pow.enabled = false;
|
||||||
|
c.pow.required = true;
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
// Enabling the master switch (with a key) makes it valid.
|
||||||
|
c.pow.enabled = true;
|
||||||
|
c.pow.hmac_key = "a".repeat(32);
|
||||||
|
assert!(c.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enabled_chain_requires_secret_in_production() {
|
fn enabled_chain_requires_secret_in_production() {
|
||||||
let mut c = valid();
|
let mut c = valid();
|
||||||
|
|||||||
+12
-8
@@ -30,10 +30,13 @@ async fn activate_premium_is_single_use_and_stacks() {
|
|||||||
let db = &app.state.db;
|
let db = &app.state.db;
|
||||||
let user_id = app.create_user().await;
|
let user_id = app.create_user().await;
|
||||||
|
|
||||||
db.insert_premium_invoice("inv-a", user_id, "btcpay", "quarter", 90, "5", "USD")
|
// Unique per run so the suite is re-runnable against a persistent test DB.
|
||||||
|
let inv_a = format!("inv-a-{}", Uuid::new_v4());
|
||||||
|
let inv_b = format!("inv-b-{}", Uuid::new_v4());
|
||||||
|
db.insert_premium_invoice(&inv_a, user_id, "btcpay", "quarter", 90, "5", "USD")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.insert_premium_invoice("inv-b", user_id, "btcpay", "quarter", 90, "5", "USD")
|
db.insert_premium_invoice(&inv_b, user_id, "btcpay", "quarter", 90, "5", "USD")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -43,7 +46,7 @@ async fn activate_premium_is_single_use_and_stacks() {
|
|||||||
|
|
||||||
// First activation grants a future expiry.
|
// First activation grants a future expiry.
|
||||||
let until1 = db
|
let until1 = db
|
||||||
.activate_premium("inv-a", user_id, 90)
|
.activate_premium(&inv_a, user_id, 90)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("first activation grants");
|
.expect("first activation grants");
|
||||||
@@ -51,14 +54,14 @@ async fn activate_premium_is_single_use_and_stacks() {
|
|||||||
|
|
||||||
// Re-activating the SAME invoice is a no-op (single-use) — no double grant.
|
// Re-activating the SAME invoice is a no-op (single-use) — no double grant.
|
||||||
assert!(db
|
assert!(db
|
||||||
.activate_premium("inv-a", user_id, 90)
|
.activate_premium(&inv_a, user_id, 90)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none());
|
.is_none());
|
||||||
|
|
||||||
// A second invoice STACKS from the current expiry.
|
// A second invoice STACKS from the current expiry.
|
||||||
let until2 = db
|
let until2 = db
|
||||||
.activate_premium("inv-b", user_id, 90)
|
.activate_premium(&inv_b, user_id, 90)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("second activation grants");
|
.expect("second activation grants");
|
||||||
@@ -77,13 +80,14 @@ async fn activate_premium_rejects_cross_user_invoice() {
|
|||||||
let db = &app.state.db;
|
let db = &app.state.db;
|
||||||
let owner = app.create_user().await;
|
let owner = app.create_user().await;
|
||||||
let attacker = app.create_user().await;
|
let attacker = app.create_user().await;
|
||||||
db.insert_premium_invoice("inv-x", owner, "btcpay", "quarter", 90, "5", "USD")
|
let inv_x = format!("inv-x-{}", Uuid::new_v4());
|
||||||
|
db.insert_premium_invoice(&inv_x, owner, "btcpay", "quarter", 90, "5", "USD")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Another user cannot activate the owner's invoice; it stays unconsumed.
|
// Another user cannot activate the owner's invoice; it stays unconsumed.
|
||||||
assert!(db
|
assert!(db
|
||||||
.activate_premium("inv-x", attacker, 90)
|
.activate_premium(&inv_x, attacker, 90)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none());
|
.is_none());
|
||||||
@@ -98,7 +102,7 @@ async fn activate_premium_rejects_cross_user_invoice() {
|
|||||||
async fn is_premium_npub_respects_membership_and_expiry() {
|
async fn is_premium_npub_respects_membership_and_expiry() {
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let db = &app.state.db;
|
let db = &app.state.db;
|
||||||
let npub = "a".repeat(64);
|
let npub = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||||
let user = db
|
let user = db
|
||||||
.create_user(new_user_with_npub(Some(npub.clone())))
|
.create_user(new_user_with_npub(Some(npub.clone())))
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user