diff --git a/licensing-service/migrations/0025_authorize_state_scoped_initiator.sql b/licensing-service/migrations/0025_authorize_state_scoped_initiator.sql new file mode 100644 index 0000000..049db08 --- /dev/null +++ b/licensing-service/migrations/0025_authorize_state_scoped_initiator.sql @@ -0,0 +1,28 @@ +-- Carry the connect *initiator* through the BTCPay OAuth round trip. +-- +-- agent-payment-connect (plans/agent-payment-connect-scope.md): a scoped key +-- bearing `payment_providers:write` may start a BTCPay connect, but only on a +-- sandbox daemon (outer gate) AND only for a non-mainnet store (inner gate). +-- The inner gate can only be evaluated at callback time — that's the first +-- moment we know the store and can resolve its network. So the connect handler +-- must remember, across the operator's browser round-trip to BTCPay, whether +-- the initiator was the master key (may connect any network) or a scoped key +-- (restricted to non-mainnet). +-- +-- `scoped_initiator`: 0 = master (no network restriction), 1 = scoped key +-- (callback enforces non-mainnet, fail-closed). Default 0 keeps any in-flight +-- pre-upgrade state token behaving as a master connect (the only kind that +-- existed before this migration). +-- `initiator_actor_hash`: sha256 of the initiating credential, so the callback +-- can write an audit row attributing the scoped connect without a header. +-- +-- Additive, one-way (consistent with 0020-0022). The table is also pruned by +-- timestamp, so any pre-migration rows expire within 30 minutes regardless. + +PRAGMA foreign_keys = ON; + +ALTER TABLE btcpay_authorize_state + ADD COLUMN scoped_initiator INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE btcpay_authorize_state + ADD COLUMN initiator_actor_hash TEXT; diff --git a/licensing-service/src/api/api_keys.rs b/licensing-service/src/api/api_keys.rs index 6eefd50..34b06e3 100644 --- a/licensing-service/src/api/api_keys.rs +++ b/licensing-service/src/api/api_keys.rs @@ -227,6 +227,91 @@ pub async fn require_scope( Ok(token_hash) } +/// Who initiated a payment-provider connect — determines the network gate at +/// callback time (`btcpay_authorize::finish_connect`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectInitiator { + /// The master `admin_api_key`. May connect any network. + Master, + /// A scoped key carrying `payment_providers:write` on a sandbox daemon. + /// Restricted to non-mainnet stores (enforced after the OAuth round-trip, + /// once the store + network are known). + Scoped, +} + +/// Gate for **starting** a BTCPay provider connect — the fund-redirection- +/// sensitive operation. Stricter than `require_scope`: a scoped key reaches it +/// ONLY with the à-la-carte `payment_providers:write` scope AND only on a +/// **sandbox daemon** (the OUTER gate — on a production box scoped connect is +/// disabled entirely, even for regtest, since a scoped key re-pointing +/// settlement on a live box is denial-of-revenue). The INNER gate (target +/// network must be non-mainnet) is enforced separately at callback time, once +/// the store is known. See `plans/agent-payment-connect-scope.md` §5. +/// +/// Returns `(actor_hash, initiator)`. The caller records `initiator` in the +/// authorize-state row so the callback can apply the network gate. Master keys +/// bypass both gates (still subject to BTCPay's own OAuth approval). +pub async fn require_provider_connect( + state: &AppState, + headers: &HeaderMap, +) -> AppResult<(String, ConnectInitiator)> { + let header_val = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + let token = header_val + .strip_prefix("Bearer ") + .ok_or(AppError::Unauthorized)?; + + // Master admin key — full bypass, may connect any network. + if bool::from( + token + .as_bytes() + .ct_eq(state.config.admin_api_key.as_bytes()), + ) { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + return Ok((hex::encode(hasher.finalize()), ConnectInitiator::Master)); + } + + // Scoped key — must carry `payment_providers:write` (never role-granted; + // only via à-la-carte `extra_scopes`) AND the daemon must be in sandbox mode. + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + let row: Option<(String, String, Option, Option)> = sqlx::query_as( + "SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?", + ) + .bind(&token_hash) + .fetch_optional(&state.db) + .await?; + + let (key_id, role_str, revoked_at, extra_scopes_json) = row.ok_or(AppError::Forbidden)?; + if revoked_at.is_some() { + return Err(AppError::Forbidden); + } + let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?; + let has_scope = role.grants("payment_providers:write") + || extra_scopes_contains(extra_scopes_json.as_deref(), "payment_providers:write"); + if !has_scope { + return Err(AppError::Forbidden); + } + // OUTER gate: scoped connect is permitted only on a sandbox daemon. + if !state.config.sandbox_mode { + return Err(AppError::Forbidden); + } + + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?") + .bind(&now) + .bind(&key_id) + .execute(&state.db) + .await; + + Ok((token_hash, ConnectInitiator::Scoped)) +} + // ---------- CRUD endpoints (gated on master admin only) ---------- #[derive(Debug, Deserialize)] diff --git a/licensing-service/src/api/btcpay_authorize.rs b/licensing-service/src/api/btcpay_authorize.rs index d323f99..83da151 100644 --- a/licensing-service/src/api/btcpay_authorize.rs +++ b/licensing-service/src/api/btcpay_authorize.rs @@ -22,9 +22,14 @@ //! callback path uses the CSRF `state` token to tie a callback back to the //! issuing operator session. -use crate::api::{admin::{require_admin, require_scope}, AppState}; +use crate::api::{ + admin::{require_admin, require_scope}, + api_keys::{require_provider_connect, ConnectInitiator}, + AppState, +}; use crate::btcpay::client::{self as btcpay_client, BtcpayClient}; use crate::btcpay::config as btcpay_cfg; +use crate::btcpay::network::BitcoinNetwork; use crate::error::{AppError, AppResult}; use crate::payment::btcpay::BtcpayProvider; use std::sync::Arc; @@ -84,7 +89,12 @@ pub async fn start_connect( headers: HeaderMap, body: Option>, ) -> AppResult> { - require_admin(&state, &headers)?; + // Master key → connect any network. Scoped key with `payment_providers:write` + // → permitted ONLY on a sandbox daemon (outer gate); the non-mainnet inner + // gate is enforced at callback time once the store is known. See + // `plans/agent-payment-connect-scope.md` §5. + let (actor_hash, initiator) = require_provider_connect(&state, &headers).await?; + let scoped_initiator = matches!(initiator, ConnectInitiator::Scoped); let req = body.map(|Json(b)| b).unwrap_or_default(); // Resolve the target merchant profile (defaulting to the default). @@ -115,9 +125,17 @@ pub async fn start_connect( rand::thread_rng().fill_bytes(&mut raw); let state_token = BASE32_NOPAD.encode(&raw); - btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id)) - .await - .map_err(AppError::Internal)?; + btcpay_cfg::record_authorize_state( + &state.db, + &state_token, + Some(&profile.id), + scoped_initiator, + // Only stored for scoped connects (the callback's audit row). Master + // connects are covered by the StartOS action audit trail. + scoped_initiator.then_some(actor_hash.as_str()), + ) + .await + .map_err(AppError::Internal)?; // Construct the authorize URL per BTCPay's docs. // https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys @@ -226,11 +244,18 @@ pub async fn callback_get( Ok(()) => success_page( "BTCPay connected successfully. You can close this tab and return to Keysat.", ), - Err(e) => Html(format!( - "

