From dec9994e06fac2114700e01a54b4560f980db0e6 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 2 Jul 2026 19:47:38 -0400 Subject: [PATCH] fix(config): fail closed on POW_REQUIRED without FEATURE_POW; harden premium key check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- openapi.json | 2 +- src/api/capabilities.rs | 2 +- src/config.rs | 29 ++++++++++++++++++++++++----- tests/premium.rs | 20 ++++++++++++-------- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/openapi.json b/openapi.json index 20805ab..390a35c 100644 --- a/openapi.json +++ b/openapi.json @@ -2968,7 +2968,7 @@ }, "write_policy": { "type": "string", - "description": "Write policy: `inbox-outbox` | `author-allowlist` | `open`." + "description": "Write policy: `inbox-outbox` | `author-allowlist` | `open` | `premium-post`." } } }, diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index f2c920b..9a10e86 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -60,7 +60,7 @@ pub struct FeatureCapabilities { pub struct MessagingCapability { /// The ws(s):// relay URL clients connect to. pub relay_url: String, - /// Write policy: `inbox-outbox` | `author-allowlist` | `open`. + /// Write policy: `inbox-outbox` | `author-allowlist` | `open` | `premium-post`. pub write_policy: String, /// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off). pub inbound_pow_bits: u8, diff --git a/src/config.rs b/src/config.rs index cb3b716..42e89c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -865,6 +865,16 @@ impl Config { if self.pow.enabled { 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. if self.admin.enabled { @@ -1116,11 +1126,7 @@ impl Config { if p.store_id.trim().is_empty() { return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_STORE_ID")); } - if p.api_key.len() < 8 { - return Err(cfg_err( - "PREMIUM_ENABLED needs PAYMENT_API_KEY (>= 8 bytes)", - )); - } + require_secret("PAYMENT_API_KEY", &p.api_key, 8)?; // The same processor-safety floors the pay-to-register path enforces — // premium's common case is require_payment=false, which skips that // block. A 0-conf invoice settles on first mempool sighting and can @@ -1388,6 +1394,19 @@ mod tests { 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] fn enabled_chain_requires_secret_in_production() { let mut c = valid(); diff --git a/tests/premium.rs b/tests/premium.rs index 599c9f6..95cdfe9 100644 --- a/tests/premium.rs +++ b/tests/premium.rs @@ -30,10 +30,13 @@ async fn activate_premium_is_single_use_and_stacks() { let db = &app.state.db; 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 .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 .unwrap(); assert_eq!( @@ -43,7 +46,7 @@ async fn activate_premium_is_single_use_and_stacks() { // First activation grants a future expiry. let until1 = db - .activate_premium("inv-a", user_id, 90) + .activate_premium(&inv_a, user_id, 90) .await .unwrap() .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. assert!(db - .activate_premium("inv-a", user_id, 90) + .activate_premium(&inv_a, user_id, 90) .await .unwrap() .is_none()); // A second invoice STACKS from the current expiry. let until2 = db - .activate_premium("inv-b", user_id, 90) + .activate_premium(&inv_b, user_id, 90) .await .unwrap() .expect("second activation grants"); @@ -77,13 +80,14 @@ async fn activate_premium_rejects_cross_user_invoice() { let db = &app.state.db; let owner = 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 .unwrap(); // Another user cannot activate the owner's invoice; it stays unconsumed. assert!(db - .activate_premium("inv-x", attacker, 90) + .activate_premium(&inv_x, attacker, 90) .await .unwrap() .is_none()); @@ -98,7 +102,7 @@ async fn activate_premium_rejects_cross_user_invoice() { async fn is_premium_npub_respects_membership_and_expiry() { let app = require_app!(); let db = &app.state.db; - let npub = "a".repeat(64); + let npub = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let user = db .create_user(new_user_with_npub(Some(npub.clone()))) .await