From 2d0077b2b95caf507ef2c55e1400ec37c1780dca Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 27 Jun 2026 15:35:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(capabilities):=20honest=20secret-presence?= =?UTF-8?q?=20downgrade=20+=20admin=20features=20view=20[operator=20=C2=A7?= =?UTF-8?q?4]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make /capabilities fail-closed and add the admin features surface. - Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner secret) now reports enabled:false — so /capabilities never advertises a chain that would 404, and a missing-secret downgrade is indistinguishable from a deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper. - contract_version stamp on the public document (clients soft-notice a higher value, ignore unknown additive keys). - GET /admin/features (admin_guard): the resolved effective state PLUS admin-only downgrade reasons (the public surface hides the reason, per §4.3). - Golden test: the public capabilities JSON, built from a fully-secret-seeded config, contains no secret value (asserts no "integration-test-" substring). openapi regenerated (contract_version). Admin features view stays off the public OpenAPI. Deferred (documented): runtime mutation — PUT /admin/features + a DB feature- override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a deferred LB decision) is a later addition. --- openapi.json | 7 +++ src/api/admin.rs | 88 ++++++++++++++++++++++++++++++++++++ src/api/capabilities.rs | 99 +++++++++++++++++++++++++++-------------- tests/capabilities.rs | 13 ++++++ 4 files changed, 174 insertions(+), 33 deletions(-) diff --git a/openapi.json b/openapi.json index a418322..1a434e9 100644 --- a/openapi.json +++ b/openapi.json @@ -1719,6 +1719,7 @@ "type": "object", "required": [ "version", + "contract_version", "chains", "features" ], @@ -1726,6 +1727,12 @@ "chains": { "$ref": "#/components/schemas/ChainCapabilities" }, + "contract_version": { + "type": "integer", + "format": "int32", + "description": "Capabilities contract version (additive changes do not bump it).", + "minimum": 0 + }, "features": { "$ref": "#/components/schemas/FeatureCapabilities" }, diff --git a/src/api/admin.rs b/src/api/admin.rs index 91a966d..586ad0d 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -651,6 +651,93 @@ pub async fn admin_keys_rotate( })) } +// ── features (read-only view) ──────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct Downgrade { + pub feature: String, + pub reason: String, +} + +#[derive(Debug, Serialize)] +pub struct AdminFeaturesResponse { + pub effective: crate::api::capabilities::CapabilitiesResponse, + /// Flags that are ON in config but serve as disabled (missing secret/URL). + /// Exposed only to the admin — the public capabilities hides the reason. + pub downgrades: Vec, +} + +/// Admin-only view of the resolved feature state + why anything is downgraded. +/// (Runtime mutation — PUT /admin/features + DB override — is deferred; flags are +/// env/config-driven for now.) +#[instrument(skip(state, headers))] +pub async fn admin_features( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + admin_guard(&state, &headers).await?; + let cfg = &state.config; + let effective = crate::api::capabilities::effective_capabilities(cfg); + + let mut downgrades = Vec::new(); + let mut note = |on: bool, eff: bool, feature: &str, reason: &str| { + if on && !eff { + downgrades.push(Downgrade { + feature: feature.into(), + reason: reason.into(), + }); + } + }; + let c = &cfg.features.chains; + note( + c.btc, + effective.chains.btc.enabled, + "btc", + "BTC_ELECTRUM_URL/fallbacks unset", + ); + note( + c.ltc, + effective.chains.ltc.enabled, + "ltc", + "LTC_ELECTRUM_URL/fallbacks unset", + ); + note( + c.xmr, + effective.chains.xmr.enabled, + "xmr", + "XMR_LWS_ADMIN_KEY unset", + ); + note( + c.wow, + effective.chains.wow.enabled, + "wow", + "WOW_LWS_ADMIN_KEY unset", + ); + note( + c.grin, + effective.chains.grin.enabled, + "grin", + "GRIN_OWNER_API_SECRET unset", + ); + note( + cfg.features.grin_relay, + effective.features.grin_relay, + "grin_relay", + "Grin chain not serviceable", + ); + note( + cfg.features.nostr_identity, + effective.features.nostr_identity, + "nostr_identity", + "PUBLIC_API_URL unset", + ); + + Ok(Json(AdminFeaturesResponse { + effective, + downgrades, + })) +} + // ── router ─────────────────────────────────────────────────────────────────── /// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket. @@ -661,6 +748,7 @@ pub fn routes() -> Router> { .route("/admin/auth/refresh", post(admin_refresh)) .route("/admin/auth/logout", post(admin_logout)) .route("/admin/me", get(admin_me)) + .route("/admin/features", get(admin_features)) .route("/admin/keys", post(admin_keys_add).get(admin_keys_list)) .route("/admin/keys/:id", delete(admin_keys_revoke)) .route("/admin/keys/:id/rotate", post(admin_keys_rotate)) diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 8fcd1f7..5be9a28 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -12,8 +12,13 @@ use axum::{extract::State, routing::get, Json, Router}; use serde::Serialize; use tracing::instrument; +use crate::config::Config; use crate::AppState; +/// Capabilities contract version. Bumped only on a breaking shape change; clients +/// soft-notice a higher value and ignore unknown (additive) keys. +pub const CAPABILITIES_CONTRACT_VERSION: u32 = 1; + /// Per-chain availability. `network` is the configured network for UTXO chains /// (so the wallet derives addresses for the right one); `null` for chains whose /// network isn't a backend setting. @@ -48,10 +53,70 @@ pub struct FeatureCapabilities { pub struct CapabilitiesResponse { /// Backend version (Cargo package version). pub version: String, + /// Capabilities contract version (additive changes do not bump it). + pub contract_version: u32, pub chains: ChainCapabilities, pub features: FeatureCapabilities, } +/// Whether an enabled chain can actually be served (its infra secret/URL is +/// present). A chain whose flag is on but whose source is unconfigured reports +/// `enabled:false` — so `/capabilities` never advertises a chain that 404s, and +/// a missing-secret downgrade is indistinguishable from a deliberately-off chain. +fn chain_serviceable(config: &Config, asset: &str) -> bool { + let f = &config.features.chains; + let c = &config.chains; + match asset { + "btc" => { + f.btc && (c.btc.electrum_primary.is_some() || !c.btc.electrum_fallbacks.is_empty()) + } + "ltc" => { + f.ltc && (c.ltc.electrum_primary.is_some() || !c.ltc.electrum_fallbacks.is_empty()) + } + "xmr" => f.xmr && !c.xmr.lws_admin_key.is_empty(), + "wow" => f.wow && !c.wow.lws_admin_key.is_empty(), + "grin" => f.grin && !c.grin.owner_api_secret.is_empty(), + _ => false, + } +} + +/// Build the public capabilities projection with the secret-presence downgrade. +pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { + let utxo_net = |on: bool, net: &str| ChainCapability { + enabled: on, + network: Some(net.to_string()), + }; + CapabilitiesResponse { + version: env!("CARGO_PKG_VERSION").to_string(), + contract_version: CAPABILITIES_CONTRACT_VERSION, + chains: ChainCapabilities { + btc: utxo_net(chain_serviceable(config, "btc"), &config.chains.btc.network), + ltc: utxo_net(chain_serviceable(config, "ltc"), &config.chains.ltc.network), + xmr: ChainCapability { + enabled: chain_serviceable(config, "xmr"), + network: None, + }, + wow: ChainCapability { + enabled: chain_serviceable(config, "wow"), + network: None, + }, + grin: ChainCapability { + enabled: chain_serviceable(config, "grin"), + network: None, + }, + }, + features: FeatureCapabilities { + // The relay needs Grin chain access to be serviceable. + grin_relay: config.features.grin_relay && chain_serviceable(config, "grin"), + prices: config.features.prices, + // Nostr identity needs the canonical PUBLIC_API_URL. + nostr_identity: config.features.nostr_identity + && config.identity.public_api_url.is_some(), + tips: config.features.tips, + }, + } +} + /// Describe this instance's enabled chains and features. #[utoipa::path( get, @@ -61,39 +126,7 @@ pub struct CapabilitiesResponse { )] #[instrument(skip(state))] pub async fn capabilities(State(state): State>) -> Json { - let cfg = &state.config; - let chains = &cfg.features.chains; - Json(CapabilitiesResponse { - version: env!("CARGO_PKG_VERSION").to_string(), - chains: ChainCapabilities { - btc: ChainCapability { - enabled: chains.btc, - network: Some(cfg.chains.btc.network.clone()), - }, - ltc: ChainCapability { - enabled: chains.ltc, - network: Some(cfg.chains.ltc.network.clone()), - }, - xmr: ChainCapability { - enabled: chains.xmr, - network: None, - }, - wow: ChainCapability { - enabled: chains.wow, - network: None, - }, - grin: ChainCapability { - enabled: chains.grin, - network: None, - }, - }, - features: FeatureCapabilities { - grin_relay: cfg.features.grin_relay, - prices: cfg.features.prices, - nostr_identity: cfg.features.nostr_identity, - tips: cfg.features.tips, - }, - }) + Json(effective_capabilities(&state.config)) } /// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth). diff --git a/tests/capabilities.rs b/tests/capabilities.rs index 921b863..3775b74 100644 --- a/tests/capabilities.rs +++ b/tests/capabilities.rs @@ -12,6 +12,19 @@ async fn capabilities_is_public_and_well_shaped() { assert_eq!(status, StatusCode::OK); assert!(body["version"].as_str().is_some(), "version present"); + assert!( + body["contract_version"].as_u64().is_some(), + "contract_version present" + ); + + // Golden: no server secret may appear in the public capabilities JSON. The + // harness seeds every secret with the "integration-test-" prefix. + let raw = serde_json::to_string(&body).unwrap(); + assert!( + !raw.contains("integration-test"), + "no secret leaks into public capabilities: {raw}" + ); + // Every chain reports an `enabled` boolean. for chain in ["btc", "ltc", "xmr", "wow", "grin"] { assert!(