BTCPay authorization failed

{}

", - html_escape::encode_text(&e.to_string()) - )) - .into_response(), + // Carry the error's HTTP status onto the HTML page so a denied connect + // (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a + // non-2xx an agent can detect, not a misleading 200. Matches the POST + // callback, which propagates the status via `?`. + Err(e) => ( + e.status_code(), + Html(format!( + "

BTCPay authorization failed

{}

", + html_escape::encode_text(&e.to_string()) + )), + ) + .into_response(), } } @@ -302,10 +327,10 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A // Recovers the `merchant_profile_id` recorded when the operator // kicked off the connect flow. NULL falls back to the default // profile (back-compat for state tokens from pre-0022 runs). - let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token) + let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token) .await .map_err(|_| AppError::Unauthorized)?; - let profile = match recorded_profile_id.as_deref() { + let profile = match auth_state.merchant_profile_id.as_deref() { Some(id) => crate::merchant_profiles::get(&state.db, id) .await? .ok_or_else(|| AppError::BadRequest(format!( @@ -331,6 +356,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A "The authorized API key has access to zero stores. Re-run connect and pick a store.".into() ))?; + // INNER gate (scoped initiators only): the target store must settle on a + // non-mainnet network. This is the first point in the flow where we know + // the store, so detection happens here — BEFORE registering any webhook or + // persisting the provider. Fail closed: if the network can't be positively + // determined as non-mainnet, treat it as mainnet and refuse. Master + // initiators skip this entirely (they may connect any network). + let resolved_network = if auth_state.scoped_initiator { + let network = match btcpay_client::fetch_onchain_network(base_url, api_key, &store.id).await { + Ok(Some(net)) => net, + Ok(None) => { + tracing::warn!( + store = %store.id, + "scoped BTCPay connect: on-chain network undetermined → fail-closed to mainnet (deny)" + ); + BitcoinNetwork::Mainnet + } + Err(e) => { + tracing::warn!( + store = %store.id, error = %format!("{e:#}"), + "scoped BTCPay connect: network detection errored → fail-closed to mainnet (deny)" + ); + BitcoinNetwork::Mainnet + } + }; + if network.is_mainnet() { + return Err(AppError::BadRequest(format!( + "Scoped payment-provider connect is restricted to non-mainnet \ + (regtest/testnet/signet) BTCPay stores; the selected store resolved \ + to '{}'. Use the master admin key to connect a mainnet store.", + network.as_str() + ))); + } + Some(network) + } else { + None + }; + // Generate a strong webhook secret, then register the webhook on BTCPay. let mut raw_secret = [0u8; 32]; rand::thread_rng().fill_bytes(&mut raw_secret); @@ -387,14 +449,40 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A state.set_payment_provider(provider).await; } + let network_str = resolved_network.map(|n| n.as_str()); tracing::info!( provider_id = %provider_id, merchant_profile_id = %profile.id, store = %store.id, store_name = %store.name, webhook_id = %created_webhook.id, + scoped = auth_state.scoped_initiator, + network = network_str.unwrap_or("master/any"), "BTCPay connected via authorize flow" ); + + // Audit every scoped connect (spec §7) — attributes the fund-redirection- + // sensitive op to the initiating credential + the resolved network. Master + // connects are already covered by the StartOS action audit trail. + if auth_state.scoped_initiator { + let _ = crate::db::repo::insert_audit( + &state.db, + "scoped_api_key", + auth_state.initiator_actor_hash.as_deref(), + "payment_provider.connect_scoped", + Some("payment_provider"), + Some(&provider_id), + None, + None, + &json!({ + "kind": "btcpay", + "store_id": store.id, + "merchant_profile_id": profile.id, + "network": network_str, + }), + ) + .await; + } Ok(()) } diff --git a/licensing-service/src/api/openapi.rs b/licensing-service/src/api/openapi.rs index 82b86df..f422660 100644 --- a/licensing-service/src/api/openapi.rs +++ b/licensing-service/src/api/openapi.rs @@ -400,7 +400,8 @@ const SPEC_JSON: &str = r##"{ "type": "object", "properties": { "label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" }, - "role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] } + "role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] }, + "scopes": { "type": "array", "items": { "type": "string", "enum": ["payment_providers:write"] }, "description": "A-la-carte extra scopes granted on top of the role. Only payment_providers:write today: lets the key connect a non-mainnet BTCPay provider on a sandbox daemon. In no role by default." } }, "required": ["label", "role"] } } } @@ -418,9 +419,44 @@ const SPEC_JSON: &str = r##"{ "/v1/admin/tier": { "get": { "summary": "Get this daemon's tier + usage + caps", - "description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.", + "description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier. Includes a read-only `sandbox` boolean (true when KEYSAT_SANDBOX_MODE is set).", "responses": { "200": { "description": "Tier info" } } } + }, + "/v1/admin/btcpay/connect": { + "post": { + "summary": "Start a BTCPay provider connect", + "description": "Returns a one-time `state` token and the BTCPay authorize URL; complete the connect at /v1/btcpay/authorize/callback. The master key may connect any network. A scoped key needs the `payment_providers:write` extra scope AND a sandbox daemon (KEYSAT_SANDBOX_MODE); the target store must resolve to a non-mainnet network or the callback refuses. Optional JSON body: { merchant_profile_id }.", + "responses": { + "200": { "description": "{ authorize_url, state, merchant_profile_id }" }, + "403": { "description": "Scoped key without payment_providers:write, or not a sandbox daemon" }, + "409": { "description": "Profile already has a BTCPay provider; disconnect first" } + } + } + }, + "/v1/btcpay/authorize/callback": { + "get": { + "summary": "Complete a BTCPay connect", + "description": "BTCPay redirects here after the operator approves in a browser, or an agent calls it directly with a pre-issued store API key. Query params: `state` (from /connect) and `apiKey` (a BTCPay store key with the same store-settings + invoice permissions the browser flow grants). Keysat resolves the store's network and, for a scoped initiator, refuses anything not provably non-mainnet (fail-closed). No auth header; the single-use `state` token is the tie. A refusal returns a 4xx on both the GET and POST forms.", + "responses": { + "200": { "description": "Connected (HTML confirmation page)" }, + "400": { "description": "Scoped connect to a mainnet/undetermined store; nothing persisted" } + } + } + }, + "/v1/admin/btcpay/status": { + "get": { + "summary": "BTCPay connection status (default profile)", + "description": "Requires payment_providers:read. Returns { connected, store_id, base_url, webhook_id, ... }.", + "responses": { "200": { "description": "Connection status" } } + } + }, + "/v1/admin/btcpay/disconnect": { + "post": { + "summary": "Disconnect a BTCPay provider", + "description": "Master admin key required, on any daemon. Best-effort revokes the webhook + key on BTCPay, then clears the local provider row.", + "responses": { "200": { "description": "Disconnected (or no-op)" } } + } } } }"##; diff --git a/licensing-service/src/btcpay/client.rs b/licensing-service/src/btcpay/client.rs index 40a7da4..a205fa2 100644 --- a/licensing-service/src/btcpay/client.rs +++ b/licensing-service/src/btcpay/client.rs @@ -366,3 +366,87 @@ pub async fn list_payment_methods( .cloned() .unwrap_or_default()) } + +/// Resolve the Bitcoin **network** a store settles on, for the scoped +/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1). +/// +/// Lists the store's payment methods, finds the on-chain BTC method +/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never +/// hardcode), fetches a receive address, and classifies the address prefix. +/// +/// Returns: +/// - `Ok(Some(network))` when positively determined; +/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no +/// address, Lightning-only store, BTCPay not yet synced → `503`, or an +/// unrecognized prefix). The caller MUST fail closed (treat `None` as +/// mainnet and deny the scoped connect). +/// +/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which +/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`). +pub async fn fetch_onchain_network( + base_url: &str, + api_key: &str, + store_id: &str, +) -> Result> { + // Any failure to enumerate methods → undetermined → caller fails closed. + // Swallow the error here (uniform with the non-2xx wallet/address branch + // below) and log a body-free reason at warn; detail only at debug so an + // upstream error body never lands in normal logs on this sensitive path. + let methods = match list_payment_methods(base_url, api_key, store_id).await { + Ok(m) => m, + Err(e) => { + tracing::warn!( + store = %store_id, + "fetch_onchain_network: could not list payment methods; network undetermined" + ); + tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail"); + return Ok(None); + } + }; + // Find the on-chain BTC method. Lightning ids (`BTC-LN`, + // `BTC_LightningLike`, …) are deliberately excluded. + let Some(pmid) = methods.iter().find_map(|m| { + let id = m.get("paymentMethodId").and_then(|v| v.as_str())?; + match id.to_ascii_uppercase().as_str() { + "BTC-CHAIN" | "BTC" => Some(id.to_string()), + _ => None, + } + }) else { + return Ok(None); // no on-chain BTC method → undetermined → fail closed + }; + + // `pmid` is BTCPay-supplied; percent-encode it as a path segment so a + // hostile/buggy server returning an odd id can't corrupt the URL (it would + // only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed). + let url = format!( + "{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address", + base_url.trim_end_matches('/'), + urlencoding::encode(&pmid), + ); + let resp = Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build()? + .get(&url) + .header("Authorization", format!("token {api_key}")) + .send() + .await + .context("calling BTCPay wallet/address")?; + if !resp.status().is_success() { + // 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet), + // 403 (insufficient perms) — none let us positively determine the + // network, so report undetermined and let the caller fail closed. + return Ok(None); + } + // A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't + // determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any + // body snippet reqwest attaches to a parse error out of warn-level logs. + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined"); + return Ok(None); + } + }; + let address = body.get("address").and_then(|v| v.as_str()).unwrap_or(""); + Ok(super::network::classify_address_network(address)) +} diff --git a/licensing-service/src/btcpay/config.rs b/licensing-service/src/btcpay/config.rs index a0e4881..e85e2a9 100644 --- a/licensing-service/src/btcpay/config.rs +++ b/licensing-service/src/btcpay/config.rs @@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> { Ok(()) } +/// An in-flight authorize round-trip, recovered at callback time. `Default` +/// (no profile, `scoped_initiator = false`) is the back-compat reading of a +/// pre-0025 / NULL row: "master connect to the default profile" — the only +/// kind that existed before scoped connect. +#[derive(Debug, Clone, Default)] +pub struct AuthorizeState { + /// Merchant profile the resulting provider row attaches to (migration + /// 0022). None → "the default profile". + pub merchant_profile_id: Option, + /// True when a *scoped* key (not the master key) started the connect + /// (migration 0025). The callback applies the non-mainnet network gate + /// only for scoped initiators. + pub scoped_initiator: bool, + /// sha256 of the initiating credential — for the callback's audit row. + pub initiator_actor_hash: Option, +} + /// Record a new in-flight authorize state token. `merchant_profile_id` /// (multi-provider model, migration 0022) names which merchant profile /// the resulting provider row should attach to when the callback fires /// — None falls back to "the default profile" at consume-time. +/// `scoped_initiator` / `actor_hash` (migration 0025) carry who started the +/// connect so the callback can apply the network gate + attribute the audit. pub async fn record_authorize_state( pool: &SqlitePool, token: &str, merchant_profile_id: Option<&str>, + scoped_initiator: bool, + actor_hash: Option<&str>, ) -> Result<()> { let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \ - VALUES (?, ?, ?)", + "INSERT INTO btcpay_authorize_state \ + (state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \ + VALUES (?, ?, ?, ?, ?)", ) .bind(token) .bind(merchant_profile_id) .bind(&now) + .bind(scoped_initiator as i64) + .bind(actor_hash) .execute(pool) .await .context("recording btcpay authorize state")?; @@ -109,17 +133,18 @@ pub async fn record_authorize_state( } /// Validate that `token` was issued recently and has not been consumed. -/// Consumes (deletes) the token on success so a replay fails, and -/// returns the `merchant_profile_id` recorded at start-connect time so -/// the callback knows which profile to attach the new provider to. +/// Consumes (deletes) the token on success so a replay fails, and returns the +/// recorded `AuthorizeState` (profile + initiator) so the callback knows which +/// profile to attach to and whether to apply the scoped network gate. pub async fn consume_authorize_state( pool: &SqlitePool, token: &str, -) -> Result> { +) -> Result { use sqlx::Row; let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339(); let row = sqlx::query( - "SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \ + "SELECT merchant_profile_id, scoped_initiator, initiator_actor_hash \ + FROM btcpay_authorize_state \ WHERE state_token = ? AND created_at >= ?", ) .bind(token) @@ -130,11 +155,19 @@ pub async fn consume_authorize_state( let Some(row) = row else { return Err(anyhow!("unknown or expired authorize state token")); }; - let merchant_profile_id: Option = row.try_get("merchant_profile_id").ok().flatten(); + let state = AuthorizeState { + merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(), + // Tolerant read: a NULL/absent column reads as 0 (master) — fail toward + // the *less*-restrictive master path is acceptable here because the + // column only exists to ADD the scoped restriction; a pre-0025 token + // could only ever have been a master connect. + scoped_initiator: row.try_get::("scoped_initiator").unwrap_or(0) != 0, + initiator_actor_hash: row.try_get("initiator_actor_hash").ok().flatten(), + }; sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?") .bind(token) .execute(pool) .await?; - Ok(merchant_profile_id) + Ok(state) } diff --git a/licensing-service/src/btcpay/mod.rs b/licensing-service/src/btcpay/mod.rs index b8c8c96..4f28b6f 100644 --- a/licensing-service/src/btcpay/mod.rs +++ b/licensing-service/src/btcpay/mod.rs @@ -8,4 +8,5 @@ pub mod client; pub mod config; +pub mod network; pub mod webhook; diff --git a/licensing-service/src/btcpay/network.rs b/licensing-service/src/btcpay/network.rs new file mode 100644 index 0000000..b6bbf6d --- /dev/null +++ b/licensing-service/src/btcpay/network.rs @@ -0,0 +1,160 @@ +//! Bitcoin network classification from an address string. +//! +//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md` +//! §6.1): a *scoped* key may connect a BTCPay store only when its target network +//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type +//! field, so we determine the network from a **network-encoding artifact** — the +//! store's on-chain receive address — and classify by its prefix. +//! +//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a +//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`). +//! +//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller +//! MUST treat `None` as mainnet (deny the scoped connect). Never assume +//! non-mainnet from absence of evidence. + +/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest +/// distinction gates the scoped connect, but the specific non-mainnet variant +/// is kept for audit/logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BitcoinNetwork { + Mainnet, + /// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet. + Testnet, + /// Signet — indistinguishable from testnet by address alone (`tb1`), so the + /// address classifier never yields this; reserved for a future + /// derivation-scheme-based path. Kept distinct because it is a real, + /// non-mainnet network the gate must allow. + Signet, + Regtest, +} + +impl BitcoinNetwork { + pub fn as_str(self) -> &'static str { + match self { + BitcoinNetwork::Mainnet => "mainnet", + BitcoinNetwork::Testnet => "testnet", + BitcoinNetwork::Signet => "signet", + BitcoinNetwork::Regtest => "regtest", + } + } + + /// The only question the connect gate actually asks. + pub fn is_mainnet(self) -> bool { + matches!(self, BitcoinNetwork::Mainnet) + } +} + +/// Classify a Bitcoin address by its network-encoding prefix. Returns `None` +/// when the prefix is unrecognized or the string is empty — the caller +/// **fails closed** (treats `None` as mainnet). +/// +/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet. +/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58 +/// test versions are shared by testnet, signet, and regtest — all non-mainnet, +/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest +/// specifically). +pub fn classify_address_network(addr: &str) -> Option { + let s = addr.trim(); + if s.is_empty() { + return None; + } + // bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1` + // (it is not a prefix of the others, but order makes the intent explicit). + let lower = s.to_ascii_lowercase(); + if lower.starts_with("bcrt1") { + return Some(BitcoinNetwork::Regtest); + } + if lower.starts_with("tb1") { + // testnet and signet share the `tb` HRP and are indistinguishable from + // the address alone. Both non-mainnet; report Testnet. + return Some(BitcoinNetwork::Testnet); + } + if lower.starts_with("bc1") { + return Some(BitcoinNetwork::Mainnet); + } + // Legacy base58check — version byte encoded in the leading character. + // Only classify when the whole string is a *plausible* base58 address + // (correct alphabet + length): otherwise arbitrary text that merely begins + // with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet + // and the gate would fail OPEN. Junk falls through to `None` (fail closed). + // Case-sensitive, so classify off the original string. + if (26..=35).contains(&s.len()) && s.chars().all(is_base58) { + return match s.chars().next() { + Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet), + Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet), + _ => None, + }; + } + None +} + +/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`). +fn is_base58(c: char) -> bool { + matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bech32_prefixes() { + // The exact address the live regtest BTCPay 2.x returned. + assert_eq!( + classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"), + Some(BitcoinNetwork::Regtest) + ); + assert_eq!( + classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"), + Some(BitcoinNetwork::Testnet) + ); + assert_eq!( + classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + Some(BitcoinNetwork::Mainnet) + ); + } + + #[test] + fn bech32_is_case_insensitive() { + assert_eq!( + classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"), + Some(BitcoinNetwork::Regtest) + ); + assert_eq!( + classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"), + Some(BitcoinNetwork::Mainnet) + ); + } + + #[test] + fn legacy_base58() { + assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH + assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH + assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH + assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet)); + assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH + } + + #[test] + fn fail_closed_on_unknown_or_empty() { + assert_eq!(classify_address_network(""), None); + assert_eq!(classify_address_network(" "), None); + assert_eq!(classify_address_network("not-an-address"), None); + assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin + assert_eq!(classify_address_network("zzz"), None); + // The dangerous direction: a base58-length, all-base58 string that does + // NOT begin with a version char (1/3/m/n/2) must stay None, never be + // mis-read as non-mainnet. (And a real mainnet address always begins + // with 1/3/bc1, so it can never fall into the non-mainnet arms.) + assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b' + } + + #[test] + fn is_mainnet_only_true_for_mainnet() { + assert!(BitcoinNetwork::Mainnet.is_mainnet()); + assert!(!BitcoinNetwork::Testnet.is_mainnet()); + assert!(!BitcoinNetwork::Signet.is_mainnet()); + assert!(!BitcoinNetwork::Regtest.is_mainnet()); + } +} diff --git a/licensing-service/src/error.rs b/licensing-service/src/error.rs index 6d42e5a..99903cd 100644 --- a/licensing-service/src/error.rs +++ b/licensing-service/src/error.rs @@ -59,23 +59,46 @@ pub enum AppError { Internal(#[from] anyhow::Error), } +impl AppError { + /// HTTP status this error maps to. Exposed so handlers that render a + /// non-JSON body (e.g. the BTCPay callback's HTML page) still return the + /// correct status instead of a misleading 200 on a denied request. + pub fn status_code(&self) -> StatusCode { + match self { + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Unauthorized => StatusCode::UNAUTHORIZED, + AppError::Forbidden => StatusCode::FORBIDDEN, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::LicenseInvalid(_) => StatusCode::OK, + AppError::Upstream(_) => StatusCode::BAD_GATEWAY, + AppError::BtcpayNotConfigured => StatusCode::SERVICE_UNAVAILABLE, + AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS, + AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + AppError::PaymentRequired { .. } => StatusCode::PAYMENT_REQUIRED, + AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, code) = match &self { - AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), - AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), - AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), - AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), - AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), - AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"), - AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"), - AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"), - AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), - AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"), - AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"), + let status = self.status_code(); + let code = match &self { + AppError::NotFound(_) => "not_found", + AppError::BadRequest(_) => "bad_request", + AppError::Unauthorized => "unauthorized", + AppError::Forbidden => "forbidden", + AppError::Conflict(_) => "conflict", + AppError::LicenseInvalid(_) => "invalid", + AppError::Upstream(_) => "upstream_error", + AppError::BtcpayNotConfigured => "btcpay_not_configured", + AppError::TooManyRequests(_) => "rate_limited", + AppError::ServiceUnavailable(_) => "service_unavailable", + AppError::PaymentRequired { .. } => "tier_cap", AppError::Database(_) | AppError::Internal(_) => { tracing::error!(error = %self, "internal error"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") + "internal_error" } }; diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index b4a9afa..d2e9a93 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -86,6 +86,17 @@ async fn make_pool() -> (SqlitePool, NamedTempFile) { /// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5 /// products, 5 codes, etc.). Plenty for the small fixtures here. async fn make_test_state() -> (AppState, NamedTempFile) { + make_test_state_inner(false).await +} + +/// Same fixture but with the daemon sandbox flag ON — for the +/// agent-payment-connect outer gate (a scoped `payment_providers:write` key may +/// only start a connect on a sandbox daemon). +async fn make_test_state_sandbox() -> (AppState, NamedTempFile) { + make_test_state_inner(true).await +} + +async fn make_test_state_inner(sandbox_mode: bool) -> (AppState, NamedTempFile) { let (pool, tmp) = make_pool().await; let keypair = crypto::keys::load_or_generate(&pool) .await @@ -103,7 +114,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) { btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: Some("Test Operator".into()), - sandbox_mode: false, + sandbox_mode, }; let state = AppState { @@ -3607,6 +3618,187 @@ async fn scoped_key_create_rejects_ungrantable_scope() { assert_eq!(send(&state, req).await.status(), StatusCode::BAD_REQUEST); } +/// Mint a scoped key of `role` plus à-la-carte `scopes`, returning its token. +async fn mint_scoped_key_with_scopes(state: &AppState, role: &str, scopes: &[&str]) -> String { + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let req = build_request( + "POST", + "/v1/admin/api-keys", + &[("authorization", &auth)], + Some(json!({ "label": format!("{role}+scopes key"), "role": role, "scopes": scopes })), + ); + let resp = send(state, req).await; + assert_eq!(resp.status(), StatusCode::OK, "minting {role}+{scopes:?} should succeed"); + body_json(resp) + .await + .get("token") + .and_then(|t| t.as_str()) + .expect("create returns the raw token once") + .to_string() +} + +// ----- agent-payment-connect gate (slices 3-4) ----- +// `plans/agent-payment-connect-scope.md`: a scoped `payment_providers:write` +// key may START a BTCPay connect ONLY on a sandbox daemon (outer gate); the +// non-mainnet inner gate is enforced at callback time (covered live in +// tests/btcpay_network_live.rs). These cover the HTTP-level outer gate. + +/// OUTER gate, production: a scoped `payment_providers:write` key is 403 on a +/// non-sandbox daemon — even though it holds the scope. Proves §5.1 (a scoped +/// key cannot repoint settlement on a live box, regtest or otherwise). +#[tokio::test] +async fn payment_connect_outer_gate_denies_scoped_on_production() { + let (state, _tmp) = make_test_state().await; // sandbox_mode = false + let token = + mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await; + let auth = format!("Bearer {token}"); + let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None); + assert_eq!( + send(&state, req).await.status(), + StatusCode::FORBIDDEN, + "scoped payment_providers:write key must be 403 on a non-sandbox daemon" + ); +} + +/// OUTER gate, sandbox: the same key passes on a sandbox daemon, and the +/// connect is recorded as a SCOPED initiator so the callback applies the +/// non-mainnet network gate. +#[tokio::test] +async fn payment_connect_outer_gate_allows_scoped_on_sandbox() { + let (state, _tmp) = make_test_state_sandbox().await; // sandbox_mode = true + let token = + mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await; + let auth = format!("Bearer {token}"); + let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK, "scoped key passes the outer gate on sandbox"); + let body = body_json(resp).await; + assert!( + body["authorize_url"].as_str().unwrap_or("").contains("/api-keys/authorize"), + "returns a BTCPay authorize URL; got {body:?}" + ); + let (scoped, actor_hash): (i64, Option) = sqlx::query_as( + "SELECT scoped_initiator, initiator_actor_hash FROM btcpay_authorize_state WHERE state_token = ?", + ) + .bind(body["state"].as_str().expect("state token echoed")) + .fetch_one(&state.db) + .await + .expect("authorize_state row persisted"); + assert_eq!(scoped, 1, "the callback must see this as a scoped initiator"); + assert!( + actor_hash.is_some(), + "the scoped initiator's actor hash must be recorded for the callback's audit row" + ); +} + +/// A scoped key WITHOUT `payment_providers:write` is 403 even on a sandbox +/// daemon — the scope is in no role (not even full-admin), so merchant-onboard +/// can't reach connect. Proves the gate isn't widened by role. +#[tokio::test] +async fn payment_connect_denies_scoped_without_the_scope() { + let (state, _tmp) = make_test_state_sandbox().await; // sandbox ON, so only the missing scope can deny + let auth = format!("Bearer {}", mint_scoped_key(&state, "merchant-onboard").await); + let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None); + assert_eq!( + send(&state, req).await.status(), + StatusCode::FORBIDDEN, + "merchant-onboard without payment_providers:write must be 403 (no role widening)" + ); +} + +/// The master key may start a connect on ANY daemon (bypasses the sandbox +/// gate). Recorded as a master (non-scoped) initiator → callback applies no +/// network restriction. +#[tokio::test] +async fn payment_connect_allows_master_on_production() { + let (state, _tmp) = make_test_state().await; // sandbox OFF + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK, "master may connect on any daemon"); + let body = body_json(resp).await; + let scoped: i64 = sqlx::query_scalar( + "SELECT scoped_initiator FROM btcpay_authorize_state WHERE state_token = ?", + ) + .bind(body["state"].as_str().unwrap()) + .fetch_one(&state.db) + .await + .expect("authorize_state row persisted"); + assert_eq!(scoped, 0, "master connect is not a scoped initiator"); +} + +/// The initiator + actor hash round-trip through `btcpay_authorize_state` +/// (migration 0025): recorded at start, recovered at callback, single-use. +#[tokio::test] +async fn authorize_state_carries_scoped_initiator() { + let (state, _tmp) = make_test_state().await; + let profile = repo::get_default_merchant_profile(&state.db) + .await + .expect("query default profile") + .expect("a default profile exists post-migration"); + + keysat::btcpay::config::record_authorize_state( + &state.db, + "tok_scoped", + Some(&profile.id), + true, + Some("deadbeef"), + ) + .await + .expect("record scoped"); + let s = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped") + .await + .expect("consume scoped"); + assert!(s.scoped_initiator, "scoped_initiator must round-trip"); + assert_eq!(s.initiator_actor_hash.as_deref(), Some("deadbeef")); + assert_eq!(s.merchant_profile_id.as_deref(), Some(profile.id.as_str())); + // Single-use: a replay of the same token fails. + assert!( + keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped") + .await + .is_err(), + "consumed token must not replay" + ); + + // Master initiator: defaults (not scoped, no hash). + keysat::btcpay::config::record_authorize_state( + &state.db, + "tok_master", + Some(&profile.id), + false, + None, + ) + .await + .expect("record master"); + let m = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_master") + .await + .expect("consume master"); + assert!(!m.scoped_initiator); + assert_eq!(m.initiator_actor_hash, None); +} + +/// The GET BTCPay callback must surface a failed/denied connect as a non-2xx +/// status, not a 200 with an HTML error body (the POST callback already does via +/// `?`). An unknown state token fails closed at consume time -> 401. This guards +/// the regression where the deny path (e.g. a scoped key targeting a mainnet +/// store) would otherwise return 200 with no programmatic error signal. +#[tokio::test] +async fn btcpay_callback_get_propagates_error_status() { + let (state, _tmp) = make_test_state_sandbox().await; + let req = build_request( + "GET", + "/v1/btcpay/authorize/callback?state=bogus-token&apiKey=whatever", + &[], + None, + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "a GET callback with an invalid state token must return 401, not a 200 error page" + ); +} + /// Zaprite Connect refuses on Creator-tier (no `zaprite_payments` /// entitlement) with 402. Switching the daemon's self-tier to a /// Pro-flavored Licensed tier lets the Connect-precheck pass (it then diff --git a/licensing-service/tests/btcpay_network_live.rs b/licensing-service/tests/btcpay_network_live.rs new file mode 100644 index 0000000..398e859 --- /dev/null +++ b/licensing-service/tests/btcpay_network_live.rs @@ -0,0 +1,72 @@ +//! Live re-validation of the agent-payment-connect network detection against a +//! real BTCPay regtest box. Exercises the daemon's ACTUAL +//! `btcpay::client::fetch_onchain_network` (not a curl reimplementation), which +//! is what the scoped-connect gate calls at callback time. +//! +//! `#[ignore]` by default — it needs a running BTCPay regtest stack and reads +//! its connection params from the environment (no secrets in the tree). Bring +//! the box up and run: +//! +//! ```sh +//! cd ../onboarding-harness/stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d +//! # mint a canmodifystoresettings token + a store with an on-chain wallet, then: +//! source ../onboarding-harness/stage2/btcpay-regtest/.live-env +//! cargo test --test btcpay_network_live -- --ignored --nocapture +//! ``` +//! +//! Spec: `plans/agent-payment-connect-scope.md` §6.1 — "BTCPay on-chain address +//! network detection MUST be validated against a live regtest box." + +use keysat::btcpay::client::fetch_onchain_network; +use keysat::btcpay::network::BitcoinNetwork; + +fn env(key: &str) -> Option { + std::env::var(key).ok().filter(|s| !s.is_empty()) +} + +#[tokio::test] +#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"] +async fn regtest_store_resolves_to_regtest() { + let (Some(base), Some(key), Some(store)) = ( + env("KEYSAT_LIVE_BTCPAY_URL"), + env("KEYSAT_LIVE_BTCPAY_KEY"), + env("KEYSAT_LIVE_BTCPAY_STORE_REGTEST"), + ) else { + eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_REGTEST"); + return; + }; + + let net = fetch_onchain_network(&base, &key, &store) + .await + .expect("detection call should not transport-error against a live box"); + println!("regtest store {store} resolved to {net:?}"); + assert_eq!( + net, + Some(BitcoinNetwork::Regtest), + "the on-chain wallet's bcrt1 address must classify as Regtest (non-mainnet → scoped connect allowed)" + ); + assert!(!net.unwrap().is_mainnet(), "regtest must not be mainnet"); +} + +#[tokio::test] +#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"] +async fn store_without_onchain_wallet_is_undetermined() { + let (Some(base), Some(key), Some(store)) = ( + env("KEYSAT_LIVE_BTCPAY_URL"), + env("KEYSAT_LIVE_BTCPAY_KEY"), + env("KEYSAT_LIVE_BTCPAY_STORE_NOWALLET"), + ) else { + eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_NOWALLET"); + return; + }; + + let net = fetch_onchain_network(&base, &key, &store) + .await + .expect("detection call should not transport-error"); + println!("no-wallet store {store} resolved to {net:?}"); + // No on-chain wallet → undetermined → caller fails closed to mainnet → deny. + assert_eq!( + net, None, + "a store with no on-chain wallet must be undetermined so the gate fails closed" + ); +}