diff --git a/AGENTS.md b/AGENTS.md index 0890184..aa76a1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ with the pop in `Authorization: Payment …`. 1. **Use pops** — hold/lock BTC, mint pops, and pay HTTP-402-gated resources. Drive the `pop` CLI wallet from its machine contract: **[skills/pop-wallet.md](skills/pop-wallet.md)** (exact per-command JSON, the - frozen 31-code error table, and the safety rails for locking real BTC). + frozen 33-code error table, and the safety rails for locking real BTC). `pop pay --token ` runs the 402 dance for you. 2. **Accept pops** — gate your own HTTP service so it charges a pop per request. @@ -41,7 +41,7 @@ build with no special access (`cdk-common` is a normal crates.io `0.16` dep). Th `pop` CLI is a Cargo workspace member: `cargo build -p pop` / `cargo test -p pop`. The toolchain is pinned in `rust-toolchain.toml` (Rust 1.95). The other crates: `pops-core-verify` (the verifier), `pops-gateway` (the reverse-proxy), -`pops-core-funder` (the in-repo funder crypto kernel, extracted from `cdk-pop`) / -`pops-core-types` (support), plus `ts/` (WASM bindings + a Next.js serverless -demo). The gateway image and the WASM bindings are also published (ghcr + -`wasm-pkg` branch) if you want to consume rather than build. +and `pops-core-funder` (the in-repo funder crypto kernel, extracted from +`cdk-pop`), plus `ts/` (WASM bindings + a Next.js serverless demo). The gateway +image and the WASM bindings are also published (ghcr + `wasm-pkg` branch) if +you want to consume rather than build. diff --git a/Cargo.lock b/Cargo.lock index 916e6ba..71a5677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,6 +763,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1091,6 +1092,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1757,6 +1767,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" name = "pop" version = "0.1.0" dependencies = [ + "base64 0.22.1", "bip39", "bitcoin", "cdk-common", @@ -1798,6 +1809,7 @@ dependencies = [ "cdk", "chrono", "getrandom 0.2.17", + "hmac", "http", "js-sys", "serde", @@ -1807,6 +1819,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tower", + "tracing", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -1819,6 +1832,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "base64 0.22.1", "cashu", "futures-util", "http", diff --git a/README.md b/README.md index f871cf3..4810d56 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > **Agents → [AGENTS.md](AGENTS.md).** That's the entry point: it maps the two > pathways — **use pops** (drive the `pop` wallet from its machine contract, > **[skills/pop-wallet.md](skills/pop-wallet.md)** — exact per-command JSON, the -> frozen 31-code error table, and the safety rails for locking real BTC) and +> frozen 33-code error table, and the safety rails for locking real BTC) and > **gate your own service** (**[skills/gate-a-service.md](skills/gate-a-service.md)**) > — plus the shared `Payment` wire format. @@ -15,8 +15,11 @@ token — and presents it to reach a gated resource. No upfront payment and no account: spam-resistance comes from the **locked capital** ("power"), which can't be cheaply farmed at scale. It's HTTP-402-native (a gated resource answers with `402` + a `WWW-Authenticate: Payment` challenge; the client retries with -the pop in `Authorization: Payment …`). The verifier is **ecash-agnostic** — -pops are one supported credential type. +the pop in `Authorization: Payment …`), conforming to `draft-cashu-charge-01`: +challenges are stateless-bound (HMAC `id`) and expire, value at or above the +charge is accepted (excess retained), and a mint-trust DLEQ incident is +flagged to the operator rather than failing a settled payment. The verifier is +**ecash-agnostic** — pops are one supported credential type. **Non-custodial:** there is no third party. A spent pop is bearer ecash that the operator redeems into fresh proofs **only they control** — the operator keeps the diff --git a/crates/pop/Cargo.toml b/crates/pop/Cargo.toml index 4a8f04f..8fc2f69 100644 --- a/crates/pop/Cargo.toml +++ b/crates/pop/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" pops-core-funder = { path = "../pops-core-funder" } # The cashu-free `Payment` auth-scheme codec (HTTP-402 client primitives: -# parse_payment_params / decode_request_envelope / encode_payment_credentials + +# parse_payment_params / decode_charge_request / encode_payment_credentials + # their structs). `default-features = false` drops the crate's `native` surface # (cdk/axum/http/uuid) — `pay` only needs the envelope codec. See `crate::http402` # for the thin re-export the pay command uses. @@ -60,6 +60,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } [dev-dependencies] tempfile = "3" +# Hand-builds legacy/raw `request` auth-param blobs in the pay-command tests. +base64 = "0.22" # Recovery-path integration tests (`tests/pay_recovery.rs`) stand up an # in-process TCP stub that plays both the pops-gateway (the 402 + retry) and the # mint (`/v1/keysets`, `/v1/keys`, `/v1/swap`) so the post-swap value-recovery diff --git a/crates/pop/README.md b/crates/pop/README.md index 3bfe496..7e7e1a2 100644 --- a/crates/pop/README.md +++ b/crates/pop/README.md @@ -5,8 +5,11 @@ a CLTV-locked Bitcoin UTXO. This wallet is the funder's single tool to: - **`init`** — create a seed (the only secret), +- **`quote`** — create + verify a funding quote, persist it, and exit (no + funding poll; resume later with `mint --resume`), - **`mint`** — lock BTC in a CLTV-backed P2TR, wait for funding, and get a `cashuB` credential token, +- **`pay`** — spend a held pop at an HTTP-402 gated endpoint, - **`recover`** — reclaim the locked BTC after the timelock matures, - **`list` / `status`** — track deposits, and - **`balance`** — an aggregated ledger summary (total locked, per-state @@ -45,7 +48,8 @@ cargo build --release # from the repo root, or: cargo build -p pop ``` The binary is `target/release/pop`. Install with -`cargo install --path .` (installs a single `pop` binary). +`cargo install --path crates/pop` from the repo root (the root `Cargo.toml` is +a virtual workspace manifest, so `--path .` does not install). ## Wallet directory @@ -89,13 +93,28 @@ pubkey) and the on-chain recovery key (x-only pubkey). ### init ``` -pop init [--words 12|24] [--network mainnet|testnet|signet|regtest] [--esplora-url URL] [--force] +pop init [--words 12|24] [--mnemonic ""] [--show-mnemonic] + [--network mainnet|testnet|signet|regtest] [--esplora-url URL] [--force] ``` -Generates a BIP-39 mnemonic, writes the derived seed in plaintext (file `seed`, -perms `0600`), writes `config.toml` and an empty db, and prints the mnemonic -**once**. Write it down — it is the only secret and the only backup. There is no -passphrase. Default network is **mainnet**. +Generates a BIP-39 mnemonic (or imports an existing one via `--mnemonic`), +writes the derived seed in plaintext (file `seed`, perms `0600`), writes +`config.toml` and an empty db, and prints the mnemonic **once** — to stderr by +default; `--show-mnemonic` additionally includes it in the stdout JSON. Write +it down — it is the only secret and the only backup. There is no passphrase. +Default network is **mainnet**. + +### quote + +``` +pop quote --mint-url URL --amount SATS --mint-pubkey HEX33 + [--duration 30d | --unit pop_] [--label TEXT] +``` + +The non-blocking first half of `mint`: creates the funding quote, +**independently re-verifies** the returned address, persists the deposit + +recovery file, prints the funding address, and **exits** without polling. Fund +it on your own schedule, then continue with `pop mint --resume `. ### mint @@ -142,12 +161,15 @@ machine contract). ### recover ``` -pop recover (--deposit ID | --all) --dest ADDRESS [--fee 200] [--no-broadcast] +pop recover (--deposit ID | --all) --dest ADDRESS + [--fee SATS | --target BLOCKS] [--no-broadcast] ``` -Refuses (with an ETA) any deposit whose CLTV has not matured — maturity is -evaluated against the chain tip's **median-time-past** (BIP-113), not -wall-clock. For matured deposits it rebuilds the construction from stored +The fee is sized from the esplora mempool feerate estimate at `--target` +confirmation blocks (default 6); an explicit `--fee ` (absolute) +overrides the estimate. Refuses (with an ETA) any deposit whose CLTV has not +matured — maturity is evaluated against the chain tip's **median-time-past** +(BIP-113), not wall-clock. For matured deposits it rebuilds the construction from stored params, derives the funder privkey, fetches the funding UTXO, asserts the on-chain scriptPubKey matches, builds the `nLockTime = ts_expiry` script-path spend, schnorr-signs, and broadcasts. `--no-broadcast` prints the raw tx hex diff --git a/crates/pop/src/commands/pay.rs b/crates/pop/src/commands/pay.rs index e42127d..0f419ad 100644 --- a/crates/pop/src/commands/pay.rs +++ b/crates/pop/src/commands/pay.rs @@ -153,7 +153,14 @@ pub async fn run( reason: format!("WWW-Authenticate Payment params did not parse: {e}"), })?; - // ---- 3b/3c. Unwrap the request envelope → creqA → the concrete charge. ---- + // A credential MUST NOT be submitted against an expired challenge + // (framework `expires`), so check freshness FIRST — before the charge is + // even decoded, and long before any token is read or swapped. + if let Some(e) = expired_challenge_error(¶ms, &args.url) { + return Err(e.into()); + } + + // ---- 3b/3c. Decode the request object → creqA → the concrete charge. ---- let charge = decode_charge_from_params(¶ms)?; eprintln!( "Charge: {} sat of {} (mints: {})", @@ -302,8 +309,11 @@ async fn finish_payment( )?; Ok(()) } else { - // Gateway rejected (did NOT redeem) → send set AND any change are unspent - // ecash; surface BOTH. + // Non-2xx after the token was sent. A 4xx is a determinate rejection + // (did NOT redeem → both tokens unspent); a 5xx can follow a + // SUCCESSFUL swap (persist/upstream failure after settlement), so the + // send token's state is unknown. The error's message branches on the + // status; both tokens are surfaced either way. Err(PopError::GatewayRejectedPayment { status: retry_status.as_u16(), body: retry_body, @@ -466,9 +476,11 @@ async fn build_exact_payment( } /// Decodes the concrete [`Charge`] from parsed 402 params: read the -/// `draft-cashu-charge-01` request object (`amount`/`currency`/`methodDetails`), -/// enforcing its mints-superset over the inner creqA. A 0-sat charge is rejected -/// before any spend (an exact 0-sat charge is meaningless). +/// `draft-cashu-charge-01` request object, deriving amount/unit/mints from the +/// authoritative `methodDetails.paymentRequest` (the shared codec rejects a +/// creqA missing `a`/`u`/`m` or disagreeing with the top-level +/// `amount`/`currency`). A 0-sat charge is rejected before any spend (an exact +/// 0-sat charge is meaningless). fn decode_charge_from_params(params: &PaymentParams) -> Result> { let decoded = decode_charge_request(¶ms.request).map_err(|e| PopError::ChallengeParseFailed { @@ -489,6 +501,24 @@ fn decode_charge_from_params(params: &PaymentParams) -> Result Option { + let expires = params.expires.as_deref()?; + let is_past = match chrono::DateTime::parse_from_rfc3339(expires) { + Ok(ts) => ts.with_timezone(&chrono::Utc) <= chrono::Utc::now(), + Err(_) => true, + }; + is_past.then(|| PopError::ChallengeExpired { + url: url.to_string(), + expires: expires.to_string(), + }) +} + /// Validates the held token against the charge BEFORE any spend: matching unit, /// an accepted mint, and enough value. Any mismatch → a structured error and /// (at the call site) SEND NOTHING. @@ -584,11 +614,14 @@ fn build_premint( .map_err(|e| format!("failed to build premint secrets for {amount} sat: {e}").into()) } -/// Builds the `Authorization: Payment` credentials: a VERBATIM echo of the -/// parsed challenge params, plus the exact-amount token as the cashu payload. The -/// 402 carries no `digest`/`opaque`/`expires` (binding is server-deferred), so -/// those echo fields and the optional `source` are `None`. -pub fn build_credentials(params: &PaymentParams, cashu_token: &str) -> PaymentCredentials { +/// Builds the `Authorization: Payment` credentials: a VERBATIM echo of EVERY +/// parsed challenge param — required and optional alike — plus the +/// exact-amount token as the cashu payload. The server's stateless binding +/// recomputes its id-HMAC over the echo, so a dropped or altered param +/// (`expires` included) makes the credential `invalid-challenge`; an optional +/// the 402 did not carry stays absent. `source` is `None` (bearer tokens carry +/// no payer identity). +pub fn build_credentials(params: &PaymentParams, token: &str) -> PaymentCredentials { PaymentCredentials { challenge: EchoedChallenge { id: params.id.clone(), @@ -596,12 +629,13 @@ pub fn build_credentials(params: &PaymentParams, cashu_token: &str) -> PaymentCr method: params.method.clone(), intent: params.intent.clone(), request: params.request.clone(), - digest: None, - opaque: None, - expires: None, + digest: params.digest.clone(), + opaque: params.opaque.clone(), + expires: params.expires.clone(), + description: params.description.clone(), }, payload: CashuPayload { - cashu_token: cashu_token.to_string(), + token: token.to_string(), }, source: None, } @@ -841,6 +875,74 @@ mod tests { assert_eq!(d["need"], serde_json::json!(600)); } + // ---- the expired-challenge refusal (no credential against a past expires) - + + /// Params carrying the supplied `expires` (other fields don't matter to + /// the freshness check). + fn params_with_expires(expires: Option<&str>) -> PaymentParams { + PaymentParams { + id: "ch-1".into(), + realm: "pops".into(), + method: "cashu".into(), + intent: "charge".into(), + request: "cmVxdWVzdA".into(), + expires: expires.map(str::to_string), + digest: None, + opaque: None, + description: None, + } + } + + #[test] + fn expired_challenge_is_refused_before_any_spend() { + let err = expired_challenge_error( + ¶ms_with_expires(Some("2020-01-01T00:00:00Z")), + "https://app.example/r", + ) + .expect("a past expires must refuse"); + assert_eq!(err.code(), "challenge_expired"); + let d = err.details().expect("details required"); + assert_eq!(d["url"], serde_json::json!("https://app.example/r")); + assert_eq!(d["expires"], serde_json::json!("2020-01-01T00:00:00Z")); + assert!(!err.retriable(), "re-fetch the challenge, don't retry as-is"); + } + + #[test] + fn fresh_challenge_passes_the_expiry_check() { + assert!( + expired_challenge_error( + ¶ms_with_expires(Some("2999-01-01T00:00:00Z")), + "https://app.example/r", + ) + .is_none(), + "a future expires must proceed" + ); + } + + #[test] + fn challenge_without_expires_passes_the_expiry_check() { + assert!( + expired_challenge_error(¶ms_with_expires(None), "https://app.example/r") + .is_none(), + "no expires ⇒ no expiry signal to refuse on" + ); + } + + #[test] + fn unparseable_expires_is_refused_like_a_past_one() { + let err = expired_challenge_error( + ¶ms_with_expires(Some("not-a-timestamp")), + "https://app.example/r", + ) + .expect("freshness cannot be established ⇒ refuse"); + assert_eq!(err.code(), "challenge_expired"); + assert_eq!( + err.details().expect("details")["expires"], + serde_json::json!("not-a-timestamp"), + "the verbatim value is surfaced for diagnosis" + ); + } + // ---- request-object decode (the client's 402 parse surface) ---------- /// Build the parsed `PaymentParams` for a charge of `amount`, as the client @@ -854,7 +956,7 @@ mod tests { description: None, single_use: true, }; - let request = encode_charge_request(&req); + let request = encode_charge_request(&req).expect("requirement encodes"); let header = format!( r#"Payment id="ch-1", realm="pops", method="cashu", intent="charge", request="{request}""# ); @@ -869,6 +971,33 @@ mod tests { assert_eq!(c.mints, vec![mint_a()]); } + #[test] + fn decode_charge_from_params_rejects_legacy_request_shape() { + // The pre-spec wire carried `methodDetails.request` + `methodDetails.mints`; + // the client parses ONLY the `paymentRequest` shape. + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + let legacy = URL_SAFE_NO_PAD.encode( + br#"{"amount":"777","currency":"pop_1782668279","methodDetails":{"mints":["https://mint.example"],"request":"creqAx"}}"#, + ); + let params = PaymentParams { + id: "ch-1".into(), + realm: "pops".into(), + method: "cashu".into(), + intent: "charge".into(), + request: legacy, + expires: None, + digest: None, + opaque: None, + description: None, + }; + let err = decode_charge_from_params(¶ms).expect_err("legacy shape must not parse"); + assert_eq!( + crate::error::from_boxed(err).code(), + "challenge_parse_failed" + ); + } + #[test] fn decode_charge_from_params_rejects_zero_amount() { // A 0-sat exact charge is meaningless and must be rejected before any spend. @@ -892,7 +1021,7 @@ mod tests { description: None, single_use: true, }; - let request = encode_charge_request(&req); + let request = encode_charge_request(&req).expect("requirement encodes"); let header = format!( r#"Payment id="ch-42", realm="pops", method="cashu", intent="charge", request="{request}""# ); @@ -913,7 +1042,7 @@ mod tests { assert_eq!(creds.challenge.method, "cashu"); assert_eq!(creds.challenge.intent, "charge"); assert_eq!(creds.challenge.request, request, "request echoed verbatim"); - assert_eq!(creds.payload.cashu_token, "cashuBexampletoken"); + assert_eq!(creds.payload.token, "cashuBexampletoken"); // Parse the blob as the GATEWAY would (proves the wire round-trips // through the real verifier codec). @@ -921,6 +1050,41 @@ mod tests { let auth = format!("Payment {blob}"); let parsed = parse_payment_authorization(&auth).expect("gateway parses our credentials"); assert_eq!(parsed.challenge.id, "ch-42"); - assert_eq!(parsed.payload.cashu_token, "cashuBexampletoken"); + assert_eq!(parsed.payload.token, "cashuBexampletoken"); + } + + #[test] + fn build_credentials_echoes_every_issued_optional_param_verbatim() { + // A stateless-binding server recomputes its id-HMAC over the echo, so + // the client MUST return every issued param byte-for-byte — expires + // above all (a dropped expires makes the credential invalid-challenge). + let header = r#"Payment id="hmacid", realm="pops", method="cashu", intent="charge", request="cmVxdWVzdA", expires="2026-03-15T12:05:00Z", digest="sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", opaque="b3BhcXVl", description="weather report""#; + let params = parse_payment_params(header).expect("parses params"); + let creds = build_credentials(¶ms, "cashuBtok"); + assert_eq!( + creds.challenge.expires.as_deref(), + Some("2026-03-15T12:05:00Z") + ); + assert_eq!( + creds.challenge.digest.as_deref(), + Some("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:") + ); + assert_eq!(creds.challenge.opaque.as_deref(), Some("b3BhcXVl")); + assert_eq!( + creds.challenge.description.as_deref(), + Some("weather report") + ); + + // And an optional the 402 did NOT carry stays absent (an invented one + // would equally break the byte-exact echo). + let bare = r#"Payment id="hmacid", realm="pops", method="cashu", intent="charge", request="cmVxdWVzdA""#; + let creds = build_credentials( + &parse_payment_params(bare).expect("parses"), + "cashuBtok", + ); + assert_eq!(creds.challenge.expires, None); + assert_eq!(creds.challenge.digest, None); + assert_eq!(creds.challenge.opaque, None); + assert_eq!(creds.challenge.description, None); } } diff --git a/crates/pop/src/error.rs b/crates/pop/src/error.rs index edc813b..cdf712a 100644 --- a/crates/pop/src/error.rs +++ b/crates/pop/src/error.rs @@ -214,6 +214,18 @@ pub enum PopError { /// What failed to decode. reason: String, }, + /// The 402 challenge's `expires` is already in the past. The framework + /// forbids submitting a credential against an expired challenge, so + /// NOTHING was sent and no token was touched — re-request the resource + /// for a fresh challenge. (`challenge_expired`) + ChallengeExpired { + /// The URL whose challenge expired. + url: String, + /// The challenge's `expires` value verbatim (RFC 3339; an + /// unparseable value is refused the same way — freshness cannot be + /// established). + expires: String, + }, /// The held token's unit differs from the charge's; paying would be /// wrong-currency. Send nothing. (`token_unit_mismatch`) TokenUnitMismatch { @@ -263,18 +275,22 @@ pub enum PopError { /// What it actually summed to. got: u64, }, - /// The gateway rejected the presented payment on retry. It did NOT redeem, so - /// BOTH the send set (worth `amount`) AND any change are unspent ecash — - /// carried so no value is silently lost. (`gateway_rejected_payment`) + /// The gateway answered the payment retry with a non-2xx. On a 4xx + /// (verification rejections above all) the gateway determinately did NOT + /// redeem, so the send set AND any change are unspent ecash. On a 5xx the + /// error can FOLLOW a successful swap (e.g. the gateway's persist or + /// upstream failed after settlement), so the send token's redemption state + /// is UNKNOWN — hold it and verify before assuming either way. Both tokens + /// are carried so no value is silently lost. (`gateway_rejected_payment`) GatewayRejectedPayment { /// HTTP status the retry returned (typically 402). status: u16, /// The gateway's response body verbatim. body: String, - /// Worth EXACTLY the charge; unredeemed, so valid ecash that MUST be - /// recovered (the bigger half of the value). + /// Worth EXACTLY the charge — carry and recover it (on a 4xx it is + /// unspent; on a 5xx its state is unknown until verified). send_token: String, - /// Change token, if a swap produced one — also unspent. + /// Change token, if a swap produced one — unspent (it was never sent). change_token: Option, }, /// A freshly-minted proof set could not be encoded to its `cashuB` string. @@ -332,6 +348,7 @@ impl PopError { PopError::PaymentRejected { .. } => "payment_rejected", PopError::NoPaymentChallenge { .. } => "no_payment_challenge", PopError::ChallengeParseFailed { .. } => "challenge_parse_failed", + PopError::ChallengeExpired { .. } => "challenge_expired", PopError::TokenUnitMismatch { .. } => "token_unit_mismatch", PopError::TokenMintMismatch { .. } => "token_mint_mismatch", PopError::InsufficientTokenValue { .. } => "insufficient_token_value", @@ -508,6 +525,10 @@ impl PopError { PopError::ChallengeParseFailed { reason } => Some(json!({ "reason": reason, })), + PopError::ChallengeExpired { url, expires } => Some(json!({ + "url": url, + "expires": expires, + })), PopError::TokenUnitMismatch { required, got } => Some(json!({ "required": required, "got": got, @@ -695,6 +716,11 @@ impl PopError { PopError::ChallengeParseFailed { reason } => { format!("could not decode the 402 payment challenge into a charge: {reason}") } + PopError::ChallengeExpired { url, expires } => format!( + "the 402 challenge from {url} expired at {expires}; a credential must not be \ + submitted against an expired challenge, so nothing was sent (the held token is \ + untouched). Re-request the resource to get a fresh challenge, then pay that." + ), PopError::TokenUnitMismatch { required, got } => format!( "the held token's unit `{got}` does not match the charge's required unit `{required}`; \ not paying (wrong currency). Present a token in `{required}`." @@ -740,11 +766,25 @@ impl PopError { Some(_) => " plus a change token", None => "", }; - format!( - "the gateway rejected the payment (HTTP {status}): {body}. The gateway did \ - NOT redeem, so the send token{change_suffix} are unspent ecash — RECOVER them \ - (json: `details.send_token`/`details.change_token`; human mode prints them below)" - ) + if *status >= 500 { + // A 5xx can follow a successful swap (persist/upstream + // failure after settlement) — the redemption state is + // genuinely unknown. + format!( + "the server reported an error after the token was sent (HTTP {status}): \ + {body}. Redemption state UNKNOWN — do not assume the send token is \ + spendable, and do not assume it is spent; hold the send \ + token{change_suffix} and verify its state before reuse \ + (json: `details.send_token`/`details.change_token`; human mode prints \ + them below)" + ) + } else { + format!( + "the gateway rejected the payment (HTTP {status}): {body}. The gateway did \ + NOT redeem, so the send token{change_suffix} are unspent ecash — RECOVER them \ + (json: `details.send_token`/`details.change_token`; human mode prints them below)" + ) + } } PopError::GatewayRetryFailed { reason, @@ -1019,6 +1059,41 @@ mod tests { assert_eq!(e.recovery_tokens(), Some(("cashuBsend", None))); } + /// The post-send message is truthful per status class: a 4xx (verification + /// rejection) determinately did NOT redeem (tokens unspent); a 5xx can + /// follow a successful swap, so it must claim uncertainty — never + /// "unspent ecash". + #[test] + fn gateway_rejected_payment_message_is_truthful_per_status_class() { + let rejected_402 = PopError::GatewayRejectedPayment { + status: 402, + body: "verification failed".to_string(), + send_token: "cashuBsend".to_string(), + change_token: None, + }; + let msg = rejected_402.message(); + assert!(msg.contains("did NOT redeem"), "got: {msg}"); + assert!(msg.contains("unspent ecash"), "got: {msg}"); + + let failed_500 = PopError::GatewayRejectedPayment { + status: 500, + body: "failed to persist redeemed proofs".to_string(), + send_token: "cashuBsend".to_string(), + change_token: None, + }; + let msg = failed_500.message(); + assert!( + msg.contains("Redemption state UNKNOWN"), + "a 5xx must claim uncertainty, got: {msg}" + ); + assert!( + !msg.contains("unspent ecash") && !msg.contains("did NOT redeem"), + "a 5xx must not claim the token is unspent, got: {msg}" + ); + // The recovery surface is unchanged either way. + assert_eq!(failed_500.recovery_tokens(), Some(("cashuBsend", None))); + } + /// `gateway_retry_failed` is terminal (NOT retriable — the input proofs are /// already spent), carrying BOTH unspent tokens so a retry network error /// never loses the freshly-minted ecash. @@ -1074,6 +1149,28 @@ mod tests { assert!(e.recovery_tokens().is_none()); } + /// `challenge_expired` is terminal-for-this-challenge (re-fetch, don't + /// retry as-is) and carries the url + verbatim expires so the caller can + /// see what lapsed. Nothing was sent. + #[test] + fn challenge_expired_carries_url_and_expires() { + let e = PopError::ChallengeExpired { + url: "https://app.example/resource".into(), + expires: "2026-03-15T12:05:00Z".into(), + }; + let env = e.to_envelope(); + assert_eq!(env["error"]["code"], json!("challenge_expired")); + assert_eq!(env["error"]["retriable"], json!(false)); + assert_eq!( + env["error"]["details"]["url"], + json!("https://app.example/resource") + ); + assert_eq!( + env["error"]["details"]["expires"], + json!("2026-03-15T12:05:00Z") + ); + } + /// Message-only codes carry no `details` key. #[test] fn message_only_codes_have_no_details() { @@ -1085,6 +1182,41 @@ mod tests { assert!(e.details().is_none()); } + /// The agent-facing error table in skills/pop-wallet.md must carry one row + /// per contract code and advertise the actual count — the doc freezing a + /// stale count is exactly the drift this pins against. + #[test] + fn error_code_table_in_pop_wallet_skill_matches_the_contract() { + let doc_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../skills/pop-wallet.md"); + let doc = std::fs::read_to_string(&doc_path) + .unwrap_or_else(|e| panic!("read {doc_path:?}: {e}")); + + let samples = sample_errors(); + let codes: std::collections::BTreeSet<&str> = + samples.iter().map(|e| e.code()).collect(); + assert_eq!( + codes.len(), + samples.len(), + "sample_errors must carry exactly one entry per code" + ); + + for code in &codes { + assert!( + doc.contains(&format!("| `{code}` |")), + "skills/pop-wallet.md error table is missing a row for `{code}`" + ); + } + + let advertised = format!("the {} codes", codes.len()); + assert!( + doc.contains(&advertised), + "skills/pop-wallet.md must advertise the actual code count \ + ({}; expected the phrase {advertised:?})", + codes.len() + ); + } + /// A non-PopError boxed error resolves to internal_error. #[test] fn from_boxed_wraps_unknown_as_internal() { @@ -1197,6 +1329,10 @@ mod tests { PopError::ChallengeParseFailed { reason: "bad creqA".into(), }, + PopError::ChallengeExpired { + url: "https://x".into(), + expires: "2026-03-15T12:05:00Z".into(), + }, PopError::TokenUnitMismatch { required: "pop_2".into(), got: "pop_1".into(), diff --git a/crates/pop/src/http402.rs b/crates/pop/src/http402.rs index eb80db5..c416e3a 100644 --- a/crates/pop/src/http402.rs +++ b/crates/pop/src/http402.rs @@ -3,10 +3,11 @@ //! A thin re-export of the codec from [`pops_core_verify`], so the wallet and the //! verifier share ONE wire (`draft-cashu-charge-01`) and cannot drift: the //! cashu-free `Authorization` credentials envelope + the spec request object, -//! plus the cashu-coupled [`decode_charge_request`] that reads the request object -//! and enforces its mints-superset. Pulled with `default-features = false` to -//! keep `pops-core-verify`'s native surface (cdk/axum/http/uuid) out of the -//! wallet. +//! plus the cashu-coupled [`decode_charge_request`] that derives the charge from +//! the authoritative `methodDetails.paymentRequest` (rejecting a creqA missing +//! `a`/`u`/`m` or disagreeing with the top-level fields). Pulled with +//! `default-features = false` to keep `pops-core-verify`'s native surface +//! (cdk/axum/http/uuid) out of the wallet. //! //! If the two crates' `cashu` majors ever diverge, this is the sole module to //! repoint. diff --git a/crates/pop/tests/pay_recovery.rs b/crates/pop/tests/pay_recovery.rs index 289d855..4b03e60 100644 --- a/crates/pop/tests/pay_recovery.rs +++ b/crates/pop/tests/pay_recovery.rs @@ -187,7 +187,7 @@ fn challenge_header(mint_url: &str) -> String { description: None, single_use: true, }; - let request = encode_charge_request(&req); + let request = encode_charge_request(&req).expect("requirement encodes"); format!( r#"Payment id="ch-recovery", realm="pops", method="cashu", intent="charge", request="{request}""# ) diff --git a/crates/pops-core-verify/Cargo.toml b/crates/pops-core-verify/Cargo.toml index 295c049..06c778f 100644 --- a/crates/pops-core-verify/Cargo.toml +++ b/crates/pops-core-verify/Cargo.toml @@ -25,11 +25,22 @@ thiserror = "1" # Pure-Rust, wasm-clean — kept out of the cashu/secp dep so the envelope-only # wasm surface stays lean while token_hash is still computable in core. sha2 = "0.10" +# HMAC-SHA256 for the framework's stateless challenge binding (the `id` is +# base64url(HMAC(server_key, the 7 pipe-joined slots))). Pure-Rust, wasm-clean. +hmac = "0.12" +# OS randomness for the generate-at-boot binding-key fallback. Already in the +# tree transitively; the `wasm` feature flips on its `js` backend. +getrandom = "0.2" # JCS / RFC 8785 canonical JSON for the `request` object and the `Authorization` # credential blob (`draft-cashu-charge-01` §Encoding): lexicographic key sort + # ECMAScript number formatting, over serde_json. Pure-Rust + wasm-clean (ryu-js # for the number serialization), so the envelope codec stays wasm-targetable. serde_jcs = "0.1" +# Operator-facing diagnostics from the shared swap ceremony — the WARN on a +# missing/invalid swap-output DLEQ (`draft-cashu-charge-01` §security-dleq: +# alert + quarantine, never fail the payment). Core-only, wasm-clean; hosts +# bring their own subscriber. +tracing = { version = "0.1", default-features = false, features = ["std"] } # ── native feature deps (off on wasm) ─────────────────────────────────────── # cdk supplies wallet::{HttpClient, MintConnector} for the real MintClient. @@ -38,8 +49,14 @@ cdk = { version = "0.16", default-features = false, features = ["wallet"], optio axum = { version = "0.7", optional = true } # http types (StatusCode / HeaderValue) used directly by the middleware. http = { version = "1", optional = true } -# uuid v4 generates the per-request challenge `id`. +# Not referenced by this crate's code. Declared purely as a feature handle: +# the `wasm` feature must flip `uuid/js` on the uuid that cashu pulls in +# transitively, and a feature can only be forwarded through a declared dep +# (see the `[features]` note). uuid = { version = "1", features = ["v4"], optional = true } +# tokio::time::timeout bounds every mint HTTP call in `cdk_mint_client` (a hung +# mint must surface as the 503 mint-unavailable path, not hang the request). +tokio = { version = "1", features = ["time"], optional = true } # RFC 3339 parsing + the wall clock for the middleware's `challenge.expires` # enforcement and the `Payment-Receipt` timestamp. Native-only: the wasm surface # decodes but does not enforce expiry. @@ -54,15 +71,9 @@ js-sys = { version = "0.3", optional = true } # reflection on `globalThis` (Node has no `window`/Worker global), so only the # Response feature is needed here. web-sys = { version = "0.3", optional = true, features = ["Response"] } -# getrandom appears here ONLY so the wasm feature can flip on its `js` backend -# (the redeem path's blinding RNG needs it on wasm32; envelope-only does not, but -# selecting the backend at the crate root is harmless). Pinned to 0.2 to match -# the cashu/bitcoin transitive major. -getrandom = { version = "0.2", optional = true } - [features] default = ["native"] -native = ["dep:cdk", "dep:axum", "dep:http", "dep:uuid", "dep:chrono"] +native = ["dep:cdk", "dep:axum", "dep:http", "dep:uuid", "dep:chrono", "dep:tokio"] # `uuid/js` selects uuid's wasm randomness backend. uuid is pulled # TRANSITIVELY by cashu (not just our native dep), and uuid 1.23+ refuses to # compile on wasm32 without an explicit randomness feature; `getrandom/js` @@ -71,7 +82,8 @@ native = ["dep:cdk", "dep:axum", "dep:http", "dep:uuid", "dep:chrono"] wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:js-sys", "dep:web-sys", "getrandom/js", "uuid/js"] [dev-dependencies] -# async test runtime for the folded validator/middleware tests. -tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +# async test runtime for the folded validator/middleware tests; `net` serves +# the hung-mint listeners the mint-HTTP-timeout tests bind on 127.0.0.1. +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "net"] } # tower::ServiceExt::oneshot drives the axum router in the middleware tests. tower = "0.5" diff --git a/crates/pops-core-verify/src/binding.rs b/crates/pops-core-verify/src/binding.rs new file mode 100644 index 0000000..cb44e5c --- /dev/null +++ b/crates/pops-core-verify/src/binding.rs @@ -0,0 +1,560 @@ +//! Stateless challenge binding per the framework (`draft-httpauth-payment-00`, +//! Challenge Binding): the challenge `id` is +//! `base64url(HMAC-SHA256(server_key, realm|method|intent|request|expires|digest|opaque))` +//! over exactly SEVEN fixed positional slots, pipe-joined, with the empty +//! string standing in for an absent optional. Binding is stateless: the only +//! record of "what was issued" is the id itself, so verifying a credential = +//! recomputing the HMAC over the echoed auth-params and comparing — any +//! tampered, added, or dropped param changes the input and the recomputation +//! fails. +//! +//! `description` is deliberately NOT a slot (the framework excludes it from +//! the binding: display-only, MUST NOT be relied upon for verification), so a +//! stateless server cannot and does not authenticate an echoed `description`. +//! +//! The server key is a secret (spec Privacy §: MUST NOT be logged or shared — +//! [`BindingKey`]'s `Debug` is redacted). It comes from operator config, with +//! a generate-at-boot fallback: a restart then invalidates outstanding +//! challenges, which clients resolve by refetching the 402. +//! +//! `draft-cashu-charge-01` step 3 + Challenge Binding §: a server operating +//! statelessly MUST include `expires` on every challenge (nothing else ever +//! lapses it), so issuance here always emits it and verification requires it. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::envelope::EchoedChallenge; + +type HmacSha256 = Hmac; + +/// The server secret the challenge `id` is HMAC'd under. +/// +/// `Debug` is redacted: the key MUST NOT appear in logs, error messages, or +/// debugging output (framework Challenge-Binding Secret Management §). +#[derive(Clone)] +pub struct BindingKey(Vec); + +impl std::fmt::Debug for BindingKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "BindingKey(<{} bytes, redacted>)", self.0.len()) + } +} + +impl BindingKey { + /// Wrap raw key bytes (operator-supplied; 32 bytes RECOMMENDED). + pub fn from_bytes(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + /// Parse a hex-encoded key (the config wire form). Rejects an empty + /// string, odd length, or a non-hex digit; requires at least 16 bytes + /// (32 hex chars) so a typo'd key cannot quietly defeat the binding. + pub fn from_hex(s: &str) -> Result { + let s = s.trim(); + if !s.len().is_multiple_of(2) { + return Err("hex key must have an even number of digits".to_string()); + } + let mut bytes = Vec::with_capacity(s.len() / 2); + let digits = s.as_bytes(); + for pair in digits.chunks(2) { + let hi = (pair[0] as char) + .to_digit(16) + .ok_or_else(|| format!("non-hex digit {:?} in key", pair[0] as char))?; + let lo = (pair[1] as char) + .to_digit(16) + .ok_or_else(|| format!("non-hex digit {:?} in key", pair[1] as char))?; + bytes.push(((hi << 4) | lo) as u8); + } + if bytes.len() < 16 { + return Err(format!( + "key is {} bytes; at least 16 (32 hex chars) required", + bytes.len() + )); + } + Ok(Self(bytes)) + } + + /// Generate a fresh 32-byte key from OS randomness — the at-boot fallback + /// when no key is configured. Challenges issued under it die with the + /// process; clients refetch the 402. + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).expect("OS randomness available"); + Self(bytes.to_vec()) + } +} + +/// The framework's seven fixed positional HMAC slots, in table order. Required +/// slots carry their string value; an absent optional becomes the empty string +/// when joined, keeping every combination of optionals unambiguous. +#[derive(Debug, Clone, Copy)] +pub struct BindingSlots<'a> { + /// Slot 0: the protection-space realm. + pub realm: &'a str, + /// Slot 1: the payment method identifier. + pub method: &'a str, + /// Slot 2: the payment intent. + pub intent: &'a str, + /// Slot 3: the `request` auth-param exactly as on the wire + /// (JCS-serialized, base64url-encoded). + pub request_b64: &'a str, + /// Slot 4: `expires`, when issued. + pub expires: Option<&'a str>, + /// Slot 5: `digest`, when issued. + pub digest: Option<&'a str>, + /// Slot 6: `opaque` (base64url form), when issued. + pub opaque: Option<&'a str>, +} + +impl<'a> BindingSlots<'a> { + /// The slots as a credential's echoed challenge populates them. + pub fn from_echo(echo: &'a EchoedChallenge) -> Self { + Self { + realm: &echo.realm, + method: &echo.method, + intent: &echo.intent, + request_b64: &echo.request, + expires: echo.expires.as_deref(), + digest: echo.digest.as_deref(), + opaque: echo.opaque.as_deref(), + } + } + + /// The HMAC input: all seven slots pipe-joined, absent optionals as empty + /// segments. + fn joined(&self) -> String { + [ + self.realm, + self.method, + self.intent, + self.request_b64, + self.expires.unwrap_or(""), + self.digest.unwrap_or(""), + self.opaque.unwrap_or(""), + ] + .join("|") + } +} + +/// Compute the challenge `id`: base64url-nopad over HMAC-SHA256 of the seven +/// pipe-joined slots (framework Challenge Binding, Recommended HMAC-SHA256 +/// Binding). +pub fn compute_challenge_id(key: &BindingKey, slots: &BindingSlots<'_>) -> String { + let mut mac = + HmacSha256::new_from_slice(&key.0).expect("HMAC-SHA256 accepts any key length"); + mac.update(slots.joined().as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +/// Whether an echoed challenge is a faithful echo of one this server issued: +/// recompute the id-HMAC over the echoed auth-params and compare it (in +/// constant time) against the echoed `id`. Returns `false` for an id that is +/// not valid base64url, not 32 bytes, or whose MAC does not match — i.e. any +/// tampered, added, or dropped bound param. +pub fn verify_challenge_echo(key: &BindingKey, echo: &EchoedChallenge) -> bool { + let mut mac = + HmacSha256::new_from_slice(&key.0).expect("HMAC-SHA256 accepts any key length"); + mac.update(BindingSlots::from_echo(echo).joined().as_bytes()); + match URL_SAFE_NO_PAD.decode(&echo.id) { + // `verify_slice` is the constant-time comparison. + Ok(id_bytes) => mac.verify_slice(&id_bytes).is_ok(), + Err(_) => false, + } +} + +#[cfg(feature = "native")] +mod native { + use std::time::Duration; + + use chrono::{DateTime, SecondsFormat, Utc}; + + use super::{compute_challenge_id, verify_challenge_echo, BindingKey}; + use crate::charge::ChargeError; + use crate::envelope::{EchoedChallenge, PAYMENT_SCHEME}; + + /// Default challenge lifetime: 300 s (spec: `expires` is MUST under + /// stateless operation; the TTL itself is operator-configurable). + pub const DEFAULT_CHALLENGE_TTL: Duration = Duration::from_secs(300); + + /// One freshly-issued challenge: the HMAC-bound `id`, its RFC 3339 + /// `expires`, and the complete `WWW-Authenticate` header value. + #[derive(Debug, Clone)] + pub struct IssuedChallenge { + /// The HMAC-SHA256 binding id (base64url-nopad). + pub id: String, + /// RFC 3339 expiry (`now + ttl`, second precision, `Z`). + pub expires: String, + /// The full `Payment id="…", realm="…", method="…", intent="…", + /// request="…", expires="…"` header value. + pub header_value: String, + } + + /// Issue a fresh challenge: stamp `expires = now + ttl`, bind every + /// emitted auth-param into the id, and format the header. No `digest` or + /// `opaque` is emitted (their HMAC slots are empty), so a credential + /// echoing either back fails the binding. + pub fn issue_challenge( + key: &BindingKey, + realm: &str, + method: &str, + intent: &str, + request_b64: &str, + ttl: Duration, + ) -> IssuedChallenge { + let expires_ts = Utc::now() + + chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::seconds(300)); + let expires = expires_ts.to_rfc3339_opts(SecondsFormat::Secs, true); + let id = compute_challenge_id( + key, + &super::BindingSlots { + realm, + method, + intent, + request_b64, + expires: Some(&expires), + digest: None, + opaque: None, + }, + ); + let header_value = format!( + r#"{PAYMENT_SCHEME} id="{id}", realm="{realm}", method="{method}", intent="{intent}", request="{request_b64}", expires="{expires}""# + ); + IssuedChallenge { + id, + expires, + header_value, + } + } + + /// Whether an echoed RFC-3339 `expires` is in the past against the wall + /// clock. An UNPARSEABLE timestamp is treated as expired — defense in + /// depth; an echo that passed the HMAC carries the exact string this + /// server issued, which always parses. + pub fn expires_is_past(expires: &str) -> bool { + match DateTime::parse_from_rfc3339(expires) { + Ok(ts) => ts.with_timezone(&Utc) <= Utc::now(), + Err(_) => true, + } + } + + /// Spec verification step 3 for a stateless server: authenticate the echo + /// (recompute the id-HMAC over the echoed params — a tampered, added, or + /// dropped param, or an `expires`-less echo, is `invalid-challenge`), then + /// check freshness (`expires` in the past is `payment-expired`). + pub fn validate_challenge_echo( + key: &BindingKey, + echo: &EchoedChallenge, + ) -> Result<(), ChargeError> { + if !verify_challenge_echo(key, echo) { + return Err(ChargeError::InvalidChallenge); + } + // Statelessly-issued challenges always carry `expires` (the spec's + // MUST); an echo without one cannot be a faithful echo. The HMAC + // mismatch above already rejects it — this keeps the rule explicit. + let Some(expires) = echo.expires.as_deref() else { + return Err(ChargeError::InvalidChallenge); + }; + if expires_is_past(expires) { + return Err(ChargeError::ChallengeExpired); + } + Ok(()) + } +} + +#[cfg(feature = "native")] +pub use native::{ + expires_is_past, issue_challenge, validate_challenge_echo, IssuedChallenge, + DEFAULT_CHALLENGE_TTL, +}; + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> BindingKey { + BindingKey::from_bytes(*b"0123456789abcdef0123456789abcdef") + } + + fn echo_with(id: &str) -> EchoedChallenge { + EchoedChallenge { + id: id.to_string(), + realm: "api.example.com".into(), + method: "cashu".into(), + intent: "charge".into(), + request: "cmVxdWVzdA".into(), + digest: None, + opaque: None, + expires: Some("2026-03-15T12:05:00Z".into()), + description: None, + } + } + + /// An honestly-issued echo: id computed over the other fields. + fn issued_echo() -> EchoedChallenge { + let mut echo = echo_with(""); + echo.id = compute_challenge_id(&key(), &BindingSlots::from_echo(&echo)); + echo + } + + /// Slots with every field defaulted; tests override what they exercise. + fn slots<'a>( + expires: Option<&'a str>, + digest: Option<&'a str>, + opaque: Option<&'a str>, + ) -> BindingSlots<'a> { + BindingSlots { + realm: "r", + method: "cashu", + intent: "charge", + request_b64: "q", + expires, + digest, + opaque, + } + } + + #[test] + fn id_is_hmac_over_the_seven_pipe_joined_slots() { + // The expected input string is HAND-WRITTEN from the framework's slot + // table (realm|method|intent|request|expires|digest|opaque, empty + // string for an absent optional) — digest and opaque absent here, so + // the string ends with two empty segments. + let id = compute_challenge_id( + &key(), + &BindingSlots { + realm: "api.example.com", + method: "cashu", + intent: "charge", + request_b64: "cmVxdWVzdA", + expires: Some("2026-03-15T12:05:00Z"), + digest: None, + opaque: None, + }, + ); + let mut mac = HmacSha256::new_from_slice(b"0123456789abcdef0123456789abcdef") + .expect("hmac key"); + mac.update(b"api.example.com|cashu|charge|cmVxdWVzdA|2026-03-15T12:05:00Z||"); + let expected = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + assert_eq!(id, expected); + } + + #[test] + fn id_is_base64url_nopad_of_32_mac_bytes() { + let id = compute_challenge_id(&key(), &slots(Some("2026-01-01T00:00:00Z"), None, None)); + let bytes = URL_SAFE_NO_PAD.decode(&id).expect("id is base64url-nopad"); + assert_eq!(bytes.len(), 32, "HMAC-SHA256 output is 32 bytes"); + assert!(!id.contains('='), "no padding on the id"); + } + + #[test] + fn absent_optionals_occupy_distinct_fixed_slots() { + // Framework: (expires set, no digest) and (no expires, digest set) + // MUST produce distinct inputs — the positional empty-string slots + // are what keeps them apart. + let with_expires = compute_challenge_id(&key(), &slots(Some("X"), None, None)); + let with_digest = compute_challenge_id(&key(), &slots(None, Some("X"), None)); + let with_opaque = compute_challenge_id(&key(), &slots(None, None, Some("X"))); + assert_ne!(with_expires, with_digest); + assert_ne!(with_expires, with_opaque); + assert_ne!(with_digest, with_opaque); + } + + #[test] + fn faithful_echo_verifies() { + assert!(verify_challenge_echo(&key(), &issued_echo())); + } + + /// A named mutation of one echoed slot. + type Tamper = (&'static str, fn(&mut EchoedChallenge)); + + #[test] + fn tampering_any_bound_slot_fails_verification() { + // Every HMAC-bound auth-param: changing it (or injecting an unissued + // optional) breaks the recomputation. + let tampers: Vec = vec![ + ("realm", |e| e.realm = "evil.example.com".into()), + ("method", |e| e.method = "tempo".into()), + ("intent", |e| e.intent = "authorize".into()), + ("request", |e| e.request = "cmVxdWVzdB".into()), + ("expires", |e| { + e.expires = Some("2999-03-15T12:05:00Z".into()) + }), + ("expires-dropped", |e| e.expires = None), + ("digest-injected", |e| e.digest = Some("sha-256=:x:".into())), + ("opaque-injected", |e| e.opaque = Some("b3BhcXVl".into())), + ]; + for (slot, tamper) in tampers { + let mut echo = issued_echo(); + tamper(&mut echo); + assert!( + !verify_challenge_echo(&key(), &echo), + "tampered slot {slot} must fail the binding" + ); + } + } + + #[test] + fn tampered_id_fails_verification() { + let mut echo = issued_echo(); + echo.id = compute_challenge_id( + &key(), + &BindingSlots { + realm: "another.realm", + ..slots(None, None, None) + }, + ); + assert!(!verify_challenge_echo(&key(), &echo)); + } + + #[test] + fn non_base64url_id_fails_verification_without_panicking() { + let mut echo = issued_echo(); + echo.id = "not/base64+url=".into(); + assert!(!verify_challenge_echo(&key(), &echo)); + } + + #[test] + fn echoed_description_is_not_bound() { + // The framework excludes `description` from the 7 slots, so an echo + // differing only in description still verifies (it is display-only + // and unverifiable under stateless operation). + let mut echo = issued_echo(); + echo.description = Some("display only".into()); + assert!(verify_challenge_echo(&key(), &echo)); + } + + #[test] + fn different_key_fails_verification() { + let other = BindingKey::from_bytes(*b"ffffffffffffffffffffffffffffffff"); + assert!(!verify_challenge_echo(&other, &issued_echo())); + } + + #[test] + fn from_hex_roundtrips_and_rejects_garbage() { + let k = BindingKey::from_hex("000102030405060708090a0b0c0d0e0f").expect("16 bytes"); + assert_eq!(k.0, (0u8..16).collect::>()); + assert!(BindingKey::from_hex("").is_err(), "empty key rejected"); + assert!(BindingKey::from_hex("abc").is_err(), "odd length rejected"); + assert!(BindingKey::from_hex("zz00").is_err(), "non-hex rejected"); + assert!( + BindingKey::from_hex("aabb").is_err(), + "a 2-byte key is too short to bind anything" + ); + } + + #[test] + fn generated_keys_are_distinct() { + assert_ne!(BindingKey::generate().0, BindingKey::generate().0); + } + + #[test] + fn debug_is_redacted() { + let k = BindingKey::from_bytes(*b"0123456789abcdef0123456789abcdef"); + let dbg = format!("{k:?}"); + assert!( + !dbg.contains("0123456789abcdef"), + "Debug must not leak key bytes: {dbg}" + ); + assert!(dbg.contains("redacted")); + } + + #[cfg(feature = "native")] + mod native_tests { + use std::time::Duration; + + use super::*; + use crate::charge::ChargeError; + + #[test] + fn issued_challenge_verifies_as_its_own_echo() { + let issued = issue_challenge( + &key(), + "api.example.com", + "cashu", + "charge", + "cmVxdWVzdA", + DEFAULT_CHALLENGE_TTL, + ); + let echo = EchoedChallenge { + id: issued.id.clone(), + realm: "api.example.com".into(), + method: "cashu".into(), + intent: "charge".into(), + request: "cmVxdWVzdA".into(), + digest: None, + opaque: None, + expires: Some(issued.expires.clone()), + description: None, + }; + validate_challenge_echo(&key(), &echo).expect("a faithful echo validates"); + } + + #[test] + fn issued_header_carries_all_params_and_parses() { + let issued = issue_challenge( + &key(), + "api.example.com", + "cashu", + "charge", + "cmVxdWVzdA", + DEFAULT_CHALLENGE_TTL, + ); + let params = crate::envelope::parse_payment_params(&issued.header_value) + .expect("issued header parses"); + assert_eq!(params.id, issued.id); + assert_eq!(params.realm, "api.example.com"); + assert_eq!(params.method, "cashu"); + assert_eq!(params.intent, "charge"); + assert_eq!(params.request, "cmVxdWVzdA"); + assert_eq!(params.expires.as_deref(), Some(issued.expires.as_str())); + } + + #[test] + fn issued_expires_is_rfc3339_in_the_future() { + let issued = issue_challenge( + &key(), + "r", + "cashu", + "charge", + "q", + DEFAULT_CHALLENGE_TTL, + ); + assert!(!expires_is_past(&issued.expires)); + chrono::DateTime::parse_from_rfc3339(&issued.expires) + .expect("expires is RFC 3339"); + } + + #[test] + fn stale_expires_is_payment_expired_not_invalid_challenge() { + // A zero TTL stamps `expires = now`, instantly past — the echo is + // authentic (HMAC verifies) but stale. + let issued = + issue_challenge(&key(), "r", "cashu", "charge", "q", Duration::ZERO); + let echo = EchoedChallenge { + id: issued.id.clone(), + realm: "r".into(), + method: "cashu".into(), + intent: "charge".into(), + request: "q".into(), + digest: None, + opaque: None, + expires: Some(issued.expires.clone()), + description: None, + }; + match validate_challenge_echo(&key(), &echo) { + Err(ChargeError::ChallengeExpired) => {} + other => panic!("expected ChallengeExpired, got {other:?}"), + } + } + + #[test] + fn unparseable_expires_counts_as_past() { + assert!(expires_is_past("not-a-timestamp")); + assert!(expires_is_past("2000-01-01T00:00:00Z")); + assert!(!expires_is_past("2999-01-01T00:00:00Z")); + } + } +} diff --git a/crates/pops-core-verify/src/cashu_credential.rs b/crates/pops-core-verify/src/cashu_credential.rs index ce29c8c..25d0a03 100644 --- a/crates/pops-core-verify/src/cashu_credential.rs +++ b/crates/pops-core-verify/src/cashu_credential.rs @@ -20,7 +20,7 @@ use crate::charge::{ChargeError, RedeemedProofs}; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::challenge::{decode_token, CashuRequirement}; +use crate::challenge::{decode_token, mint_url_has_userinfo, CashuRequirement}; use crate::redeemer::{ChargeRequirement, Redeemer, Redeemed}; use crate::error::Error as ChallengeError; use crate::mint_client::{MintClient, MintClientError}; @@ -37,13 +37,18 @@ pub struct ValidatedCharge { pub unit: CurrencyUnit, /// Total amount of the swapped proofs. pub amount: Amount, + /// NUT-12 verdict on the swap-RETURNED blind signatures. `false` is a + /// mint-trust incident (`draft-cashu-charge-01` §security-dleq), already + /// WARN-logged by the swap ceremony — the charge itself SUCCEEDED and the + /// resource is served; the flag rides along so hosts can alert/quarantine. + pub dleq_ok: bool, } /// Errors a [`ChargeValidator`] can return. The pre-swap arms (`UnitMismatch`, -/// `MintNotAllowed`, `AmountMismatch`, `TokenEmpty`, `LockedToken`, -/// `MultiMintOrUnit`, `TooManyProofs`) are raised BEFORE the swap is ever -/// attempted; the rest are raised at/after it. [`CashuCredential`] maps these -/// onto [`crate::charge::ChargeError`]. +/// `ResolvedKeysetUnitMismatch`, `MintNotAllowed`, `PaymentInsufficient`, +/// `TokenEmpty`, `LockedToken`, `MultiMintOrUnit`, `TooManyProofs`) are raised +/// BEFORE the swap is ever attempted; the rest are raised at/after it. +/// [`CashuCredential`] maps these onto [`crate::charge::ChargeError`]. #[derive(Debug, Error)] pub enum ValidationError { /// Token unit does not match the requirement's unit. @@ -55,6 +60,24 @@ pub enum ValidationError { got: CurrencyUnit, }, + /// A RESOLVED keyset's unit differs from the requirement's (spec + /// verification step 7): the token's declared unit is client-supplied data, + /// the mint's published keyset is the authority. Raised BEFORE the swap, so + /// a foreign-unit proof smuggled under a matching declared unit never + /// reaches the mint. + #[error( + "resolved keyset {keyset_id} has unit {got:?}, requirement unit is {expected:?} \ + (the published keyset, not the token's declared unit, is authoritative)" + )] + ResolvedKeysetUnitMismatch { + /// The offending keyset (full hex id). + keyset_id: String, + /// Unit the requirement demands. + expected: CurrencyUnit, + /// Unit the mint publishes for this keyset. + got: CurrencyUnit, + }, + /// A proof carries a NUT-10 spending-condition secret (P2PK / HTLC). This /// intent is BEARER-only; a locked proof is rejected before the swap, which /// the bearer ceremony could not satisfy anyway. @@ -86,29 +109,67 @@ pub enum ValidationError { allowed: Vec, }, - /// Total proof amount is not EXACTLY the requirement. The charge is - /// exact-amount: the verifier makes no change, so an over-funded token is - /// rejected just like an under-funded one (the holder splits locally, - /// non-custodially, before presenting). - #[error("token amount {got} does not equal required {required}")] - AmountMismatch { + /// Token's mint URL carries userinfo (`user@host`) — the spec's mint-trust + /// § rejects it outright, before any membership comparison. + #[error("token mint URL {url} carries userinfo (user@host), which is rejected outright")] + MintUrlUserinfo { + /// The offending mint URL. + url: String, + }, + + /// A v2 short keyset id resolves to zero or multiple keysets in the mint's + /// published list. Raised BEFORE proof extraction (cashu's own resolver + /// silently takes the first prefix match, so ambiguity is checked here). + #[error("unresolvable or ambiguous short keyset id {short_id} against the mint's published keysets")] + ShortKeysetIdUnresolved { + /// The short id as it appears on the wire (hex). + short_id: String, + }, + + /// Total proof amount is LESS than the requirement (spec verification + /// step 8: value must be at least `amount + expected_swap_fee`). The + /// verifier makes no change; value ABOVE the requirement is accepted and + /// retained, so only an under-funded token is rejected. + #[error("token amount {got} is less than required {required}")] + PaymentInsufficient { /// Amount required. required: Amount, /// Total presented. got: Amount, }, - /// Mint accepted the call but rejected the proofs (expired, double-spent, - /// bad signature, keyset rotated, etc.). + /// Mint accepted the call but rejected the proofs WITHOUT typing the + /// reason as already-spent or keyset-class (bad signature, unbalanced, + /// etc.) — the definitive-rejection catch-all. #[error("mint rejected swap: {0}")] MintRejectedSwap(String), - /// Swap-output blind signatures whose NUT-12 DLEQ is missing or invalid: - /// unsigned / wrong-key outputs that MUST NOT be redeemed. Kept distinct - /// from [`Self::MintRejectedSwap`] so it maps to `DleqInvalid`, not a - /// double-spend — collapsing the two would hide the mint-trust signal. - #[error("swap-output DLEQ verification failed: {0}")] - SwapOutputDleqInvalid(String), + /// Mint rejected the swap because an input proof is ALREADY SPENT (the + /// mint-typed double-spend, NUT code 11001). + #[error("mint rejected swap: proof already spent: {0}")] + AlreadySpent(String), + + /// Mint rejected the call with a keyset-class error: the keyset has + /// retired or its `final_expiry` has passed (spec verification step 9 — + /// a `payment-expired` condition, never `verification-failed`). The token + /// was NOT consumed. + #[error("mint rejected swap (keyset retired or final_expiry passed): {0}")] + KeysetRetiredOrExpired(String), + + /// The keyset charges an `input_fee_ppk` the fee-free profile disallows — + /// a policy reject raised before the swap (token NOT consumed), kept + /// distinct from [`Self::MintRejectedSwap`] so it never reads as a + /// double-spend. + #[error( + "fee-bearing keyset {keyset_id} disallowed: input_fee_ppk {input_fee_ppk} \ + exceeds the fee-free profile" + )] + FeeTooHigh { + /// Keyset whose fee exceeded the profile (hex id). + keyset_id: String, + /// The disallowed `input_fee_ppk`. + input_fee_ppk: u64, + }, /// DETERMINATE unreachable: a pre-swap GET or a connect failure that never /// submitted the inputs. The token was NOT consumed; retry is authoritative. @@ -192,6 +253,13 @@ impl ChargeValidator { let token_mint = token .mint_url() .map_err(|e| ValidationError::MalformedToken(e.to_string()))?; + // Spec mint-trust §: a userinfo-bearing mint URL is rejected outright, + // before any membership comparison. + if mint_url_has_userinfo(&token_mint.to_string()) { + return Err(ValidationError::MintUrlUserinfo { + url: token_mint.to_string(), + }); + } if !requirement.mints.is_empty() && !requirement.mints.contains(&token_mint) { return Err(ValidationError::MintNotAllowed { got: token_mint, @@ -233,20 +301,49 @@ impl ChargeValidator { .await .map_err(|e| match e { MintClientError::Unreachable(msg) => ValidationError::MintUnreachable(msg), - // `keysets()` submits no inputs and does no DLEQ work, so these - // two arms are unreachable here; map defensively (determinate - // unreachable / swap-rejection) to keep the match total. + // `keysets()` submits no inputs and does no DLEQ/fee work, so + // the remaining arms are unreachable here; map defensively + // (each onto its honest counterpart) to keep the match total. MintClientError::UnreachableIndeterminate(msg) => { ValidationError::MintUnreachable(msg) } MintClientError::RejectedSwap(msg) => ValidationError::MintRejectedSwap(msg), - MintClientError::SwapOutputDleqInvalid(msg) => { - ValidationError::MintRejectedSwap(msg) + MintClientError::AlreadySpent(msg) => ValidationError::AlreadySpent(msg), + MintClientError::KeysetRetiredOrExpired(msg) => { + ValidationError::KeysetRetiredOrExpired(msg) } + MintClientError::FeeTooHigh { + keyset_id, + input_fee_ppk, + } => ValidationError::FeeTooHigh { + keyset_id, + input_fee_ppk, + }, })?; - // Resolves V1 short IDs against the list (V0 do not consult it). A V1 ID - // with no matching keyset surfaces as MalformedToken. + // cashu 0.16's `Id::from_short_keyset_id` silently resolves a v2 short + // id to the FIRST prefix match, so resolve locally first: zero matches + // is unresolvable and more than one is ambiguous — both reject here, + // pre-extraction. Only well-formed v2 prefixes (7–32 bytes) are + // checked; anything else falls through to the extraction error below. + for short in token_short_keyset_ids(token) { + let bytes = short.to_bytes(); + let prefix = &bytes[1..]; + if bytes[0] != 0x01 || !(7..=32).contains(&prefix.len()) { + continue; + } + let matches = keysets + .iter() + .filter(|k| k.id.to_bytes()[1..].starts_with(prefix)) + .count(); + if matches != 1 { + return Err(ValidationError::ShortKeysetIdUnresolved { + short_id: short.to_string(), + }); + } + } + + // Resolves V1 short IDs against the list (V0 do not consult it). let proofs = token .proofs(&keysets) .map_err(|e| ValidationError::MalformedToken(e.to_string()))?; @@ -262,13 +359,32 @@ impl ChargeValidator { return Err(ValidationError::MultiMintOrUnit); } - // Exact-amount (see `AmountMismatch`). Summed directly rather than via - // `Token::value()` so an off-amount token short-circuits before swap. + // Spec verification step 7, BEFORE the step-8 value check and the swap: + // the resolved keyset's unit must equal the requirement's. The token's + // declared unit (checked above) is client-supplied data; the mint's + // published keyset is the authority — without this, a sat-keyset proof + // under a token DECLARING the pop unit would reach the swap. A keyset + // id absent from the published list resolves nothing (no unit to + // assert); the swap rejects unknown keysets. + if let Some(resolved) = keysets.iter().find(|k| k.id == first_keyset) { + if resolved.unit != requirement.unit { + return Err(ValidationError::ResolvedKeysetUnitMismatch { + keyset_id: first_keyset.to_string(), + expected: requirement.unit.clone(), + got: resolved.unit.clone(), + }); + } + } + + // Value check (see `PaymentInsufficient`): at least the requirement + // (the fee-free profile's `expected_swap_fee` is 0, so the requirement + // is the bare amount). Summed directly rather than via `Token::value()` + // so an under-funded token short-circuits before swap. let token_amount = proofs .total_amount() .map_err(|e| ValidationError::MalformedToken(e.to_string()))?; - if token_amount != requirement.amount { - return Err(ValidationError::AmountMismatch { + if token_amount < requirement.amount { + return Err(ValidationError::PaymentInsufficient { required: requirement.amount, got: token_amount, }); @@ -288,8 +404,10 @@ impl ChargeValidator { self.check_and_extract(token, requirement).await?; // A successful swap atomically proves both unspentness (nullifier check) - // and unexpired credential (`final_expiry` check). - let new_proofs = self + // and unexpired credential (`final_expiry` check). Its `dleq_ok` is the + // swap-output NUT-12 verdict — a FLAG, never a failure (the ceremony + // already WARN-logged a false one; see `ValidatedCharge::dleq_ok`). + let outcome = self .mint_client .swap(&token_mint, proofs) .await @@ -299,27 +417,55 @@ impl ChargeValidator { ValidationError::MintUnreachableIndeterminate(msg) } MintClientError::RejectedSwap(msg) => ValidationError::MintRejectedSwap(msg), - // Money-safety: a bad swap-output DLEQ is its own outcome, NEVER - // collapsed into MintRejectedSwap (which would 402 as a - // DoubleSpend and hide the mint-trust signal). - MintClientError::SwapOutputDleqInvalid(msg) => { - ValidationError::SwapOutputDleqInvalid(msg) + // The mint-typed already-spent rejection keeps the honest + // double-spend detail. + MintClientError::AlreadySpent(msg) => ValidationError::AlreadySpent(msg), + // Spec step 9 split: a keyset-retirement/final_expiry rejection + // is payment-expired, kept apart from the verification-failed + // catch-all above. + MintClientError::KeysetRetiredOrExpired(msg) => { + ValidationError::KeysetRetiredOrExpired(msg) } + // A fee-policy reject (raised pre-submit inside the ceremony) + // keeps its own arm so it never reads as a double-spend. + MintClientError::FeeTooHigh { + keyset_id, + input_fee_ppk, + } => ValidationError::FeeTooHigh { + keyset_id, + input_fee_ppk, + }, })?; - let new_amount = new_proofs + let new_amount = outcome + .proofs .total_amount() .map_err(|e| ValidationError::MalformedToken(e.to_string()))?; Ok(ValidatedCharge { - new_proofs, + new_proofs: outcome.proofs, mint_url: token_mint, unit: token_unit, amount: new_amount, + dleq_ok: outcome.dleq_ok, }) } } +/// Every short keyset id the token references on the wire (V4 groups proofs +/// per keyset id; V3 carries one per proof) — the inputs to the local +/// resolution scan above. +fn token_short_keyset_ids(token: &Token) -> Vec { + match token { + Token::TokenV3(t) => t + .token + .iter() + .flat_map(|t| t.proofs.iter().map(|p| p.keyset_id.clone())) + .collect(), + Token::TokenV4(t) => t.token.iter().map(|t| t.keyset_id.clone()).collect(), + } +} + /// Convert a cashu-typed [`CashuRequirement`] into the decoupled /// [`ChargeRequirement`] the [`Redeemer`] seam speaks. For callers (the /// middleware) that hold the cashu-typed requirement but drive a generic @@ -345,6 +491,13 @@ fn cashu_requirement_from_charge(req: &ChargeRequirement) -> Result String { /// Map a cashu-typed [`ValidationError`] onto the cross-slice [`ChargeError`]. /// `mint_url` supplies the transport context the cashu arm does not carry. The -/// two money-safety arms (DoubleSpend, DLEQ) are noted inline. +/// money-safety DoubleSpend arm is noted inline. fn map_validation_error(e: ValidationError, mint_url: &str) -> ChargeError { match e { ValidationError::MintUnreachable(detail) => ChargeError::MintUnreachable { @@ -390,35 +543,57 @@ fn map_validation_error(e: ValidationError, mint_url: &str) -> ChargeError { ValidationError::LockedToken => ChargeError::LockedToken, ValidationError::MultiMintOrUnit => ChargeError::MultiMintOrUnit, ValidationError::TooManyProofs { got, max } => ChargeError::TooManyProofs { got, max }, - ValidationError::AmountMismatch { required, got } => ChargeError::AmountMismatch { - required: u64::from(required), - presented: u64::from(got), - amount: u64::from(required), - expected_swap_fee: 0, - }, + ValidationError::PaymentInsufficient { required, got } => { + ChargeError::PaymentInsufficient { + required: u64::from(required), + presented: u64::from(got), + amount: u64::from(required), + expected_swap_fee: 0, + } + } ValidationError::UnitMismatch { expected, got } => ChargeError::WrongUnit { expected: expected.to_string(), got: got.to_string(), }, + // Spec step 7: a resolved-keyset unit mismatch is the same + // verification-failed condition as a declared-unit mismatch (the + // keyset-id context stays in the validation-layer log). + ValidationError::ResolvedKeysetUnitMismatch { expected, got, .. } => { + ChargeError::WrongUnit { + expected: expected.to_string(), + got: got.to_string(), + } + } ValidationError::MintNotAllowed { got, allowed } => ChargeError::MintNotAllowed { got: got.to_string(), allowed: allowed.iter().map(|m| m.to_string()).collect(), }, + ValidationError::MintUrlUserinfo { url } => ChargeError::MintUrlUserinfo { url }, + ValidationError::ShortKeysetIdUnresolved { short_id } => { + ChargeError::ShortKeysetIdUnresolved { short_id } + } ValidationError::TokenEmpty => { ChargeError::MalformedCredential("token contains no proofs".to_string()) } ValidationError::MalformedToken(msg) => { ChargeError::MalformedCredential(format!("malformed token: {msg}")) } - // Both swap-rejections (expired credential OR double-spent proof) - // currently surface as DoubleSpend=402. Splitting out an Expired arm - // needs the mint's NUT-03 error-body parse, which is not yet done. - ValidationError::MintRejectedSwap(_) => ChargeError::DoubleSpend, - // Money-safety: a missing/invalid swap-output DLEQ is verification- - // failed — a 402 (gateway serves nothing), distinct from a double-spend - // so the operator sees the mint-trust signal. NEVER serve the resource - // on this path. - ValidationError::SwapOutputDleqInvalid(_) => ChargeError::DleqInvalid, + // Spec step 9: a swap rejected for keyset retirement or passed + // `final_expiry` (the mint's NUT keyset-error codes, classified by the + // mint client) is payment-expired; every OTHER rejection is the + // else-branch verification-failed condition — split into the + // mint-typed already-spent (the honest double-spend detail) and the + // neutral catch-all. + ValidationError::KeysetRetiredOrExpired(_) => ChargeError::Expired, + ValidationError::AlreadySpent(_) => ChargeError::DoubleSpend, + ValidationError::MintRejectedSwap(detail) => ChargeError::SwapRejected(detail), + ValidationError::FeeTooHigh { + keyset_id, + input_fee_ppk, + } => ChargeError::FeeTooHigh { + keyset_id, + input_fee_ppk, + }, } } @@ -526,6 +701,7 @@ impl Redeemer for CashuCredential { unit, amount, proofs, + dleq_ok: validated.dleq_ok, }) } } @@ -545,7 +721,7 @@ mod tests { use super::{ChargeValidator, ValidatedCharge, ValidationError}; use crate::challenge::CashuRequirement; - use crate::mint_client::{MintClient, MintClientError}; + use crate::mint_client::{MintClient, MintClientError, SwapOutcome}; /// Canned outcome for the mock [`MintClient::swap`] call. enum SwapResponse { @@ -555,7 +731,14 @@ mod tests { Unreachable, UnreachableIndeterminate, RejectedSwap, + /// The mint-typed already-spent rejection (NUT code 11001). + AlreadySpent, + /// The keyset-class rejection (retired / final_expiry passed) — the + /// spec's payment-expired swap outcome. + KeysetRetiredOrExpired, DleqInvalid, + /// The ceremony's pre-submit fee-policy reject (fee-bearing keyset). + FeeTooHigh, } /// Canned outcome for the mock [`MintClient::keysets`] call. @@ -627,10 +810,13 @@ mod tests { &self, _mint_url: &MintUrl, proofs: Proofs, - ) -> Result { + ) -> Result { self.swap_calls.fetch_add(1, Ordering::SeqCst); match self.swap_response { - SwapResponse::Echo => Ok(proofs), + SwapResponse::Echo => Ok(SwapOutcome { + proofs, + dleq_ok: true, + }), SwapResponse::Unreachable => { Err(MintClientError::Unreachable("mock unreachable".into())) } @@ -640,9 +826,22 @@ mod tests { SwapResponse::RejectedSwap => { Err(MintClientError::RejectedSwap("mock rejected".into())) } - SwapResponse::DleqInvalid => Err(MintClientError::SwapOutputDleqInvalid( - "mock swap-output DLEQ invalid".into(), - )), + SwapResponse::AlreadySpent => { + Err(MintClientError::AlreadySpent("mock already spent".into())) + } + SwapResponse::KeysetRetiredOrExpired => Err( + MintClientError::KeysetRetiredOrExpired("mock keyset retired".into()), + ), + // The ceremony's serve-and-flag contract: a DLEQ failure on the + // swap-returned signatures still redeems (spec §security-dleq). + SwapResponse::DleqInvalid => Ok(SwapOutcome { + proofs, + dleq_ok: false, + }), + SwapResponse::FeeTooHigh => Err(MintClientError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }), } } } @@ -745,6 +944,7 @@ mod tests { mint_url, unit, amount, + dleq_ok, } = validator .validate(&token, &req) .await @@ -764,6 +964,7 @@ mod tests { assert_eq!(unit, pop_unit()); assert_eq!(amount, Amount::from(10)); assert_eq!(new_proofs.len(), 2); + assert!(dleq_ok, "a clean swap reports a clean DLEQ verdict"); } #[tokio::test] @@ -835,37 +1036,39 @@ mod tests { .await .expect_err("underfunded amount must fail"); assert!( - matches!(err, ValidationError::AmountMismatch { .. }), - "expected AmountMismatch, got {err:?}" + matches!(err, ValidationError::PaymentInsufficient { .. }), + "expected PaymentInsufficient, got {err:?}" ); assert_eq!( counters.swap.load(Ordering::SeqCst), 0, - "swap must NOT be called on amount mismatch" + "swap must NOT be called on an insufficient token" ); } #[tokio::test] - async fn validate_rejects_overfunded_amount() { - // Exact-amount: an over-funded token is rejected, NOT charged with change. + async fn validate_accepts_overfunded_amount_and_retains_excess() { + // Spec step 8: value ABOVE `amount + expected_swap_fee` is accepted and + // retained — the whole 20 is swapped against a 10 requirement. let token = make_token(mint_a(), pop_unit(), vec![make_proof(16, 0), make_proof(4, 1)]); let req = requirement(pop_unit(), vec![mint_a()], 10); let (mock, counters) = MockMintClient::with_swap(SwapResponse::Echo); let validator = ChargeValidator::new(mock); - let err = validator + let validated = validator .validate(&token, &req) .await - .expect_err("overfunded amount must fail (no verifier-side change)"); - assert!( - matches!(err, ValidationError::AmountMismatch { .. }), - "expected AmountMismatch, got {err:?}" + .expect("an over-funded token must validate"); + assert_eq!( + validated.amount, + Amount::from(20), + "the WHOLE presented value is redeemed (excess retained, no change)" ); assert_eq!( counters.swap.load(Ordering::SeqCst), - 0, - "swap must NOT be called on amount mismatch" + 1, + "swap runs once on the over-funded accept path" ); } @@ -959,30 +1162,120 @@ mod tests { } #[tokio::test] - async fn validate_propagates_swap_output_dleq_invalid() { - // Money-safety: a swap-output DLEQ failure must surface as its OWN arm, - // never collapsed into MintRejectedSwap. + async fn validate_propagates_keyset_retired_swap_rejection_distinctly() { + // Spec step 9: a swap rejected for keyset retirement / final_expiry + // surfaces as its own arm, never collapsed into MintRejectedSwap. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); let req = requirement(pop_unit(), vec![mint_a()], 10); - let (mock, counters) = MockMintClient::with_swap(SwapResponse::DleqInvalid); + let (mock, counters) = + MockMintClient::with_swap(SwapResponse::KeysetRetiredOrExpired); let validator = ChargeValidator::new(mock); let err = validator .validate(&token, &req) .await - .expect_err("swap-output DLEQ failure must fail validation"); + .expect_err("a keyset-class rejection must fail"); assert!( - matches!(err, ValidationError::SwapOutputDleqInvalid(_)), - "expected SwapOutputDleqInvalid (distinct from MintRejectedSwap), got {err:?}" + matches!(err, ValidationError::KeysetRetiredOrExpired(_)), + "expected KeysetRetiredOrExpired, got {err:?}" ); assert_eq!( counters.swap.load(Ordering::SeqCst), 1, - "swap must be called once before the DLEQ failure surfaces" + "swap must be called once before the rejection surfaces" ); } + #[tokio::test] + async fn validate_succeeds_with_dleq_flag_false_on_swap_output_dleq_failure() { + // Spec step 9: "a failed or missing DLEQ proof after a successful swap + // is a mint-trust incident, not a payment failure" — validation + // SUCCEEDS, the redeemed value is kept, and `dleq_ok` carries the + // verdict for the operator. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let req = requirement(pop_unit(), vec![mint_a()], 10); + + let (mock, counters) = MockMintClient::with_swap(SwapResponse::DleqInvalid); + let validator = ChargeValidator::new(mock); + + let validated = validator + .validate(&token, &req) + .await + .expect("a swap-output DLEQ failure must NOT fail validation"); + assert!(!validated.dleq_ok, "the verdict flag must carry the failure"); + assert_eq!( + u64::from(validated.amount), + 10, + "the consumed inputs' value must be redeemed" + ); + assert_eq!( + counters.swap.load(Ordering::SeqCst), + 1, + "the swap ran once and succeeded" + ); + } + + #[tokio::test] + async fn validate_rejects_resolved_keyset_unit_mismatch_before_swap() { + // Spec step 7: the published keyset is the unit authority. A token + // DECLARING the required unit whose proofs sit on a keyset the mint + // publishes under a DIFFERENT unit is rejected pre-swap — with ZERO + // swap calls, so the foreign-unit proofs are never consumed. + let keyset_id = Id::from_str("009a1f293253e41e").expect("valid v0 keyset id"); + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let req = requirement(pop_unit(), vec![mint_a()], 10); + + let (mock, counters) = MockMintClient::new( + SwapResponse::Echo, + KeysetsResponse::Ok(vec![keyset_info(keyset_id, CurrencyUnit::Sat)]), + ); + let validator = ChargeValidator::new(mock); + + let err = validator + .validate(&token, &req) + .await + .expect_err("a sat-keyset proof under a pop-declared token must be rejected"); + match err { + ValidationError::ResolvedKeysetUnitMismatch { + keyset_id: id, + expected, + got, + } => { + assert_eq!(id, "009a1f293253e41e"); + assert_eq!(expected, pop_unit()); + assert_eq!(got, CurrencyUnit::Sat); + } + other => panic!("expected ResolvedKeysetUnitMismatch, got {other:?}"), + } + assert_eq!( + counters.swap.load(Ordering::SeqCst), + 0, + "step 7 must reject BEFORE the swap (zero swap calls)" + ); + } + + #[tokio::test] + async fn validate_accepts_resolved_keyset_with_matching_unit() { + // The positive half of the step-7 assertion: a published keyset whose + // unit equals the requirement's passes through to the swap. + let keyset_id = Id::from_str("009a1f293253e41e").expect("valid v0 keyset id"); + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let req = requirement(pop_unit(), vec![mint_a()], 10); + + let (mock, counters) = MockMintClient::new( + SwapResponse::Echo, + KeysetsResponse::Ok(vec![keyset_info(keyset_id, pop_unit())]), + ); + let validator = ChargeValidator::new(mock); + + validator + .validate(&token, &req) + .await + .expect("a matching resolved-keyset unit must validate"); + assert_eq!(counters.swap.load(Ordering::SeqCst), 1, "swap proceeds"); + } + #[tokio::test] async fn validate_happy_path_v1_keyset() { // A V1 token serializes its keyset id as a 7-byte short id on the wire; @@ -1045,8 +1338,9 @@ mod tests { #[tokio::test] async fn validate_rejects_v1_token_with_no_matching_keyset() { - // Empty keysets list ⇒ the 7-byte short id cannot resolve, so extraction - // fails as MalformedToken and no proofs exist to swap. + // Empty keysets list ⇒ the 7-byte short id resolves to nothing — the + // dedicated ShortKeysetIdUnresolved verification failure (never the + // malformed-credential family), and no proofs exist to swap. let v1_id = v1_keyset_id(); let proofs = vec![proof_with_keyset(10, 0, v1_id)]; let token_str = make_token(mint_a(), pop_unit(), proofs).to_string(); @@ -1063,8 +1357,8 @@ mod tests { .await .expect_err("no-matching-keyset must fail"); assert!( - matches!(err, ValidationError::MalformedToken(_)), - "expected MalformedToken, got {err:?}" + matches!(err, ValidationError::ShortKeysetIdUnresolved { .. }), + "expected ShortKeysetIdUnresolved, got {err:?}" ); assert_eq!( counters.keysets.load(Ordering::SeqCst), @@ -1074,7 +1368,79 @@ mod tests { assert_eq!( counters.swap.load(Ordering::SeqCst), 0, - "swap must NOT be called when no proofs can be extracted" + "swap must NOT be called when the short id resolves nothing" + ); + } + + #[tokio::test] + async fn validate_rejects_ambiguous_short_keyset_id() { + // TWO published keysets share the wire short id's 7-byte prefix — + // cashu's own resolver would silently take the first; the validator + // rejects the ambiguity instead, before any swap. + let v1_id = v1_keyset_id(); + let sibling = Id::from_str( + "01aabbccddeeff00ffeeddccbbaa99887766554433221100ffeeddccbbaa998877", + ) + .expect("valid v1 keyset id sharing the 7-byte prefix"); + let proofs = vec![proof_with_keyset(10, 0, v1_id)]; + let token_str = make_token(mint_a(), pop_unit(), proofs).to_string(); + let token = Token::from_str(&token_str).expect("v1 token round-trips"); + + let req = requirement(pop_unit(), vec![mint_a()], 10); + + let (mock, counters) = MockMintClient::new( + SwapResponse::Echo, + KeysetsResponse::Ok(vec![ + keyset_info(v1_id, pop_unit()), + keyset_info(sibling, pop_unit()), + ]), + ); + let validator = ChargeValidator::new(mock); + + let err = validator + .validate(&token, &req) + .await + .expect_err("an ambiguous short id must fail"); + assert!( + matches!(err, ValidationError::ShortKeysetIdUnresolved { .. }), + "expected ShortKeysetIdUnresolved, got {err:?}" + ); + assert_eq!( + counters.swap.load(Ordering::SeqCst), + 0, + "swap must NOT be called on an ambiguous short id" + ); + } + + #[tokio::test] + async fn validate_rejects_userinfo_mint_url_before_any_network_call() { + // Spec mint-trust §: `user@host` in the token's mint URL is rejected + // outright — even when the requirement would otherwise accept any mint. + let userinfo_mint = + MintUrl::from_str("https://user@mint-a.example.com").expect("parses with userinfo"); + let token = make_token(userinfo_mint, pop_unit(), vec![make_proof(10, 0)]); + let req = requirement(pop_unit(), vec![], 10); + + let (mock, counters) = MockMintClient::with_swap(SwapResponse::Echo); + let validator = ChargeValidator::new(mock); + + let err = validator + .validate(&token, &req) + .await + .expect_err("a userinfo mint URL must be rejected"); + assert!( + matches!(err, ValidationError::MintUrlUserinfo { .. }), + "expected MintUrlUserinfo, got {err:?}" + ); + assert_eq!( + counters.keysets.load(Ordering::SeqCst), + 0, + "keysets must NOT be called for a userinfo mint URL" + ); + assert_eq!( + counters.swap.load(Ordering::SeqCst), + 0, + "swap must NOT be called for a userinfo mint URL" ); } @@ -1340,6 +1706,10 @@ mod tests { !redeemed.proofs.active_keyset_id.is_empty(), "active_keyset_id must be populated" ); + assert!( + redeemed.dleq_ok, + "a clean swap-output DLEQ verdict rides the success" + ); } #[tokio::test] @@ -1362,13 +1732,9 @@ mod tests { } #[tokio::test] - async fn verify_and_redeem_maps_amount_mismatch_with_zero_fee() { - let presented = make_token( - mint_a(), - pop_unit(), - vec![make_proof(16, 0), make_proof(4, 1)], - ) - .to_string(); + async fn verify_and_redeem_maps_underfunded_to_payment_insufficient() { + let presented = + make_token(mint_a(), pop_unit(), vec![make_proof(8, 0)]).to_string(); let req = charge_req("pop_1700000000", vec![mint_a()], 10); let (mock, _c) = MockMintClient::with_swap(SwapResponse::Echo); @@ -1377,26 +1743,51 @@ mod tests { let err = cred .verify_and_redeem(&presented, &req) .await - .expect_err("amount mismatch must map to AmountMismatch"); + .expect_err("an under-funded token must map to PaymentInsufficient"); match err { - ChargeError::AmountMismatch { + ChargeError::PaymentInsufficient { required, presented, amount, expected_swap_fee, } => { assert_eq!(required, 10); - assert_eq!(presented, 20); + assert_eq!(presented, 8); assert_eq!(amount, 10); - assert_eq!(expected_swap_fee, 0, "fee forced 0 in Step 1"); + assert_eq!(expected_swap_fee, 0, "fee-free profile: fee is 0"); } - other => panic!("expected AmountMismatch, got {other:?}"), + other => panic!("expected PaymentInsufficient, got {other:?}"), } } #[tokio::test] - async fn verify_and_redeem_maps_rejected_swap_to_double_spend() { - // Any swap rejection collapses to DoubleSpend (see `map_validation_error`). + async fn verify_and_redeem_accepts_overfunded_and_reports_full_value() { + // Over-funded accept path through the Redeemer seam: the excess is + // retained, so the redeemed amount is the FULL presented value. + let presented = make_token( + mint_a(), + pop_unit(), + vec![make_proof(16, 0), make_proof(4, 1)], + ) + .to_string(); + let req = charge_req("pop_1700000000", vec![mint_a()], 10); + + let (mock, _c) = MockMintClient::with_swap(SwapResponse::Echo); + let cred = CashuCredential::new(mock); + + let redeemed = cred + .verify_and_redeem(&presented, &req) + .await + .expect("an over-funded token must redeem"); + assert_eq!(redeemed.amount, 20, "full presented value redeemed"); + assert_eq!(redeemed.proofs.amount, 20); + } + + #[tokio::test] + async fn verify_and_redeem_maps_untyped_rejection_to_neutral_swap_rejected() { + // A swap rejection the mint did NOT type as already-spent maps to the + // neutral SwapRejected — verification-failed (the spec's step-9 + // else-branch) with a detail that claims no double-spend. let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]) .to_string(); let req = charge_req("pop_1700000000", vec![mint_a()], 10); @@ -1407,30 +1798,172 @@ mod tests { let err = cred .verify_and_redeem(&presented, &req) .await - .expect_err("rejected swap must map to DoubleSpend"); + .expect_err("rejected swap must map to SwapRejected"); + assert!( + matches!(err, ChargeError::SwapRejected(_)), + "expected SwapRejected, got {err:?}" + ); + let detail = err.to_string(); + assert!( + detail.contains("the mint rejected the swap"), + "neutral detail expected, got: {detail}" + ); + assert!( + !detail.contains("double-spend"), + "an untyped rejection must not claim a double-spend: {detail}" + ); + } + + #[tokio::test] + async fn verify_and_redeem_maps_already_spent_to_double_spend() { + // The mint-typed already-spent rejection keeps the spent-specific + // DoubleSpend detail. + let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]) + .to_string(); + let req = charge_req("pop_1700000000", vec![mint_a()], 10); + + let (mock, _c) = MockMintClient::with_swap(SwapResponse::AlreadySpent); + let cred = CashuCredential::new(mock); + + let err = cred + .verify_and_redeem(&presented, &req) + .await + .expect_err("already-spent must map to DoubleSpend"); assert!( matches!(err, ChargeError::DoubleSpend), "expected DoubleSpend, got {err:?}" ); + assert!( + err.to_string().contains("double-spend"), + "the spent-specific detail must survive: {err}" + ); + } + + #[tokio::test] + async fn verify_and_redeem_maps_unresolved_short_id_to_its_own_variant() { + // An unresolvable v2 short keyset id is the dedicated + // ShortKeysetIdUnresolved (slug verification-failed), never a + // malformed credential. + let v1_id = v1_keyset_id(); + let presented = + make_token(mint_a(), pop_unit(), vec![proof_with_keyset(10, 0, v1_id)]).to_string(); + let req = charge_req("pop_1700000000", vec![mint_a()], 10); + + let (mock, _c) = + MockMintClient::new(SwapResponse::Echo, KeysetsResponse::Ok(Vec::new())); + let cred = CashuCredential::new(mock); + + let err = cred + .verify_and_redeem(&presented, &req) + .await + .expect_err("an unresolvable short id must fail"); + assert!( + matches!(err, ChargeError::ShortKeysetIdUnresolved { .. }), + "expected ShortKeysetIdUnresolved, got {err:?}" + ); } #[tokio::test] - async fn verify_and_redeem_maps_swap_output_dleq_to_dleq_invalid() { - // Money-safety: a swap-output DLEQ failure maps to DleqInvalid, NOT - // DoubleSpend — the gateway serves nothing and no proofs are produced. + async fn verify_and_redeem_rejects_userinfo_requirement_mint_as_malformed_request() { + // Operator-config side of the mint-trust rule: a requirement mint with + // userinfo is server-side config → MalformedRequest (400), never a 402. let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]).to_string(); + let req = ChargeRequirement { + amount: 10, + unit: "pop_1700000000".to_string(), + mints: vec!["https://user@mint-a.example.com".to_string()], + payment_id: None, + description: None, + single_use: true, + }; + + let (mock, counters) = MockMintClient::with_swap(SwapResponse::Echo); + let cred = CashuCredential::new(mock); + + let err = cred + .verify_and_redeem(&presented, &req) + .await + .expect_err("a userinfo requirement mint must fail"); + assert!( + matches!(err, ChargeError::MalformedRequest(_)), + "expected MalformedRequest, got {err:?}" + ); + assert_eq!( + counters.swap.load(Ordering::SeqCst), + 0, + "swap must NOT be called on a misconfigured requirement" + ); + } + + #[tokio::test] + async fn verify_and_redeem_maps_keyset_retired_rejection_to_expired() { + // Spec step 9 + Keyset Rotation §: a swap rejected because the keyset + // retired or its final_expiry passed maps to Expired → payment-expired + // 402, distinct from the double-spend verification-failed family. + let presented = + make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]).to_string(); let req = charge_req("pop_1700000000", vec![mint_a()], 10); - let (mock, _c) = MockMintClient::with_swap(SwapResponse::DleqInvalid); + let (mock, _c) = MockMintClient::with_swap(SwapResponse::KeysetRetiredOrExpired); let cred = CashuCredential::new(mock); let err = cred .verify_and_redeem(&presented, &req) .await - .expect_err("swap-output DLEQ failure must map to DleqInvalid"); + .expect_err("a keyset-class rejection must map to Expired"); assert!( - matches!(err, ChargeError::DleqInvalid), - "swap-output DLEQ failure must map to DleqInvalid, got {err:?}" + matches!(err, ChargeError::Expired), + "expected Expired (payment-expired), got {err:?}" + ); + } + + #[tokio::test] + async fn verify_and_redeem_maps_fee_reject_to_fee_too_high_not_double_spend() { + // A fee-bearing keyset is a POLICY reject with an honest detail — + // never collapsed into DoubleSpend. + let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]).to_string(); + let req = charge_req("pop_1700000000", vec![mint_a()], 10); + + let (mock, _c) = MockMintClient::with_swap(SwapResponse::FeeTooHigh); + let cred = CashuCredential::new(mock); + + let err = cred + .verify_and_redeem(&presented, &req) + .await + .expect_err("a fee-policy reject must map to FeeTooHigh"); + match err { + ChargeError::FeeTooHigh { + keyset_id, + input_fee_ppk, + } => { + assert_eq!(keyset_id, "009a1f293253e41e"); + assert_eq!(input_fee_ppk, 100); + } + other => panic!("expected FeeTooHigh (not DoubleSpend), got {other:?}"), + } + } + + #[tokio::test] + async fn verify_and_redeem_succeeds_with_dleq_flag_false_on_swap_output_dleq_failure() { + // Spec step 9 + §security-dleq: a missing/invalid DLEQ on the + // swap-RETURNED signatures is a mint-trust incident, NOT a payment + // failure — the redeem SUCCEEDS, the value is kept, and `Redeemed` + // carries `dleq_ok: false` for the operator surface. + let presented = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]).to_string(); + let req = charge_req("pop_1700000000", vec![mint_a()], 10); + + let (mock, _c) = MockMintClient::with_swap(SwapResponse::DleqInvalid); + let cred = CashuCredential::new(mock); + + let redeemed = cred + .verify_and_redeem(&presented, &req) + .await + .expect("a swap-output DLEQ failure must NOT fail the redeem"); + assert!(!redeemed.dleq_ok, "Redeemed must carry the false verdict"); + assert_eq!(redeemed.amount, 10, "the redeemed value is kept"); + assert!( + redeemed.proofs.fresh_proofs.starts_with("cashuB"), + "fresh proofs are still produced (the value was redeemed)" ); } @@ -1454,7 +1987,10 @@ mod tests { .. } => { assert!(!mint_url.is_empty(), "mint_url must be threaded through"); - assert!(!indeterminate, "Step 1 never sets indeterminate"); + assert!( + !indeterminate, + "a determinate connect failure never sets indeterminate" + ); } other => panic!("expected MintUnreachable, got {other:?}"), } diff --git a/crates/pops-core-verify/src/cdk_mint_client.rs b/crates/pops-core-verify/src/cdk_mint_client.rs index 3a7bcfe..f2f509b 100644 --- a/crates/pops-core-verify/src/cdk_mint_client.rs +++ b/crates/pops-core-verify/src/cdk_mint_client.rs @@ -30,29 +30,55 @@ //! //! The implementation assumes a zero-fee keyset (PoP v1 fixes fees at 0). +use std::time::Duration; + use async_trait::async_trait; use cashu::nuts::nut02::{Id, KeySet, KeySetInfo, KeysetResponse}; use cashu::nuts::nut03::{SwapRequest, SwapResponse}; use cashu::{MintUrl, Proofs}; use cdk::wallet::{HttpClient, MintConnector}; -use crate::mint_client::{MintClient, MintClientError}; +use crate::mint_client::{MintClient, MintClientError, SwapOutcome}; use crate::swap_ceremony::{swap_to_redeem, MintHttp}; +/// Default bound on each mint HTTP call (keysets / keys / swap), 10 s. A mint +/// that never answers must surface as the 503 mint-unavailable path, not hang +/// the request indefinitely. +pub const DEFAULT_MINT_HTTP_TIMEOUT: Duration = Duration::from_secs(10); + /// `cdk`-backed [`MintClient`]. /// -/// Holds no state — every call builds a fresh +/// Holds only the per-call HTTP timeout — every call builds a fresh /// [`cdk::wallet::HttpClient`] for the supplied [`MintUrl`]. Stateless /// design keeps the validator free to talk to many mints without a /// per-mint registration step. -#[derive(Debug, Default, Clone, Copy)] -pub struct CdkMintClient; +#[derive(Debug, Clone, Copy)] +pub struct CdkMintClient { + /// Bound on each individual mint HTTP call. + timeout: Duration, +} + +impl Default for CdkMintClient { + fn default() -> Self { + Self::new() + } +} impl CdkMintClient { - /// Construct a fresh client. Costs nothing; the actual - /// [`HttpClient`] is built per request inside the trait methods. + /// Construct with the [`DEFAULT_MINT_HTTP_TIMEOUT`]. Costs nothing; the + /// actual [`HttpClient`] is built per request inside the trait methods. pub fn new() -> Self { - Self + Self::with_timeout(DEFAULT_MINT_HTTP_TIMEOUT) + } + + /// Construct with an explicit per-call mint HTTP timeout. + pub fn with_timeout(timeout: Duration) -> Self { + Self { timeout } + } + + /// The configured per-call mint HTTP timeout. + pub fn timeout(&self) -> Duration { + self.timeout } /// Build the per-mint [`HttpClient`] used to issue HTTP calls. @@ -61,14 +87,54 @@ impl CdkMintClient { fn http(mint_url: &MintUrl) -> HttpClient { HttpClient::new(mint_url.clone(), None) } + + /// Run `fut` (one mint HTTP call) under the configured timeout. Elapsed → + /// [`MintClientError::Unreachable`]: the same transport-failure arm a + /// connect failure takes, so the existing 503 + consumed-vs-unknown + /// contract applies unchanged (the ceremony re-tags a swap-POST failure as + /// indeterminate; the pre-POST GETs stay determinate). + async fn bounded( + &self, + fut: impl std::future::Future>, + ) -> Result { + match tokio::time::timeout(self.timeout, fut).await { + Ok(result) => result.map_err(map_cdk_err), + Err(_elapsed) => Err(MintClientError::Unreachable(format!( + "mint HTTP call timed out after {:?}", + self.timeout + ))), + } + } } /// Translate a [`cdk::Error`] into the coarse [`MintClientError`] -/// the validator understands. Uses `is_definitive_failure` as the -/// split point (4xx and parse/crypto errors → rejected; -/// 5xx/timeout/transport → unreachable). +/// the validator understands. +/// +/// The keyset-class variants come first: cdk's transport parses the mint's +/// NUT error body (`{"code", "detail"}`) and maps code 12001 +/// (keyset-not-known) → [`cdk::Error::UnknownKeySet`] and 12002 +/// (keyset-inactive) → [`cdk::Error::InactiveKeyset`]; +/// [`cdk::Error::KeysetUnknown`] is cdk's wallet-local twin of 12001. All +/// three mean the keyset has retired or its `final_expiry` has passed — +/// `payment-expired` per `draft-cashu-charge-01` step 9. No registered NUT +/// error code names `final_expiry` itself, so these keyset codes are the +/// entire wire signal; a mint rejecting an expired keyset under any other +/// code stays in the rejected catch-all (the spec's else-branch: +/// `verification-failed`). +/// +/// The already-spent rejection (NUT code 11001 → [`cdk::Error::TokenAlreadySpent`]) +/// keeps its own arm so the spent case carries the honest double-spend detail; +/// the rest split on `is_definitive_failure` (4xx and parse/crypto errors → +/// rejected; 5xx/timeout/transport → unreachable). fn map_cdk_err(e: cdk::Error) -> MintClientError { - if e.is_definitive_failure() { + if matches!(e, cdk::Error::TokenAlreadySpent) { + MintClientError::AlreadySpent(e.to_string()) + } else if matches!( + e, + cdk::Error::UnknownKeySet | cdk::Error::KeysetUnknown(_) | cdk::Error::InactiveKeyset + ) { + MintClientError::KeysetRetiredOrExpired(e.to_string()) + } else if e.is_definitive_failure() { MintClientError::RejectedSwap(e.to_string()) } else { MintClientError::Unreachable(e.to_string()) @@ -84,10 +150,7 @@ impl MintHttp for CdkMintClient { &self, mint_url: &MintUrl, ) -> Result { - Self::http(mint_url) - .get_mint_keysets() - .await - .map_err(map_cdk_err) + self.bounded(Self::http(mint_url).get_mint_keysets()).await } async fn get_keyset_keys( @@ -95,10 +158,8 @@ impl MintHttp for CdkMintClient { mint_url: &MintUrl, keyset_id: Id, ) -> Result { - Self::http(mint_url) - .get_mint_keyset(keyset_id) + self.bounded(Self::http(mint_url).get_mint_keyset(keyset_id)) .await - .map_err(map_cdk_err) } async fn post_swap( @@ -106,10 +167,7 @@ impl MintHttp for CdkMintClient { mint_url: &MintUrl, request: SwapRequest, ) -> Result { - Self::http(mint_url) - .post_swap(request) - .await - .map_err(map_cdk_err) + self.bounded(Self::http(mint_url).post_swap(request)).await } } @@ -126,9 +184,358 @@ impl MintClient for CdkMintClient { &self, mint_url: &MintUrl, proofs: Proofs, - ) -> Result { + ) -> Result { // Delegate to the transport-generic ceremony: the crypto is shared with // the wasm client; this struct supplies only the raw HTTP above. swap_to_redeem(self, mint_url, proofs).await } } + +#[cfg(test)] +mod tests { + //! Mint-HTTP-timeout behavior against REAL local listeners (the whole + //! reqwest/cdk transport stack runs): a mint that accepts but never + //! answers must surface as `Unreachable` within the configured bound + //! (pre-swap GET = determinate), and a mint that answers the GETs but + //! hangs on the swap POST must surface as `UnreachableIndeterminate` + //! (inputs submitted, outcome unknown) — the consumed-vs-unknown contract + //! unchanged under timeout. + + use std::str::FromStr; + use std::time::{Duration, Instant}; + + use axum::routing::{get, post}; + use axum::Router; + use cashu::dhke::hash_to_curve; + use cashu::nuts::nut01::Keys; + use cashu::nuts::nut02::{Id, KeySet, KeySetInfo, KeysetResponse}; + use cashu::nuts::KeysResponse; + use cashu::secret::Secret; + use cashu::{Amount, CurrencyUnit, MintUrl, Proof, PublicKey, SecretKey}; + + use super::{map_cdk_err, CdkMintClient, DEFAULT_MINT_HTTP_TIMEOUT}; + use crate::mint_client::{MintClient, MintClientError}; + + // ---- the cdk::Error → MintClientError classification -------------------- + // + // What cdk 0.16 exposes for the spec's step-9 split: its transport parses + // the mint's NUT error body and surfaces the two REGISTERED keyset codes as + // typed variants — 12001 keyset-not-known → `cdk::Error::UnknownKeySet`, + // 12002 keyset-inactive → `cdk::Error::InactiveKeyset` (plus the + // wallet-local `KeysetUnknown(Id)` twin). There is NO registered NUT error + // code for "final_expiry passed" specifically, so those keyset codes are + // the entire honest wire signal for "keyset retired or expired". + // Already-spent (11001 → `TokenAlreadySpent`) is the one OTHER typed + // rejection, kept distinct so its detail can honestly say double-spend; + // every remaining definitive rejection stays in the `RejectedSwap` + // catch-all. Both map to the spec's else-branch `verification-failed`. + + #[test] + fn keyset_class_cdk_errors_classify_as_retired_or_expired() { + for e in [ + cdk::Error::UnknownKeySet, + cdk::Error::KeysetUnknown(keyset_id()), + cdk::Error::InactiveKeyset, + ] { + let detail = e.to_string(); + match map_cdk_err(e) { + MintClientError::KeysetRetiredOrExpired(msg) => { + assert_eq!(msg, detail, "the cdk detail must be carried through") + } + other => panic!("expected KeysetRetiredOrExpired, got {other:?}"), + } + } + } + + #[test] + fn already_spent_classifies_as_its_own_spent_arm() { + // 11001 is the ONE rejection cdk types as already-spent; it carries the + // honest double-spend detail downstream. + assert!( + matches!( + map_cdk_err(cdk::Error::TokenAlreadySpent), + MintClientError::AlreadySpent(_) + ), + "a typed already-spent rejection must classify as AlreadySpent" + ); + } + + #[test] + fn other_definitive_rejections_stay_rejected_swap() { + for e in [ + cdk::Error::TransactionUnbalanced(10, 8, 0), + cdk::Error::HttpError(Some(400), "Bad Request".into()), + ] { + assert!( + matches!(map_cdk_err(e), MintClientError::RejectedSwap(_)), + "a non-keyset, non-spent definitive rejection must stay RejectedSwap" + ); + } + } + + #[test] + fn ambiguous_cdk_errors_stay_unreachable() { + for e in [ + cdk::Error::Timeout, + cdk::Error::HttpError(Some(503), "Service Unavailable".into()), + cdk::Error::HttpError(None, "connection refused".into()), + ] { + assert!( + matches!(map_cdk_err(e), MintClientError::Unreachable(_)), + "an ambiguous transport condition must stay Unreachable" + ); + } + } + + #[test] + fn default_timeout_is_ten_seconds() { + // The build contract's default bound on each mint HTTP call. + assert_eq!(DEFAULT_MINT_HTTP_TIMEOUT, Duration::from_secs(10)); + assert_eq!(CdkMintClient::new().timeout(), Duration::from_secs(10)); + } + + #[test] + fn with_timeout_is_respected() { + assert_eq!( + CdkMintClient::with_timeout(Duration::from_millis(250)).timeout(), + Duration::from_millis(250) + ); + } + + /// Bind a listener that ACCEPTS connections and then never writes a byte. + async fn hung_listener() -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind hung listener"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let _held_open = socket; + std::future::pending::<()>().await; + }); + } + }); + port + } + + fn mint_url_for(port: u16) -> MintUrl { + MintUrl::from_str(&format!("http://127.0.0.1:{port}")).expect("local mint url") + } + + #[tokio::test] + async fn hung_mint_keysets_times_out_as_determinate_unreachable() { + let port = hung_listener().await; + let client = CdkMintClient::with_timeout(Duration::from_millis(250)); + + let started = Instant::now(); + let err = client + .keysets(&mint_url_for(port)) + .await + .expect_err("a mint that never answers must error"); + let elapsed = started.elapsed(); + + match err { + MintClientError::Unreachable(msg) => { + assert!( + msg.contains("timed out"), + "the error should name the timeout, got: {msg}" + ); + } + other => panic!("a pre-swap GET timeout is DETERMINATE Unreachable, got {other:?}"), + } + assert!( + elapsed < Duration::from_secs(2), + "must give up within the 250ms bound plus margin, took {elapsed:?}" + ); + } + + // ---- the answering-then-hanging mint (swap-POST timeout) ---------- + + /// Deterministic per-amount mint secret key (same construction as the + /// ceremony's mock: amount into the low bytes of a fixed non-zero scalar). + fn mint_secret_for_amount(amount: u64) -> SecretKey { + let mut bytes = [0u8; 32]; + bytes[0] = 0x11; + bytes[31] = amount as u8; + bytes[30] = (amount >> 8) as u8; + SecretKey::from_slice(&bytes).expect("non-zero scalar is a valid secret key") + } + + fn test_unit() -> CurrencyUnit { + CurrencyUnit::Custom("pop_1700000000".to_string()) + } + + fn public_keys() -> Keys { + let map = [1u64, 2, 4, 8] + .into_iter() + .map(|a| { + let pk: PublicKey = mint_secret_for_amount(a).public_key(); + (Amount::from(a), pk) + }) + .collect(); + Keys::new(map) + } + + fn keyset_id() -> Id { + Id::v1_from_keys(&public_keys()) + } + + /// A real axum mint serving NUT-02 keysets + NUT-01 keys, with the supplied + /// NUT-03 swap route. + async fn mint_with_swap_route(swap_route: axum::routing::MethodRouter) -> u16 { + let keysets_body = serde_json::to_string(&KeysetResponse { + keysets: vec![KeySetInfo { + id: keyset_id(), + unit: test_unit(), + active: true, + input_fee_ppk: 0, + final_expiry: None, + }], + }) + .expect("keysets serialize"); + let keys_body = serde_json::to_string(&KeysResponse { + keysets: vec![KeySet { + id: keyset_id(), + unit: test_unit(), + active: Some(true), + keys: public_keys(), + input_fee_ppk: 0, + final_expiry: None, + }], + }) + .expect("keys serialize"); + + let json = |body: String| ([(http::header::CONTENT_TYPE, "application/json")], body); + let app = Router::new() + .route( + "/v1/keysets", + get(move || async move { json(keysets_body.clone()) }), + ) + .route( + "/v1/keys/:id", + get(move || async move { json(keys_body.clone()) }), + ) + .route("/v1/swap", swap_route); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock mint"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve mock mint"); + }); + port + } + + /// The mint whose swap endpoint never responds. + async fn hang_on_swap_mint() -> u16 { + mint_with_swap_route(post(|| async { + std::future::pending::().await + })) + .await + } + + /// A mint whose swap endpoint answers HTTP 400 with the given NUT error + /// body (`{"code": , "detail": "…"}`). + async fn reject_swap_mint(error_body: &'static str) -> u16 { + mint_with_swap_route(post(move || async move { + ( + http::StatusCode::BAD_REQUEST, + [(http::header::CONTENT_TYPE, "application/json")], + error_body, + ) + })) + .await + } + + /// An input proof on the advertised keyset (the C point is arbitrary; the + /// hang happens before any input validation). + fn input_proof(amount: u64, index: u8) -> Proof { + let mut preimage = [0u8; 33]; + preimage[0] = 2; + preimage[1] = index; + let c = hash_to_curve(&preimage).expect("hash_to_curve"); + Proof::new(Amount::from(amount), keyset_id(), Secret::generate(), c) + } + + #[tokio::test(flavor = "multi_thread")] + async fn hung_swap_post_times_out_as_indeterminate() { + // The GETs answer, the swap POST hangs: the inputs were SUBMITTED, so + // the timeout must surface as INDETERMINATE — the operator must + // checkstate, never assume the token is still good. + let port = hang_on_swap_mint().await; + let client = CdkMintClient::with_timeout(Duration::from_millis(500)); + + let started = Instant::now(); + let err = client + .swap( + &mint_url_for(port), + vec![input_proof(8, 0), input_proof(2, 1)], + ) + .await + .expect_err("a hung swap POST must error"); + let elapsed = started.elapsed(); + + assert!( + matches!(err, MintClientError::UnreachableIndeterminate(_)), + "a swap-POST timeout must be re-tagged indeterminate, got {err:?}" + ); + assert!( + elapsed < Duration::from_secs(5), + "must give up within the bound plus margin, took {elapsed:?}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn swap_rejected_with_keyset_inactive_code_classifies_as_expired() { + // The whole wire path: the mint answers the swap POST with NUT error + // code 12002 (keyset-inactive); cdk's transport parses the body into + // `InactiveKeyset`, and the client surfaces the payment-expired arm. + let port = + reject_swap_mint(r#"{"code":12002,"detail":"Keyset is inactive"}"#).await; + let client = CdkMintClient::new(); + + let err = client + .swap( + &mint_url_for(port), + vec![input_proof(8, 0), input_proof(2, 1)], + ) + .await + .expect_err("a 12002-rejected swap must error"); + match err { + MintClientError::KeysetRetiredOrExpired(msg) => { + assert!( + msg.contains("Inactive Keyset"), + "cdk's typed detail must surface, got: {msg}" + ); + } + other => panic!("expected KeysetRetiredOrExpired, got {other:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn swap_rejected_with_already_spent_code_classifies_as_spent() { + // NUT error code 11001 (token already spent) is the spec's + // verification-failed family with the spent-specific detail — it must + // NOT classify as expired, nor collapse into the neutral catch-all. + let port = + reject_swap_mint(r#"{"code":11001,"detail":"Token is already spent"}"#).await; + let client = CdkMintClient::new(); + + let err = client + .swap( + &mint_url_for(port), + vec![input_proof(8, 0), input_proof(2, 1)], + ) + .await + .expect_err("an 11001-rejected swap must error"); + assert!( + matches!(err, MintClientError::AlreadySpent(_)), + "an already-spent rejection must classify as AlreadySpent, got {err:?}" + ); + } +} diff --git a/crates/pops-core-verify/src/challenge.rs b/crates/pops-core-verify/src/challenge.rs index 8d95710..1500695 100644 --- a/crates/pops-core-verify/src/challenge.rs +++ b/crates/pops-core-verify/src/challenge.rs @@ -1,10 +1,12 @@ //! The cashu-coupled `creqA` layer (the cashu-free envelopes live in //! [`crate::envelope`]). [`encode_challenge`] serializes a [`CashuRequirement`] //! into the opaque `creqA…`; [`encode_charge_request`] wraps it in the -//! `draft-cashu-charge-01` request object the 402's `request` auth-param carries, -//! and [`decode_charge_request`] reads that object back (enforcing the -//! mints-superset over the inner creqA); [`decode_token`] parses the `cashuB…` -//! token the client returns on retry. +//! `draft-cashu-charge-01` request object the 402's `request` auth-param carries +//! (`methodDetails.paymentRequest`, the authoritative artifact), and +//! [`decode_charge_request`] reads that object back, enforcing the spec's +//! creqA requirements (`a`/`u`/non-empty-`m` present, top-level +//! `amount`/`currency` matching them, empty transports, no `nut10`); +//! [`decode_token`] parses the `cashuB…` token the client returns on retry. //! //! Transports are left empty (the challenge is in-band) and `nut10` is `None` (a //! bearer charge has no spend lock). This module does NOT enforce the `pop_` @@ -27,7 +29,10 @@ use crate::error::Error; pub struct CashuRequirement { /// Currency unit the proofs must carry (`pop_` for PoP). pub unit: CurrencyUnit, - /// Mints the verifier accepts. Empty means "any mint". + /// Mints the verifier accepts. Empty means "any mint" at the validator and + /// on the bare-creqA `X-Cashu` transport; a `Payment` charge challenge + /// cannot be emitted from an empty set ([`encode_charge_request`] requires + /// a non-empty `m`). pub mints: Vec, /// Exact amount of proofs required. pub amount: Amount, @@ -65,49 +70,69 @@ pub fn encode_challenge(req: &CashuRequirement) -> String { req.to_payment_request().to_string() } -/// The decoded `draft-cashu-charge-01` request object: the spec amount/unit/mints -/// (the authoritative source) plus the opaque `creqA…` they were issued with. +/// The decoded `draft-cashu-charge-01` request object: the payment parameters +/// derived from the authoritative `methodDetails.paymentRequest` (already +/// checked against the top-level `amount`/`currency`), plus the opaque `creqA…` +/// itself. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DecodedChargeRequest { - /// Exact amount required. + /// Exact amount required (the creqA's `a`, equal to the top-level `amount`). pub amount: Amount, - /// Currency unit the proofs must carry. + /// Currency unit the proofs must carry (the creqA's `u`, equal to the + /// top-level `currency`). pub unit: CurrencyUnit, - /// Mints the verifier accepts (a non-empty superset of the creqA's mints). + /// Mints the verifier accepts: the creqA's `m`, always non-empty. pub mints: Vec, /// Optional human-readable description. pub description: Option, /// Optional external correlation id. pub external_id: Option, - /// The opaque `creqA…` carried under `methodDetails.request`. + /// The opaque `creqA…` carried under `methodDetails.paymentRequest`. pub creq_a: String, } /// Build the `draft-cashu-charge-01` request object for the 402's `request` -/// auth-param: the spec amount/currency/description plus `methodDetails` -/// (`{ request: creqA, mints }`). `methodDetails.mints` is the requirement's -/// accepted set, which IS the creqA's set, so the spec's mints-superset holds by -/// construction. Returns the base64url-nopad JCS string (`encode_request_object`). -pub fn encode_charge_request(req: &CashuRequirement) -> String { +/// auth-param: the spec amount/currency/description plus +/// `methodDetails.paymentRequest` (the creqA, carrying the same amount/unit and +/// the accepted mint set). Returns the base64url-nopad JCS string +/// (`encode_request_object`). +/// +/// The spec REQUIRES the emitted creqA to carry `a`, `u`, and a NON-EMPTY `m`; +/// `a`/`u` always exist on a [`CashuRequirement`], so the one emit-side failure +/// is a requirement naming no mints — [`Error::EncodeFailed`]. +pub fn encode_charge_request(req: &CashuRequirement) -> Result { + if req.mints.is_empty() { + return Err(Error::EncodeFailed( + "requirement names no mints; the charge challenge requires a non-empty \ + mint set (`m`) in its payment request" + .to_string(), + )); + } let object = RequestObject { amount: u64::from(req.amount).to_string(), currency: req.unit.to_string(), description: req.description.clone(), external_id: req.payment_id.clone(), method_details: MethodDetails { - request: encode_challenge(req), - mints: req.mints.iter().map(|m| m.to_string()).collect(), + payment_request: encode_challenge(req), }, }; - encode_request_object(&object) + Ok(encode_request_object(&object)) } -/// Decode the 402's `request` auth-param into a [`DecodedChargeRequest`]. +/// Decode the 402's `request` auth-param into a [`DecodedChargeRequest`], +/// enforcing the `draft-cashu-charge-01` rules on the embedded payment request +/// (the authoritative artifact): +/// +/// - the creqA MUST carry `a` (amount), `u` (unit), and a NON-EMPTY `m` (mints); +/// - the top-level `amount`/`currency` MUST equal the creqA's `a`/`u` +/// (amounts compared as integers); +/// - the creqA's transport set MUST be empty (the credential is in-band); +/// - the creqA MUST carry no `nut10` spending condition (bearer-only profile — +/// rejecting here is what lets a future locked profile degrade closed). /// -/// Reads the authoritative amount/currency/mints, then enforces the -/// `draft-cashu-charge-01` rule against the inner `creqA`: `methodDetails.mints` -/// MUST be a NON-EMPTY superset of the creqA's mints. A missing/short superset, -/// an unparseable creqA, or a non-decimal `amount` is a [`Error::DecodeFailed`]. +/// Any violation, an unparseable creqA, or a non-decimal `amount` is a +/// [`Error::DecodeFailed`]. pub fn decode_charge_request(b64: &str) -> Result { let object = decode_request_object(b64)?; @@ -117,42 +142,71 @@ pub fn decode_charge_request(b64: &str) -> Result { let unit = CurrencyUnit::from_str(&object.currency) .map_err(|e| Error::DecodeFailed(format!("request currency {:?}: {e}", object.currency)))?; - let mut mints = Vec::with_capacity(object.method_details.mints.len()); - for m in &object.method_details.mints { - mints.push( - MintUrl::from_str(m) - .map_err(|e| Error::DecodeFailed(format!("methodDetails mint {m:?}: {e}")))?, - ); + let creq = PaymentRequest::from_str(&object.method_details.payment_request) + .map_err(|e| Error::DecodeFailed(format!("methodDetails.paymentRequest creqA: {e}")))?; + + let creq_amount = creq.amount.ok_or_else(|| { + Error::DecodeFailed("payment request omits `a` (amount); the charge method requires it".to_string()) + })?; + let creq_unit = creq.unit.clone().ok_or_else(|| { + Error::DecodeFailed("payment request omits `u` (unit); the charge method requires it".to_string()) + })?; + if creq.mints.is_empty() { + return Err(Error::DecodeFailed( + "payment request omits `m` (mints); the charge method requires a non-empty mint set" + .to_string(), + )); } - // The inner creqA is the ground truth for the accepted mints; methodDetails - // MUST be a non-empty superset of it. - let creq = PaymentRequest::from_str(&object.method_details.request) - .map_err(|e| Error::DecodeFailed(format!("methodDetails.request creqA: {e}")))?; - if mints.is_empty() { + if u64::from(creq_amount) != amount_u64 { + return Err(Error::DecodeFailed(format!( + "request `amount` ({amount_u64}) does not equal the payment request's `a` ({})", + u64::from(creq_amount) + ))); + } + if creq_unit != unit { + return Err(Error::DecodeFailed(format!( + "request `currency` ({unit}) does not equal the payment request's `u` ({creq_unit})" + ))); + } + + if !creq.transports.is_empty() { return Err(Error::DecodeFailed( - "methodDetails.mints is empty (spec requires a non-empty superset of the creqA mints)" + "payment request names a transport; the charge credential is in-band \ + (the transport set must be empty)" .to_string(), )); } - for cm in &creq.mints { - if !mints.contains(cm) { - return Err(Error::DecodeFailed(format!( - "methodDetails.mints is not a superset of the creqA mints (missing {cm})" - ))); - } + if creq.nut10.is_some() { + return Err(Error::DecodeFailed( + "payment request carries a nut10 spending condition; this bearer-only \ + profile requires it absent" + .to_string(), + )); } Ok(DecodedChargeRequest { amount: Amount::from(amount_u64), unit, - mints, + mints: creq.mints, description: object.description, external_id: object.external_id, - creq_a: object.method_details.request, + creq_a: object.method_details.payment_request, }) } +/// Whether a mint URL carries RFC 3986 userinfo (`user@host`): an `@` inside +/// the authority component (between the scheme's `://` and the first `/`, `?`, +/// or `#`). The spec's mint-trust § rejects such a URL outright — token-side as +/// a verification failure, operator-side as a config error. +pub fn mint_url_has_userinfo(url: &str) -> bool { + let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); + let end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + after_scheme[..end].contains('@') +} + /// Decode the `cashuB…` token the client returns on retry. /// /// CONTRACT: cashuB / TokenV4 ONLY. A `cashuA…` (TokenV3) is REJECTED at the @@ -181,7 +235,6 @@ pub fn decode_token(token_str: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::envelope::{decode_request_envelope, encode_request_envelope}; fn sample_requirement() -> CashuRequirement { CashuRequirement { @@ -197,6 +250,26 @@ mod tests { } } + #[test] + fn mint_url_userinfo_detection_is_scoped_to_the_authority() { + for url in [ + "https://user@mint.example.com", + "https://user:pw@mint.example.com:3338/path", + "http://a@b", + ] { + assert!(mint_url_has_userinfo(url), "{url} carries userinfo"); + } + for url in [ + "https://mint.example.com", + "https://mint.example.com:3338/path", + // `@` outside the authority is NOT userinfo. + "https://mint.example.com/path@segment", + "https://mint.example.com/?q=a@b", + ] { + assert!(!mint_url_has_userinfo(url), "{url} carries no userinfo"); + } + } + #[test] fn encode_challenge_has_creqa_prefix() { let req = sample_requirement(); @@ -321,23 +394,11 @@ mod tests { ); } - #[test] - fn creqa_request_envelope_roundtrips() { - // creqA → request-envelope → creqA. - let req = sample_requirement(); - let creq = encode_challenge(&req); - let envelope = encode_request_envelope(&creq); - let unwrapped = decode_request_envelope(&envelope) - .expect("request envelope round-trips"); - assert_eq!(unwrapped, creq); - assert!(unwrapped.starts_with("creqA")); - } - #[test] fn charge_request_roundtrips_through_request_object() { // requirement → spec request object → decoded amount/unit/mints + creqA. let req = sample_requirement(); - let encoded = encode_charge_request(&req); + let encoded = encode_charge_request(&req).expect("encodes"); let decoded = decode_charge_request(&encoded).expect("charge request round-trips"); assert_eq!(decoded.amount, req.amount); assert_eq!(decoded.unit, req.unit); @@ -347,31 +408,30 @@ mod tests { assert!(decoded.creq_a.starts_with("creqA")); } - #[test] - fn decode_charge_request_rejects_mints_subset() { - // methodDetails.mints MUST be a superset of the creqA's mints. Hand-build - // an object whose creqA names two mints but methodDetails names only one. - let req = sample_requirement(); // creqA carries mint1 + mint2 - let creq = encode_challenge(&req); - let object = RequestObject { - amount: u64::from(req.amount).to_string(), - currency: req.unit.to_string(), + /// Hand-build the request object around an arbitrary `PaymentRequest`, so + /// each reject test controls exactly one creqA property. `amount`/`currency` + /// default to the creqA's own values (overridable for the mismatch tests). + fn object_for(creq: &PaymentRequest, amount: &str, currency: &str) -> String { + encode_request_object(&RequestObject { + amount: amount.to_string(), + currency: currency.to_string(), description: None, external_id: None, method_details: MethodDetails { - request: creq, - mints: vec!["https://mint1.example.com".into()], // missing mint2 + payment_request: creq.to_string(), }, - }; - let err = decode_charge_request(&encode_request_object(&object)) - .expect_err("a mints-subset must be rejected"); - assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + }) + } + + /// A fully spec-conformant `PaymentRequest` to mutate per test. + fn conformant_creq() -> PaymentRequest { + sample_requirement().to_payment_request() } #[test] - fn decode_charge_request_rejects_empty_mints() { - // A requirement with no mints yields an empty methodDetails.mints, which - // the spec forbids (must be a non-empty superset). + fn encode_charge_request_rejects_empty_mints() { + // Emit-side enforcement: the spec requires a non-empty `m`, so a + // requirement naming no mints cannot become a challenge. let req = CashuRequirement { unit: CurrencyUnit::Custom("pop_1700000000".to_string()), mints: vec![], @@ -380,8 +440,116 @@ mod tests { description: None, single_use: false, }; - let encoded = encode_charge_request(&req); - let err = decode_charge_request(&encoded).expect_err("empty mints must be rejected"); + let err = encode_charge_request(&req).expect_err("no-mints requirement must not encode"); + assert!(matches!(err, Error::EncodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_creqa_missing_amount() { + let mut creq = conformant_creq(); + creq.amount = None; + let err = decode_charge_request(&object_for(&creq, "42", "pop_1700000000")) + .expect_err("creqA without `a` must be rejected"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_creqa_missing_unit() { + let mut creq = conformant_creq(); + creq.unit = None; + let err = decode_charge_request(&object_for(&creq, "42", "pop_1700000000")) + .expect_err("creqA without `u` must be rejected"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_creqa_empty_mints() { + let mut creq = conformant_creq(); + creq.mints = vec![]; + let err = decode_charge_request(&object_for(&creq, "42", "pop_1700000000")) + .expect_err("creqA without a non-empty `m` must be rejected"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_amount_disagreeing_with_creqa() { + // Top-level `amount` and creqA `a` are compared as integers; any + // disagreement is a tampered/inconsistent challenge. + let creq = conformant_creq(); // a = 42 + let err = decode_charge_request(&object_for(&creq, "43", "pop_1700000000")) + .expect_err("amount ≠ creqA `a` must be rejected"); assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); } + + #[test] + fn decode_charge_request_rejects_currency_disagreeing_with_creqa() { + let creq = conformant_creq(); // u = pop_1700000000 + let err = decode_charge_request(&object_for(&creq, "42", "sat")) + .expect_err("currency ≠ creqA `u` must be rejected"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_nonempty_transports() { + use cashu::nuts::nut18::{Transport, TransportType}; + let mut creq = conformant_creq(); + creq.transports = vec![Transport { + _type: TransportType::HttpPost, + target: "https://elsewhere.example/pay".to_string(), + tags: Vec::new(), + }]; + let err = decode_charge_request(&object_for(&creq, "42", "pop_1700000000")) + .expect_err("a creqA naming a transport must be rejected (in-band only)"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_rejects_nut10_locked_challenge() { + // Bearer-only profile: a nut10-carrying challenge must be rejected by + // the client, so a future locked profile degrades closed. + use cashu::nuts::nut10::Kind; + use cashu::nuts::nut18::Nut10SecretRequest; + let mut creq = conformant_creq(); + creq.nut10 = Some(Nut10SecretRequest::new( + Kind::P2PK, + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + None::>>, + )); + let err = decode_charge_request(&object_for(&creq, "42", "pop_1700000000")) + .expect_err("a nut10-locked challenge must be rejected"); + assert!(matches!(err, Error::DecodeFailed(_)), "got {err:?}"); + } + + #[test] + fn decode_charge_request_accepts_padded_creqa_inside_json() { + // The creqA is an opaque string INSIDE the JSON; its own encoding allows + // padding, so a padded creqA must be accepted (spec Encoding §) even + // though the OUTER header value must be unpadded. Pick a description + // length whose CBOR encodes with `=` padding so both forms are distinct. + let (creq_padded, req) = (0..4usize) + .map(|n| { + let mut r = sample_requirement(); + r.description = Some("x".repeat(n)); + (r.to_payment_request().to_string(), r) + }) + .find(|(s, _)| s.ends_with('=')) + .expect("some description length yields a padded creqA"); + let creq_unpadded = creq_padded.trim_end_matches('=').to_string(); + assert_ne!(creq_padded, creq_unpadded); + for creq_str in [creq_padded, creq_unpadded] { + let object = encode_request_object(&RequestObject { + amount: u64::from(req.amount).to_string(), + currency: req.unit.to_string(), + description: None, + external_id: None, + method_details: MethodDetails { + payment_request: creq_str, + }, + }); + let decoded = + decode_charge_request(&object).expect("padded and unpadded creqA both decode"); + assert_eq!(decoded.amount, req.amount); + assert_eq!(decoded.mints, req.mints); + } + } } diff --git a/crates/pops-core-verify/src/charge.rs b/crates/pops-core-verify/src/charge.rs index dc44589..1effaf7 100644 --- a/crates/pops-core-verify/src/charge.rs +++ b/crates/pops-core-verify/src/charge.rs @@ -22,7 +22,8 @@ pub enum ChargeError { /// unresolved after a 5xx/timeout. Token NOT consumed; the caller MAY retry /// the same token. /// - /// HTTP 503 · `mint-unavailable` · RETRYABLE · SHOULD carry `Retry-After`. + /// HTTP 503 · no problem type (`about:blank` body) · RETRYABLE · SHOULD + /// carry `Retry-After`. #[error("mint unavailable at {mint_url}: {transport_detail}")] MintUnreachable { /// Mint endpoint that could not be reached. @@ -37,12 +38,13 @@ pub enum ChargeError { }, // (B) VERIFICATION — 402 + fresh re-challenge, terminal for this token. - /// Presented value ≠ `amount + expected_swap_fee` (over- or under-funded; the - /// server makes no change). + /// Presented value < `amount + expected_swap_fee` (spec verification step 8). + /// UNDER-funded only: value above the requirement is accepted and retained + /// by the server (the spec's Errors § has no over-payment counterpart). /// - /// HTTP 402 · `amount-mismatch` · terminal. - #[error("amount mismatch: presented {presented}, required {required} (= amount {amount} + swap_fee {expected_swap_fee})")] - AmountMismatch { + /// HTTP 402 · `payment-insufficient` · terminal. + #[error("payment insufficient: presented {presented}, required {required} (= amount {amount} + swap_fee {expected_swap_fee})")] + PaymentInsufficient { /// `amount + expected_swap_fee` the server requires. required: u64, /// Total value the presented token carried. @@ -76,6 +78,32 @@ pub enum ChargeError { allowed: Vec, }, + /// Token's mint URL carries userinfo (`user@host`) — rejected outright per + /// the spec's mint-trust §, before any membership comparison. + /// + /// HTTP 402 · `verification-failed` · terminal. + #[error("mint URL rejected: userinfo (user@host) is not allowed in a mint URL: {url}")] + MintUrlUserinfo { + /// The offending mint URL. + url: String, + }, + + /// The keyset charges a swap fee this server's fee-free profile disallows + /// (`input_fee_ppk` over the supported maximum of 0). A policy-disallowed + /// unit per the spec's Errors §, NOT a double-spend. + /// + /// HTTP 402 · `verification-failed` · terminal. + #[error( + "fee-bearing keyset disallowed by server policy: keyset {keyset_id} charges \ + input_fee_ppk {input_fee_ppk} (this server's profile is fee-free)" + )] + FeeTooHigh { + /// Keyset whose fee exceeded the profile (hex id). + keyset_id: String, + /// The disallowed `input_fee_ppk` the mint publishes for it. + input_fee_ppk: u64, + }, + /// Token's proofs reference more than one mint or unit. /// /// HTTP 402 · `verification-failed` · terminal. @@ -89,14 +117,6 @@ pub enum ChargeError { #[error("token carries a NUT-10 spending condition (locked); bearer proofs only")] LockedToken, - /// A blind signature the swap RETURNED failed NUT-12 DLEQ — invalid or - /// omitted by the mint (a malicious mint reporting outputs it never validly - /// signed, which the server then could not spend). Security-critical. - /// - /// HTTP 402 · `verification-failed` · terminal. - #[error("swap-output DLEQ verification failed")] - DleqInvalid, - /// A proof's short (v1) keyset id does not resolve, or resolves ambiguously, /// against the mint's published keysets. /// @@ -107,12 +127,21 @@ pub enum ChargeError { short_id: String, }, - /// Swap rejected because a proof was already spent (double-spend / replay). + /// Swap rejected because a proof was already spent (double-spend / replay) + /// — only when the mint TYPED the rejection as already-spent (NUT 11001). /// /// HTTP 402 · `verification-failed` · terminal. #[error("double-spend: a proof in the token is already spent")] DoubleSpend, + /// Swap rejected by the mint for a reason it did not type as already-spent + /// or keyset-class (bad signature, unbalanced, …). The neutral detail never + /// claims a double-spend the mint never asserted. + /// + /// HTTP 402 · `verification-failed` · terminal. + #[error("the mint rejected the swap: {0}")] + SwapRejected(String), + /// Swap rejected because the keyset retired or its `final_expiry` (NUT-02) /// passed — distinct from double-spend. For `pop_` this is where the CLTV /// time-lock surfaces (enforced by the mint at swap). @@ -139,19 +168,32 @@ pub enum ChargeError { // (C) MALFORMED — 400 for a bad request frame, 402 for a bad credential. /// The credential could not be decoded/parsed: bad base64url or JSON, a - /// required field absent/wrong-typed, `cashu_token` not a Cashu token, or a - /// `cashuA…` (TokenV3 — this intent is cashuB/TokenV4 only). + /// required field absent/wrong-typed, `payload.token` not a Cashu token, or + /// a `cashuA…` (TokenV3 — this intent is cashuB/TokenV4 only). /// /// HTTP 402 · `malformed-credential` (a bad credential is 402, not 400 — it /// is still a re-makeable attempt). #[error("malformed credential: {0}")] MalformedCredential(String), - /// The credential names an unsupported method, or the request bore more than - /// one `Authorization: Payment` credential. + /// The credential names a payment method this server does not support + /// (anything ≠ `"cashu"`). Framework problem type `method-unsupported`. + /// + /// HTTP 400 · `method-unsupported` (the framework's status table; not a + /// payment-verification failure, so no 402 re-challenge). + #[error("unsupported payment method {method:?} (this server accepts \"cashu\")")] + MethodUnsupported { + /// The method string the credential carried. + method: String, + }, + + /// The REQUEST frame is malformed — more than one `Authorization: Payment` + /// credential, or a server-side requirement that cannot be parsed. Follows + /// the framework's status handling (400), NOT a 402: there is no problem + /// type registered for it, so the body's `type` is `about:blank`. /// - /// HTTP 400 · framework status (not a well-formed payment attempt). - #[error("unsupported method or malformed request: {0}")] + /// HTTP 400 · no registered slug. + #[error("malformed request: {0}")] MalformedRequest(String), /// Token carries more proofs than the configured maximum (DoS guard), @@ -179,9 +221,9 @@ pub struct RedeemedProofs { /// keyset — a serialized `cashuB…` string the operator/wallet re-parses to /// spend. pub fresh_proofs: String, - /// Net value received = the requested `amount` exactly (the mint deducted the - /// swap fee). The caller asserts `amount == challenge.amount` to confirm - /// settlement. + /// Net value received: at least the requested `amount` (the mint deducted + /// the swap fee; value presented above the requirement is retained, so this + /// MAY exceed `challenge.amount`). pub amount: u64, /// Unit of the redeemed value (echoes the challenge `currency`). pub unit: String, @@ -189,8 +231,9 @@ pub struct RedeemedProofs { /// keyset, which MAY differ from the input proofs' keyset. For spending /// without re-fetching keysets, and for audit. pub active_keyset_id: String, - /// SHA-256 (lowercase hex) of the EXACT presented `cashu_token` — the receipt - /// `reference`: a stable, shareable settlement id that exposes no secret. + /// SHA-256 (lowercase hex) of the EXACT presented `payload.token` string — + /// the receipt `reference`: a stable, shareable settlement id that exposes + /// no secret. pub token_hash: String, } @@ -199,14 +242,8 @@ mod tests { use super::*; #[test] - fn dleq_invalid_displays() { - let err = ChargeError::DleqInvalid; - assert_eq!(err.to_string(), "swap-output DLEQ verification failed"); - } - - #[test] - fn amount_mismatch_display() { - let err = ChargeError::AmountMismatch { + fn payment_insufficient_display() { + let err = ChargeError::PaymentInsufficient { required: 1100, presented: 1000, amount: 1000, @@ -214,7 +251,7 @@ mod tests { }; assert_eq!( err.to_string(), - "amount mismatch: presented 1000, required 1100 (= amount 1000 + swap_fee 100)" + "payment insufficient: presented 1000, required 1100 (= amount 1000 + swap_fee 100)" ); } diff --git a/crates/pops-core-verify/src/envelope.rs b/crates/pops-core-verify/src/envelope.rs index 8704ce5..ee80010 100644 --- a/crates/pops-core-verify/src/envelope.rs +++ b/crates/pops-core-verify/src/envelope.rs @@ -13,21 +13,21 @@ //! "intent": "", //! "request": "" //! }, -//! "payload": { "cashu_token": "cashuB..." } +//! "payload": { "token": "cashuB..." } //! } //! ``` //! //! The credential blob is JCS-canonical (RFC 8785); the inner `request` echo and -//! `cashu_token` strings are opaque (not re-canonicalized). The echoed +//! `token` strings are opaque (not re-canonicalized). The echoed //! `challenge` carries optional `digest`/`opaque`/`expires` (present iff the 402 //! carried them) and an optional top-level `source`; the parser tolerates these //! and any further unknown fields. //! //! Request object (`request="…"`): a base64url-nopad encoding of the JCS-canonical //! bytes of the `draft-cashu-charge-01` request schema — -//! `{ amount, currency, description?, externalId?, methodDetails: { request, mints } }` -//! — where `methodDetails.request` is the opaque `creqA…` and `methodDetails.mints` -//! is the non-empty superset of the `creqA`'s accepted mints. +//! `{ amount, currency, description?, externalId?, methodDetails: { paymentRequest } }` +//! — where `methodDetails.paymentRequest` is the opaque `creqA…`, the +//! authoritative source of all payment parameters (amount, unit, accepted mints). use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; @@ -70,14 +70,20 @@ pub struct EchoedChallenge { /// carried it. The verifier rejects an echo whose `expires` is in the past. #[serde(default, skip_serializing_if = "Option::is_none")] pub expires: Option, + /// Echo of the optional human-readable description, present iff the 402 + /// carried it. Display-only: the framework excludes it from the challenge + /// binding, so it is echoed but never authenticated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Cashu-method `payload`. This struct just carries the token string out of the /// JSON; its structural validation is the validator's job. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CashuPayload { - /// The `cashuB…` token, forwarded as-is to [`crate::challenge::decode_token`]. - pub cashu_token: String, + /// The `cashuB…` token (the spec's `payload.token`), forwarded as-is to + /// [`crate::challenge::decode_token`]. + pub token: String, } /// Full `draft-cashu-charge-01` credentials object. The top-level `source` is @@ -87,7 +93,7 @@ pub struct CashuPayload { pub struct PaymentCredentials { /// Echo of the WWW-Authenticate auth-params. pub challenge: EchoedChallenge, - /// Method-specific payload (cashu = `{ "cashu_token": "..." }`). + /// Method-specific payload (cashu = `{ "token": "..." }`). pub payload: CashuPayload, /// Optional client-supplied source identifier; absent on the wire when /// `None`. @@ -127,7 +133,7 @@ pub enum AuthParseError { /// Parse an `Authorization: Payment ` header into the /// structured credentials. `WrongMethod` is surfaced here for a non-`cashu` -/// method; the caller still decodes `payload.cashu_token` via +/// method; the caller still decodes `payload.token` via /// [`crate::challenge::decode_token`]. pub fn parse_payment_authorization( header_value: &str, @@ -182,8 +188,12 @@ pub fn encode_payment_credentials(credentials: &PaymentCredentials) -> String { } /// The `WWW-Authenticate: Payment …` auth-params a client receives on a 402. -/// Cashu-free: `request` stays the raw base64url envelope (the client unwraps it -/// via [`decode_request_envelope`]). +/// Cashu-free: `request` stays the raw base64url request object (the client +/// decodes it via [`decode_request_object`] or the cashu-coupled +/// [`crate::challenge::decode_charge_request`]). The optional params +/// (`expires`/`digest`/`opaque`/`description`) are captured when present +/// because a client MUST echo each issued param unchanged in its credential +/// (spec Credential Schema + Challenge Binding). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentParams { /// Server-issued challenge id. @@ -196,6 +206,18 @@ pub struct PaymentParams { pub intent: String, /// The base64url-nopad request envelope (wraps the `creqA…`). pub request: String, + /// Optional RFC 3339 challenge expiry, present iff the 402 carried it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option, + /// Optional request-body digest, present iff the 402 carried it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + /// Optional server correlation opaque, present iff the 402 carried it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opaque: Option, + /// Optional human-readable description, present iff the 402 carried it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Strip exactly ONE matched pair of surrounding double-quotes, or `None` (a @@ -233,6 +255,10 @@ pub fn parse_payment_params(header_value: &str) -> Result Result Result method = Some(val.to_string()), "intent" => intent = Some(val.to_string()), "request" => request = Some(val.to_string()), + "expires" => expires = Some(val.to_string()), + "digest" => digest = Some(val.to_string()), + "opaque" => opaque = Some(val.to_string()), + "description" => description = Some(val.to_string()), _ => {} } } @@ -276,17 +316,22 @@ pub fn parse_payment_params(header_value: &str) -> Result, + /// The opaque Cashu payment-request string (`creqA…`). + #[serde(rename = "paymentRequest")] + pub payment_request: String, } /// The `draft-cashu-charge-01` `request` auth-param object. `amount` is the @@ -304,7 +349,7 @@ pub struct RequestObject { /// Optional external correlation id; absent on the wire when `None`. #[serde(default, rename = "externalId", skip_serializing_if = "Option::is_none")] pub external_id: Option, - /// Cashu method-specific details (the creqA + accepted mints). + /// Cashu method-specific details (the authoritative `paymentRequest`). #[serde(rename = "methodDetails")] pub method_details: MethodDetails, } @@ -318,9 +363,12 @@ pub fn encode_request_object(object: &RequestObject) -> String { } /// Decode the `request="…"` auth-param into a [`RequestObject`]. Errors on bad -/// base64 / JSON or a missing/wrong-shaped field (the cashu-semantic -/// mints-superset check is the caller's, via the cashu-coupled -/// [`crate::challenge`] layer). +/// base64 / JSON or a missing/wrong-shaped field. The base64url MUST be +/// unpadded — a padded value is malformed under the framework's grammar +/// (the artifacts INSIDE the JSON, `creqA…`/`cashuB…`, carry their own +/// padding-tolerant encodings). The cashu-semantic checks (creqA `a`/`u`/`m` +/// presence + consistency) are the caller's, via the cashu-coupled +/// [`crate::challenge`] layer. pub fn decode_request_object(b64: &str) -> Result { let bytes = URL_SAFE_NO_PAD .decode(b64.trim()) @@ -329,38 +377,6 @@ pub fn decode_request_object(b64: &str) -> Result { .map_err(|e| Error::DecodeFailed(format!("request object json: {e}"))) } -/// The JSON object inside the `pops-gateway` binary's `request` auth-param, -/// holding the `creqA…` under a single `cashu_request` field. The gateway -/// (`gateway.rs`) is a separate call-site with its own flat codec; the -/// `draft-cashu-charge-01` request codec is [`RequestObject`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RequestEnvelope { - cashu_request: String, -} - -/// Wrap a `creqA…` in the gateway's flat `request` envelope, base64url-nopad- -/// encoded. Cannot fail. (The spec request codec is [`encode_request_object`].) -pub fn encode_request_envelope(creq_a: &str) -> String { - let envelope = RequestEnvelope { - cashu_request: creq_a.to_string(), - }; - let json = serde_json::to_string(&envelope) - .expect("RequestEnvelope always serializes"); - URL_SAFE_NO_PAD.encode(json.as_bytes()) -} - -/// Unwrap the gateway's flat `request` envelope, returning the inner -/// `cashu_request` (`creqA…`). Errors on bad base64 / JSON or a missing -/// `cashu_request`. (The spec request codec is [`decode_request_object`].) -pub fn decode_request_envelope(b64: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(b64.trim()) - .map_err(|e| Error::DecodeFailed(format!("request envelope base64: {e}")))?; - let envelope: RequestEnvelope = serde_json::from_slice(&bytes) - .map_err(|e| Error::DecodeFailed(format!("request envelope json: {e}")))?; - Ok(envelope.cashu_request) -} - #[cfg(test)] mod tests { use super::*; @@ -376,9 +392,10 @@ mod tests { digest: None, opaque: None, expires: None, + description: None, }, payload: CashuPayload { - cashu_token: token.into(), + token: token.into(), }, source: None, } @@ -397,7 +414,7 @@ mod tests { assert_eq!(parsed.challenge.realm, "pops-core-verify"); assert_eq!(parsed.challenge.method, "cashu"); assert_eq!(parsed.challenge.intent, "charge"); - assert_eq!(parsed.payload.cashu_token, "cashuBabc"); + assert_eq!(parsed.payload.token, "cashuBabc"); } #[test] @@ -488,7 +505,7 @@ mod tests { #[test] fn json_missing_challenge_returns_json_parse() { - let blob = URL_SAFE_NO_PAD.encode(br#"{"payload":{"cashu_token":"x"}}"#); + let blob = URL_SAFE_NO_PAD.encode(br#"{"payload":{"token":"x"}}"#); let header = format!("Payment {blob}"); let err = parse_payment_authorization(&header).expect_err("no challenge"); assert!( @@ -511,12 +528,28 @@ mod tests { } #[test] - fn payload_missing_cashu_token_returns_json_parse() { + fn payload_missing_token_returns_json_parse() { let blob = URL_SAFE_NO_PAD.encode( br#"{"challenge":{"id":"a","realm":"b","method":"cashu","intent":"charge","request":"r"},"payload":{}}"#, ); let header = format!("Payment {blob}"); - let err = parse_payment_authorization(&header).expect_err("no cashu_token"); + let err = parse_payment_authorization(&header).expect_err("no token"); + assert!( + matches!(err, AuthParseError::JsonParse(_)), + "expected JsonParse, got {err:?}" + ); + } + + #[test] + fn payload_with_only_legacy_cashu_token_field_returns_json_parse() { + // The spec names the payload field `token`; the pre-spec `cashu_token` + // spelling no longer satisfies the required field. + let blob = URL_SAFE_NO_PAD.encode( + br#"{"challenge":{"id":"a","realm":"b","method":"cashu","intent":"charge","request":"r"},"payload":{"cashu_token":"cashuBabc"}}"#, + ); + let header = format!("Payment {blob}"); + let err = parse_payment_authorization(&header) + .expect_err("legacy cashu_token field must not satisfy payload.token"); assert!( matches!(err, AuthParseError::JsonParse(_)), "expected JsonParse, got {err:?}" @@ -548,14 +581,14 @@ mod tests { }, "source": "did:example:123", "payload": { - "cashu_token": "cashuBxyz", + "token": "cashuBxyz", "extra": "ignored" } }); let blob = URL_SAFE_NO_PAD.encode(json.to_string().as_bytes()); let header = format!("Payment {blob}"); let parsed = parse_payment_authorization(&header).expect("optional fields ok"); - assert_eq!(parsed.payload.cashu_token, "cashuBxyz"); + assert_eq!(parsed.payload.token, "cashuBxyz"); } #[test] @@ -566,60 +599,21 @@ mod tests { parse_payment_authorization(&header).expect("extra whitespace tolerated"); } - #[test] - fn request_envelope_roundtrips() { - let creq = "creqAsomepayload"; - let envelope = encode_request_envelope(creq); - let unwrapped = decode_request_envelope(&envelope) - .expect("request envelope round-trips"); - assert_eq!(unwrapped, creq); - } - - #[test] - fn request_envelope_is_base64url_nopad() { - let envelope = encode_request_envelope("creqAdummy"); - // base64url-nopad excludes '+', '/', '='. - for c in envelope.chars() { - assert!( - c.is_ascii_alphanumeric() || c == '-' || c == '_', - "envelope contains non-base64url char {c:?}: {envelope}" - ); - } - } - - #[test] - fn decode_request_envelope_rejects_bad_base64() { - let err = decode_request_envelope("!!!notbase64!!!") - .expect_err("bad base64"); - assert!(matches!(err, Error::DecodeFailed(_))); - } - - #[test] - fn decode_request_envelope_rejects_missing_field() { - // Valid base64 + valid JSON, but no `cashu_request`. - let bad = URL_SAFE_NO_PAD.encode(br#"{"other":"x"}"#); - let err = decode_request_envelope(&bad) - .expect_err("missing cashu_request"); - assert!(matches!(err, Error::DecodeFailed(_))); - } - #[test] fn parse_payment_params_extracts_all_five_fields() { - let creq_envelope = encode_request_envelope("creqAsomepayload"); + let request_object = encode_request_object(&sample_request_object()); let header = format!( - r#"Payment id="ch-1", realm="pops-core-verify", method="cashu", intent="charge", request="{creq_envelope}""# + r#"Payment id="ch-1", realm="pops-core-verify", method="cashu", intent="charge", request="{request_object}""# ); let params = parse_payment_params(&header).expect("parses Payment params"); assert_eq!(params.id, "ch-1"); assert_eq!(params.realm, "pops-core-verify"); assert_eq!(params.method, "cashu"); assert_eq!(params.intent, "charge"); - assert_eq!(params.request, creq_envelope); - // And the request unwraps back to the creqA. - assert_eq!( - decode_request_envelope(¶ms.request).expect("unwrap"), - "creqAsomepayload" - ); + assert_eq!(params.request, request_object); + // And the request decodes back to the spec request object. + let decoded = decode_request_object(¶ms.request).expect("decodes"); + assert_eq!(decoded, sample_request_object()); } #[test] @@ -707,8 +701,7 @@ mod tests { description: Some("read access".into()), external_id: Some("inv-7".into()), method_details: MethodDetails { - request: "creqAsomepayload".into(), - mints: vec!["https://mint.example".into()], + payment_request: "creqAsomepayload".into(), }, } } @@ -734,15 +727,16 @@ mod tests { #[test] fn request_object_bytes_are_jcs_canonical() { - // JCS sorts object keys lexicographically; the decoded base64 is exactly - // those canonical bytes, key-sorted at both levels (amount < currency < - // description < externalId < methodDetails; request < mints). + // Expected bytes hand-derived from the spec (Request Schema + Method + // Details + Encoding): JCS sorts keys lexicographically (amount < + // currency < description < externalId < methodDetails) and + // methodDetails carries exactly ONE field, `paymentRequest`. let encoded = encode_request_object(&sample_request_object()); let bytes = URL_SAFE_NO_PAD.decode(&encoded).expect("decodes"); let json = std::str::from_utf8(&bytes).expect("utf8"); assert_eq!( json, - r#"{"amount":"100","currency":"pop_1782668279","description":"read access","externalId":"inv-7","methodDetails":{"mints":["https://mint.example"],"request":"creqAsomepayload"}}"# + r#"{"amount":"100","currency":"pop_1782668279","description":"read access","externalId":"inv-7","methodDetails":{"paymentRequest":"creqAsomepayload"}}"# ); } @@ -754,8 +748,7 @@ mod tests { description: None, external_id: None, method_details: MethodDetails { - request: "creqAx".into(), - mints: vec!["https://m.example".into()], + payment_request: "creqAx".into(), }, }; let bytes = URL_SAFE_NO_PAD @@ -764,7 +757,20 @@ mod tests { let json = std::str::from_utf8(&bytes).expect("utf8"); assert_eq!( json, - r#"{"amount":"1","currency":"pop_1700000000","methodDetails":{"mints":["https://m.example"],"request":"creqAx"}}"# + r#"{"amount":"1","currency":"pop_1700000000","methodDetails":{"paymentRequest":"creqAx"}}"# + ); + } + + #[test] + fn request_object_never_carries_a_mints_field() { + // The deleted `methodDetails.mints` must not reappear on the wire; the + // mint set lives only inside the creqA. + let encoded = encode_request_object(&sample_request_object()); + let bytes = URL_SAFE_NO_PAD.decode(&encoded).expect("decodes"); + let json = std::str::from_utf8(&bytes).expect("utf8"); + assert!( + !json.contains("\"mints\""), + "emitted request object must not carry methodDetails.mints: {json}" ); } @@ -775,26 +781,83 @@ mod tests { assert!(matches!(err, Error::DecodeFailed(_))); } + #[test] + fn decode_request_object_rejects_legacy_request_field_name() { + // The pre-spec wire named the creqA `methodDetails.request` (with a + // sibling `mints`); only `paymentRequest` parses now. + let bad = URL_SAFE_NO_PAD.encode( + br#"{"amount":"1","currency":"pop_1","methodDetails":{"mints":["https://m.example"],"request":"creqAx"}}"#, + ); + let err = decode_request_object(&bad).expect_err("legacy field names must not parse"); + assert!(matches!(err, Error::DecodeFailed(_))); + } + #[test] fn decode_request_object_rejects_bad_base64() { let err = decode_request_object("!!!notbase64!!!").expect_err("bad base64"); assert!(matches!(err, Error::DecodeFailed(_))); } + #[test] + fn decode_request_object_rejects_padded_base64() { + // Header values are base64url WITHOUT padding (spec Encoding §); a padded + // value is malformed under the framework's grammar. Pick a description + // length whose unpadded encoding is not a 4-multiple, so CANONICAL `=` + // padding exists to append (the reject is then about padding, not length). + let (unpadded, pad) = (0..4usize) + .map(|n| { + let mut obj = sample_request_object(); + obj.description = Some("x".repeat(n)); + let enc = encode_request_object(&obj); + let pad = (4 - enc.len() % 4) % 4; + (enc, pad) + }) + .find(|(_, pad)| *pad > 0) + .expect("some description length yields a non-4-multiple encoding"); + decode_request_object(&unpadded).expect("unpadded form decodes"); + let padded = format!("{unpadded}{}", "=".repeat(pad)); + let err = decode_request_object(&padded).expect_err("padded request object must reject"); + assert!(matches!(err, Error::DecodeFailed(_))); + } + // ---- credential blob: JCS + optional echo fields ------------------------- #[test] fn credential_blob_bytes_are_jcs_canonical() { // The credential blob is JCS-canonical (challenge < payload < source; // within challenge: id < intent < method < realm < request, plus the - // optional digest/opaque/expires when present). + // optional digest/opaque/expires when present). The payload field is + // the spec's `token` (Credential Schema). let creds = make_credentials("cashu", "cashuBabc"); let blob = encode_payment_credentials(&creds); let bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decodes"); let json = std::str::from_utf8(&bytes).expect("utf8"); assert_eq!( json, - r#"{"challenge":{"id":"challenge-1","intent":"charge","method":"cashu","realm":"pops-core-verify","request":"ZHVtbXkK"},"payload":{"cashu_token":"cashuBabc"}}"# + r#"{"challenge":{"id":"challenge-1","intent":"charge","method":"cashu","realm":"pops-core-verify","request":"ZHVtbXkK"},"payload":{"token":"cashuBabc"}}"# + ); + } + + #[test] + fn padded_credential_blob_is_rejected() { + // The Authorization blob is a header value: base64url WITHOUT padding + // (spec Encoding §). Canonical `=` padding makes it malformed. + let (unpadded, pad) = (0..4usize) + .map(|n| { + let creds = make_credentials("cashu", &format!("cashuB{}", "a".repeat(n))); + let blob = encode_payment_credentials(&creds); + let pad = (4 - blob.len() % 4) % 4; + (blob, pad) + }) + .find(|(_, pad)| *pad > 0) + .expect("some token length yields a non-4-multiple blob"); + parse_payment_authorization(&format!("Payment {unpadded}")) + .expect("unpadded blob parses"); + let err = parse_payment_authorization(&format!("Payment {unpadded}{}", "=".repeat(pad))) + .expect_err("padded credential blob must reject"); + assert!( + matches!(err, AuthParseError::Base64Decode(_)), + "expected Base64Decode, got {err:?}" ); } @@ -810,9 +873,10 @@ mod tests { digest: Some("d".into()), opaque: Some("o".into()), expires: Some("2999-01-01T00:00:00Z".into()), + description: Some("a memo".into()), }, payload: CashuPayload { - cashu_token: "cashuBz".into(), + token: "cashuBz".into(), }, source: Some("did:example:1".into()), }; @@ -824,6 +888,32 @@ mod tests { parsed.challenge.expires.as_deref(), Some("2999-01-01T00:00:00Z") ); + assert_eq!(parsed.challenge.description.as_deref(), Some("a memo")); assert_eq!(parsed.source.as_deref(), Some("did:example:1")); } + + #[test] + fn parse_payment_params_captures_optional_params() { + // A client MUST echo every issued param, so the parser captures the + // optionals (expires/digest/opaque/description) when present. + let header = r#"Payment id="x", realm="r", method="cashu", intent="charge", request="e", expires="2026-03-15T12:05:00Z", digest="sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", opaque="b3BhcXVl", description="weather report""#; + let params = parse_payment_params(header).expect("parses with optionals"); + assert_eq!(params.expires.as_deref(), Some("2026-03-15T12:05:00Z")); + assert_eq!( + params.digest.as_deref(), + Some("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:") + ); + assert_eq!(params.opaque.as_deref(), Some("b3BhcXVl")); + assert_eq!(params.description.as_deref(), Some("weather report")); + } + + #[test] + fn parse_payment_params_leaves_absent_optionals_none() { + let header = r#"Payment id="x", realm="r", method="cashu", intent="charge", request="e""#; + let params = parse_payment_params(header).expect("parses without optionals"); + assert_eq!(params.expires, None); + assert_eq!(params.digest, None); + assert_eq!(params.opaque, None); + assert_eq!(params.description, None); + } } diff --git a/crates/pops-core-verify/src/http_status.rs b/crates/pops-core-verify/src/http_status.rs index 669f726..9e153fb 100644 --- a/crates/pops-core-verify/src/http_status.rs +++ b/crates/pops-core-verify/src/http_status.rs @@ -1,32 +1,29 @@ -//! The load-bearing `ChargeError` → HTTP status mapping, single-sourced. +//! Native adapter lifting the single-sourced [`problem_mapping`] status into +//! `http::StatusCode`. //! -//! Both the axum [`middleware`](crate::middleware) and out-of-crate hosts (e.g. -//! `pops-gateway`) derive their HTTP status from this one function, so the -//! 503/400/402 trichotomy the charge contract pins cannot drift between them. -//! The response *body* still differs per host (RFC-9457 `problem+json` in the -//! middleware, a flat advisory JSON in the gateway) — only the status decision -//! is shared. +//! The mapping itself (status + problem type + slug) lives in +//! [`crate::problem`], feature-independent, so every host — the axum +//! middlewares, `pops-gateway`, the wasm surface — derives from ONE table and +//! the 503/400/402 trichotomy cannot drift between them. -use http::StatusCode; use crate::charge::ChargeError; +use crate::problem::problem_mapping; +use http::StatusCode; -/// Map a [`ChargeError`] to its HTTP status per `draft-cashu-charge-01` §Errors. +/// Map a [`ChargeError`] to its HTTP status per `draft-cashu-charge-01` §Errors +/// — the [`problem_mapping`] status as a typed `StatusCode`. /// /// THE load-bearing trichotomy (see the [`ChargeError`] banner): a transport /// failure ([`ChargeError::MintUnreachable`]) is `503` — the token is NOT /// consumed and the caller MAY retry it, so it MUST NEVER collapse into a `402` /// ("your payment was wrong, re-pay"). A malformed *request frame* -/// ([`ChargeError::MalformedRequest`]) is `400`. EVERY other variant — +/// ([`ChargeError::MalformedRequest`]) and an unsupported method +/// ([`ChargeError::MethodUnsupported`]) are `400`. EVERY other variant — /// verification failures and a malformed *credential* — is `402` with a fresh /// re-challenge. pub fn charge_error_status(e: &ChargeError) -> StatusCode { - match e { - ChargeError::MintUnreachable { .. } => StatusCode::SERVICE_UNAVAILABLE, - ChargeError::MalformedRequest(_) => StatusCode::BAD_REQUEST, - // `#[non_exhaustive]`: an unmodelled future variant degrades to the - // conservative 402 (verification-failed), never a 503/400. - _ => StatusCode::PAYMENT_REQUIRED, - } + StatusCode::from_u16(problem_mapping(e).status) + .expect("problem_mapping emits only valid HTTP statuses") } #[cfg(test)] @@ -49,6 +46,14 @@ mod tests { assert_eq!(charge_error_status(&e), StatusCode::BAD_REQUEST); } + #[test] + fn method_unsupported_is_400() { + let e = ChargeError::MethodUnsupported { + method: "tempo".into(), + }; + assert_eq!(charge_error_status(&e), StatusCode::BAD_REQUEST); + } + #[test] fn verification_and_malformed_credential_are_402() { for e in [ @@ -59,12 +64,16 @@ mod tests { ChargeError::DoubleSpend, ChargeError::MalformedCredential("bad base64".into()), ChargeError::TooManyProofs { got: 100, max: 64 }, - ChargeError::AmountMismatch { - required: 1, - presented: 2, - amount: 1, + ChargeError::PaymentInsufficient { + required: 2, + presented: 1, + amount: 2, expected_swap_fee: 0, }, + ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }, ] { assert_eq!( charge_error_status(&e), diff --git a/crates/pops-core-verify/src/lib.rs b/crates/pops-core-verify/src/lib.rs index f1f4608..f83fd8c 100644 --- a/crates/pops-core-verify/src/lib.rs +++ b/crates/pops-core-verify/src/lib.rs @@ -21,6 +21,11 @@ #![warn(missing_docs)] +// The framework's stateless challenge binding: the HMAC-SHA256 challenge `id` +// over the seven fixed slots + echo verification. hmac/sha2/base64 only — +// always compiled, wasm-clean; issuance/freshness helpers are native-gated +// (they need the clock). +pub mod binding; pub mod cashu_credential; pub mod challenge; // The committed charge contract (`ChargeError` / `RedeemedProofs`) and the @@ -35,6 +40,10 @@ pub mod unit; // compiled: the crypto is shared by the native cdk client and the wasm // injected-fetch client, and `cashu` itself compiles to wasm. pub mod swap_ceremony; +// The single-sourced ChargeError → RFC-9457 problem mapping (absolute type +// URI, slug, status, shared body). Plain serde — always compiled, wasm-clean — +// so every host (middleware, xcashu, gateway, wasm) emits from ONE table. +pub mod problem; // The NUT-24 `X-Cashu` transport codec. Cashu-free string handling over the // shared verify core, so it is always compiled and wasm-compiles like `envelope`. pub mod xcashu; diff --git a/crates/pops-core-verify/src/middleware.rs b/crates/pops-core-verify/src/middleware.rs index e90d770..3e6645a 100644 --- a/crates/pops-core-verify/src/middleware.rs +++ b/crates/pops-core-verify/src/middleware.rs @@ -5,19 +5,25 @@ //! Flow: a request without `Authorization: Payment ` gets a 402 carrying a //! `WWW-Authenticate: Payment` challenge (whose `request="…"` is the //! `draft-cashu-charge-01` request object built from the -//! [`CashuRequirement`]). The client retries -//! with the credentials blob; the middleware verify+redeems through the generic -//! [`Redeemer`] seam and, on success, attaches the -//! [`Redeemed`] to `request.extensions_mut()` and -//! emits a `Payment-Receipt`. +//! [`CashuRequirement`]). Every challenge is stateless-bound per the framework: +//! its `id` is the HMAC-SHA256 over the issued auth-params under the state's +//! [`BindingKey`], and it carries an RFC 3339 `expires` (`now + challenge_ttl`). +//! The client retries with the credentials blob; the middleware authenticates +//! the echoed challenge (recompute the id-HMAC; check `expires` freshness), +//! then verify+redeems through the generic [`Redeemer`] seam and, on success, +//! attaches the [`Redeemed`] to `request.extensions_mut()` and emits a +//! `Payment-Receipt`. A tampered/inconsistent echo → `invalid-challenge` 402; +//! a stale `expires` → `payment-expired` 402. //! -//! Status mapping: a verification or malformed-credential failure → 402 + a fresh -//! re-challenge; a transport failure to reach the mint → 503; a malformed request -//! frame → 400. Every error body is RFC-9457 `application/problem+json` carrying -//! the `draft-cashu-charge-01` problem-type. Every 402 carries -//! `Cache-Control: no-store`; the 200 carries `Cache-Control: private`. +//! Status mapping (the single-sourced [`crate::problem`] map): a verification +//! or malformed-credential failure → 402 + a fresh re-challenge; a transport +//! failure to reach the mint → 503; a malformed request frame or a non-"cashu" +//! method → 400. Every error body is RFC-9457 `application/problem+json` +//! carrying the absolute `draft-cashu-charge-01` problem-type URI. Every 402 +//! carries `Cache-Control: no-store`; the 200 carries `Cache-Control: private`. use std::sync::Arc; +use std::time::Duration; use axum::{ extract::{Request, State}, @@ -26,16 +32,19 @@ use axum::{ }; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; -use chrono::{DateTime, Utc}; +use chrono::Utc; use http::{header::HeaderValue, StatusCode}; use crate::charge::ChargeError; use serde::Serialize; -use uuid::Uuid; +use crate::binding::{ + issue_challenge, validate_challenge_echo, BindingKey, DEFAULT_CHALLENGE_TTL, +}; use crate::cashu_credential::{charge_requirement_from_cashu, CashuCredential}; use crate::cdk_mint_client::CdkMintClient; use crate::http_status::charge_error_status; use crate::challenge::{encode_charge_request, CashuRequirement}; +use crate::problem::{Problem, PROBLEM_JSON}; use crate::redeemer::{Redeemed, Redeemer}; use crate::envelope::{ parse_payment_authorization, AuthParseError, EchoedChallenge, CASHU_METHOD, PAYMENT_SCHEME, @@ -49,8 +58,9 @@ pub const DEFAULT_REALM: &str = "pops-core-verify"; /// as a one-shot charge (transfer-on-use). pub const INTENT_CHARGE: &str = "charge"; -/// Request-time state: the [`CashuRequirement`] to advertise on 402 and the -/// [`Redeemer`] that verifies + redeems on retry. +/// Request-time state: the [`CashuRequirement`] to advertise on 402, the +/// [`Redeemer`] that verifies + redeems on retry, and the challenge-binding +/// facts (server key + TTL). /// /// Generic over `C` so a second ecash method slots in with no middleware change; /// constructed once at router-build time and shared (`Arc`). @@ -60,26 +70,64 @@ pub struct ChargeMiddlewareState { pub requirement: CashuRequirement, /// The credential the middleware delegates to on retry. pub credential: Arc, + /// The server secret challenge ids are HMAC-bound under. Defaults to a + /// fresh per-boot key ([`BindingKey::generate`]); an operator supplies a + /// configured key via [`Self::with_binding_key`] to keep challenges valid + /// across restarts. + pub binding_key: BindingKey, + /// Challenge lifetime stamped into `expires` (default + /// [`DEFAULT_CHALLENGE_TTL`], 300 s). + pub challenge_ttl: Duration, } impl ChargeMiddlewareState { - /// Wraps `credential` in an [`Arc`] and pairs it with the requirement. + /// Wraps `credential` in an [`Arc`] and pairs it with the requirement, + /// generating a per-boot [`BindingKey`] and the default challenge TTL. pub fn new(requirement: CashuRequirement, credential: C) -> Self { Self { requirement, credential: Arc::new(credential), + binding_key: BindingKey::generate(), + challenge_ttl: DEFAULT_CHALLENGE_TTL, } } + + /// Use an operator-configured binding key instead of the per-boot one + /// (outstanding challenges then survive a restart). + pub fn with_binding_key(mut self, key: BindingKey) -> Self { + self.binding_key = key; + self + } + + /// Override the challenge TTL stamped into `expires`. + pub fn with_challenge_ttl(mut self, ttl: Duration) -> Self { + self.challenge_ttl = ttl; + self + } } /// Build a native [`ChargeMiddlewareState`] for the default -/// `CashuCredential`. +/// `CashuCredential` (mint HTTP bounded by +/// [`crate::cdk_mint_client::DEFAULT_MINT_HTTP_TIMEOUT`]). pub fn require_charge_state( requirement: CashuRequirement, ) -> ChargeMiddlewareState> { ChargeMiddlewareState::new(requirement, CashuCredential::new(CdkMintClient::new())) } +/// As [`require_charge_state`] with an explicit per-call mint HTTP timeout. A +/// mint that stops answering then surfaces as the 503 mint-unavailable path +/// within the bound instead of hanging the request. +pub fn require_charge_state_with_mint_timeout( + requirement: CashuRequirement, + mint_http_timeout: Duration, +) -> ChargeMiddlewareState> { + ChargeMiddlewareState::new( + requirement, + CashuCredential::new(CdkMintClient::with_timeout(mint_http_timeout)), + ) +} + /// Axum middleware entry point enforcing the Payment Authentication envelope. /// The `'static` bound on `C` is what `from_fn_with_state` requires to spawn /// the handler future. @@ -91,9 +139,20 @@ pub async fn require_charge( where C: Redeemer + Send + Sync + 'static, { + // More than one `Authorization: Payment` credential is a malformed REQUEST + // frame → 400 per the framework (clients MUST send exactly one). + if count_payment_credentials(req.headers()) > 1 { + return charge_error_to_response( + ChargeError::MalformedRequest( + "request bears more than one Authorization: Payment credential".to_string(), + ), + &ctx, + ); + } + // A missing header or any non-`Payment` scheme is "no payment attempt" → 402. let Some(header_raw) = req.headers().get(http::header::AUTHORIZATION) else { - return challenge_response(&ctx.requirement, None); + return challenge_response(&ctx, None); }; let header_value = match header_raw.to_str() { @@ -103,32 +162,36 @@ where ChargeError::MalformedCredential( "invalid Authorization header encoding".to_string(), ), - &ctx.requirement, + &ctx, ); } }; // `UnknownScheme` (Basic/Bearer/…) is control-flow-identical to no header at - // all; every OTHER parse error is a malformed credential → 402 re-challenge. + // all; a non-"cashu" method is the framework's method-unsupported (400); + // every OTHER parse error is a malformed credential → 402 re-challenge. let credentials = match parse_payment_authorization(header_value) { Ok(c) => c, Err(AuthParseError::UnknownScheme) => { - return challenge_response(&ctx.requirement, None); + return challenge_response(&ctx, None); + } + Err(AuthParseError::WrongMethod(method)) => { + return charge_error_to_response(ChargeError::MethodUnsupported { method }, &ctx) } Err(e) => { return charge_error_to_response( ChargeError::MalformedCredential(e.to_string()), - &ctx.requirement, + &ctx, ) } }; - // An echoed `challenge.expires` in the PAST is a `payment-expired`, caught - // BEFORE any swap. - if let Some(expires) = &credentials.challenge.expires { - if challenge_is_expired(expires) { - return charge_error_to_response(ChargeError::ChallengeExpired, &ctx.requirement); - } + // Spec verification step 3, BEFORE any swap: authenticate the echoed + // challenge (recompute the id-HMAC over every echoed param — tampered / + // inconsistent / expires-less → invalid-challenge), then check `expires` + // freshness (stale → payment-expired). + if let Err(e) = validate_challenge_echo(&ctx.binding_key, &credentials.challenge) { + return charge_error_to_response(e, &ctx); } // Verify + redeem via the generic seam; the `ChargeError` variant decides the @@ -136,11 +199,11 @@ where let charge_req = charge_requirement_from_cashu(&ctx.requirement); let redeemed = match ctx .credential - .verify_and_redeem(&credentials.payload.cashu_token, &charge_req) + .verify_and_redeem(&credentials.payload.token, &charge_req) .await { Ok(r) => r, - Err(e) => return charge_error_to_response(e, &ctx.requirement), + Err(e) => return charge_error_to_response(e, &ctx), }; // The receipt facts come from the redeemed proofs + the echoed challenge id; @@ -155,26 +218,44 @@ where req.extensions_mut().insert(redeemed); let mut response = next.run(req).await; - // `Payment-Receipt` + `Cache-Control: private` ride the settled response. + // `Payment-Receipt` + `Cache-Control: private` ride the settled SUCCESS + // response only — the spec's receipt § forbids the receipt on error + // responses (a downstream 4xx/5xx after settlement carries neither). // `from_str`/`from_static` are guarded: a header that won't build is dropped // rather than failing the served route. - if let Ok(value) = HeaderValue::from_str(&receipt_header) { - response.headers_mut().insert(PAYMENT_RECEIPT_HEADER, value); + if response.status().is_success() { + if let Ok(value) = HeaderValue::from_str(&receipt_header) { + response.headers_mut().insert(PAYMENT_RECEIPT_HEADER, value); + } + response.headers_mut().insert( + http::header::CACHE_CONTROL, + HeaderValue::from_static("private"), + ); } - response.headers_mut().insert( - http::header::CACHE_CONTROL, - HeaderValue::from_static("private"), - ); response } /// The `Payment-Receipt` response-header name. -const PAYMENT_RECEIPT_HEADER: http::header::HeaderName = +pub const PAYMENT_RECEIPT_HEADER: http::header::HeaderName = http::header::HeaderName::from_static("payment-receipt"); -/// The problem-type URI prefix for the `draft-cashu-charge-01` cashu -/// problem-types. -const PROBLEM_TYPE_PREFIX: &str = "cashu/"; +/// Count the `Authorization` values whose scheme token is `Payment` — +/// the framework allows at most one Payment credential per request (more is a +/// malformed request frame → 400). Shared by every axum-facing host (this +/// middleware and `pops-gateway`) so the counting rule cannot drift. +pub fn count_payment_credentials(headers: &http::HeaderMap) -> usize { + headers + .get_all(http::header::AUTHORIZATION) + .iter() + .filter(|v| { + v.to_str().is_ok_and(|s| { + s.split_whitespace() + .next() + .is_some_and(|scheme| scheme.eq_ignore_ascii_case(PAYMENT_SCHEME)) + }) + }) + .count() +} /// The `Payment-Receipt` JSON. `reference` is the redeemed `token_hash` (a /// settlement id exposing no secret); `externalId` is omitted when absent. @@ -190,20 +271,13 @@ struct PaymentReceipt<'a> { external_id: Option<&'a str>, } -/// An RFC-9457 `application/problem+json` body. -#[derive(Debug, Serialize)] -struct Problem { - #[serde(rename = "type")] - type_uri: String, - title: String, - status: u16, - detail: String, -} - -/// Build the `Payment-Receipt` header value: base64url-nopad over the receipt -/// JSON. `challenge_id` echoes the credential's challenge `id`; `external_id` -/// rides the receipt iff the issuance carried a correlation id. -fn payment_receipt_header( +/// Build the `Payment-Receipt` header value: base64url-nopad over the +/// JCS-canonical (RFC 8785) receipt JSON, per the spec's Encoding § (the same +/// canonicalization the request object and credential blob use). `challenge_id` +/// echoes the credential's challenge `id`; `external_id` rides the receipt iff +/// the issuance carried a correlation id. Shared by both Rust hosts (this +/// middleware and `pops-gateway`). +pub fn payment_receipt_header( redeemed: &Redeemed, challenge_id: &str, external_id: Option<&str>, @@ -216,83 +290,48 @@ fn payment_receipt_header( timestamp: Utc::now().to_rfc3339(), external_id, }; - let json = serde_json::to_string(&receipt).expect("PaymentReceipt always serializes"); + let json = serde_jcs::to_string(&receipt).expect("PaymentReceipt always serializes"); URL_SAFE_NO_PAD.encode(json.as_bytes()) } -/// Whether an echoed RFC-3339 `expires` is in the past against the wall clock. -/// An UNPARSEABLE timestamp is treated as expired — a malformed echo is not a -/// faithful challenge echo. -fn challenge_is_expired(expires: &str) -> bool { - match DateTime::parse_from_rfc3339(expires) { - Ok(ts) => ts.with_timezone(&Utc) <= Utc::now(), - Err(_) => true, - } -} - -/// The spec problem-type slug and RFC-9457 `title` for a [`ChargeError`] -/// (`draft-cashu-charge-01` §Errors — the per-variant docs on `ChargeError` -/// name these). The slug is bare (no `cashu/`); `problem_for` prepends the -/// prefix. The HTTP status is NOT decided here — it comes from the shared -/// [`charge_error_status`] so the gateway and this middleware cannot drift. -fn problem_parts(e: &ChargeError) -> (&'static str, &'static str) { - match e { - ChargeError::MintUnreachable { .. } => ("mint-unavailable", "Mint unavailable"), - ChargeError::AmountMismatch { .. } => ("amount-mismatch", "Amount mismatch"), - ChargeError::WrongUnit { .. } - | ChargeError::MintNotAllowed { .. } - | ChargeError::MultiMintOrUnit - | ChargeError::LockedToken - | ChargeError::DleqInvalid - | ChargeError::ShortKeysetIdUnresolved { .. } - | ChargeError::DoubleSpend => ("verification-failed", "Verification failed"), - ChargeError::Expired | ChargeError::ChallengeExpired => { - ("payment-expired", "Payment expired") - } - ChargeError::InvalidChallenge => ("invalid-challenge", "Invalid challenge"), - ChargeError::MalformedCredential(_) | ChargeError::TooManyProofs { .. } => { - ("malformed-credential", "Malformed credential") +/// Build a 402 carrying a fresh challenge (always `Cache-Control: no-store`). +/// The challenge `id` is the framework's stateless HMAC binding over the +/// issued params under the state's [`BindingKey`], and `expires` is stamped +/// `now + challenge_ttl` (MUST under stateless operation). `problem`, when +/// set, is the RFC-9457 `application/problem+json` body naming why the +/// previous attempt failed; a bare "no attempt yet" 402 has an empty body. +fn challenge_response( + ctx: &ChargeMiddlewareState, + problem: Option<&Problem>, +) -> Response { + // The one encode failure is a requirement naming no mints — server + // misconfiguration, never the client's fault → 500, not a payment status. + let request = match encode_charge_request(&ctx.requirement) { + Ok(r) => r, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to encode challenge request object: {e}"), + ) + .into_response(); } - ChargeError::MalformedRequest(_) => ("invalid-challenge", "Malformed request"), - } -} - -/// Build the RFC-9457 [`Problem`] + its status for a [`ChargeError`]. The status -/// is the single-sourced [`charge_error_status`] trichotomy. -fn problem_for(e: &ChargeError) -> (Problem, StatusCode) { - let (slug, title) = problem_parts(e); - let status = charge_error_status(e); - let problem = Problem { - type_uri: format!("{PROBLEM_TYPE_PREFIX}{slug}"), - title: title.to_string(), - status: status.as_u16(), - detail: e.to_string(), }; - (problem, status) -} - -/// Build a 402 carrying a fresh challenge (always `Cache-Control: no-store`). -/// `problem`, when set, is the RFC-9457 `application/problem+json` body naming -/// why the previous attempt failed; a bare "no attempt yet" 402 has an empty -/// body. -fn challenge_response(requirement: &CashuRequirement, problem: Option<&Problem>) -> Response { - // A random UUIDv4 `id` suffices; the challenge is not cryptographically - // bound to its params (no HMAC over them). - let id = Uuid::new_v4().to_string(); - - let request = encode_charge_request(requirement); // TODO: wire `realm` through middleware state. let realm = DEFAULT_REALM; - let header = format!( - r#"{} id="{}", realm="{}", method="cashu", intent="{}", request="{}""#, - PAYMENT_SCHEME, id, realm, INTENT_CHARGE, request, + let issued = issue_challenge( + &ctx.binding_key, + realm, + CASHU_METHOD, + INTENT_CHARGE, + &request, + ctx.challenge_ttl, ); // Values are all ASCII-printable; the `from_str` validation is a // belt-and-braces guard against a future encoder regression. - let www_auth = match HeaderValue::from_str(&header) { + let www_auth = match HeaderValue::from_str(&issued.header_value) { Ok(hv) => hv, Err(_) => { return ( @@ -306,22 +345,19 @@ fn challenge_response(requirement: &CashuRequirement, problem: Option<&Problem>) let cache_control = HeaderValue::from_static("no-store"); match problem { - Some(p) => { - let body = serde_json::to_string(p).expect("Problem always serializes"); - ( - StatusCode::PAYMENT_REQUIRED, - [ - (http::header::WWW_AUTHENTICATE, www_auth), - (http::header::CACHE_CONTROL, cache_control), - ( - http::header::CONTENT_TYPE, - HeaderValue::from_static("application/problem+json"), - ), - ], - body, - ) - .into_response() - } + Some(p) => ( + StatusCode::PAYMENT_REQUIRED, + [ + (http::header::WWW_AUTHENTICATE, www_auth), + (http::header::CACHE_CONTROL, cache_control), + ( + http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ), + ], + p.to_json(), + ) + .into_response(), None => ( StatusCode::PAYMENT_REQUIRED, [ @@ -335,34 +371,46 @@ fn challenge_response(requirement: &CashuRequirement, problem: Option<&Problem>) } /// Map a [`ChargeError`] to an HTTP response with an RFC-9457 -/// `application/problem+json` body (`draft-cashu-charge-01` §Errors). The three +/// `application/problem+json` body from the single-sourced +/// [`crate::problem`] map (`draft-cashu-charge-01` §Errors). The three /// non-collapsing concerns drive the status: `MintUnreachable` is 503 (transport, -/// token NOT consumed, NEVER a 402); `MalformedRequest` is 400 (not a well-formed -/// payment attempt); everything else (verification / malformed-credential) is a -/// 402 with a fresh re-challenge. The 402 carries the problem body alongside the -/// fresh `WWW-Authenticate`; a 503/400 carries the problem body with +/// token NOT consumed, NEVER a 402) and carries `Retry-After`; +/// `MalformedRequest`/`MethodUnsupported` are 400 (not a well-formed payment +/// attempt); everything else (verification / malformed-credential) is a 402 with +/// a fresh re-challenge. The 402 carries the problem body alongside the fresh +/// `WWW-Authenticate`; a 503/400 carries the problem body with /// `Cache-Control: no-store`. -fn charge_error_to_response(e: ChargeError, requirement: &CashuRequirement) -> Response { - let (problem, status) = problem_for(&e); +fn charge_error_to_response( + e: ChargeError, + ctx: &ChargeMiddlewareState, +) -> Response { + let problem = Problem::for_error(&e); + let status = charge_error_status(&e); if status == StatusCode::PAYMENT_REQUIRED { - return challenge_response(requirement, Some(&problem)); + return challenge_response(ctx, Some(&problem)); } - let body = serde_json::to_string(&problem).expect("Problem always serializes"); - ( + let mut response = ( status, [ ( http::header::CONTENT_TYPE, - HeaderValue::from_static("application/problem+json"), + HeaderValue::from_static(PROBLEM_JSON), ), ( http::header::CACHE_CONTROL, HeaderValue::from_static("no-store"), ), ], - body, + problem.to_json(), ) - .into_response() + .into_response(); + if status == StatusCode::SERVICE_UNAVAILABLE { + response.headers_mut().insert( + http::header::RETRY_AFTER, + HeaderValue::from_static("2"), + ); + } + response } /// Pluck the echoed challenge fields out of a parsed credentials blob — for test @@ -397,7 +445,7 @@ mod tests { use crate::challenge::CashuRequirement; use crate::redeemer::Redeemed; use crate::envelope::{encode_payment_credentials, CashuPayload, PaymentCredentials}; - use crate::mint_client::{MintClient, MintClientError}; + use crate::mint_client::{MintClient, MintClientError, SwapOutcome}; // ---- Mock MintClient (mirrors the validator's test helper) ------- @@ -407,8 +455,14 @@ mod tests { /// Post-submit (indeterminate) failure → exercises the 503 mapping. UnreachableIndeterminate, RejectedSwap, - /// Swap-output DLEQ gate rejected the mint's blind signatures → - /// money-safety path: 402 + re-challenge, resource NOT served. + /// The mint typed the rejection as already-spent → exercises the + /// double-spend detail. + AlreadySpent, + /// Keyset-class rejection (retired / final_expiry passed) → exercises + /// the payment-expired mapping. + KeysetRetiredOrExpired, + /// Swap-output DLEQ verdict failed → serve-and-flag path: the swap + /// SUCCEEDED, the response is the success path, `dleq_ok` is false. DleqInvalid, } @@ -435,9 +489,12 @@ mod tests { &self, _mint_url: &MintUrl, proofs: Proofs, - ) -> Result { + ) -> Result { match self.swap_response { - SwapResponse::Echo => Ok(proofs), + SwapResponse::Echo => Ok(SwapOutcome { + proofs, + dleq_ok: true, + }), SwapResponse::Unreachable => Err(MintClientError::Unreachable( "mock unreachable".into(), )), @@ -447,9 +504,16 @@ mod tests { SwapResponse::RejectedSwap => { Err(MintClientError::RejectedSwap("mock rejected".into())) } - SwapResponse::DleqInvalid => Err(MintClientError::SwapOutputDleqInvalid( - "mock swap-output DLEQ invalid".into(), - )), + SwapResponse::AlreadySpent => { + Err(MintClientError::AlreadySpent("mock already spent".into())) + } + SwapResponse::KeysetRetiredOrExpired => Err( + MintClientError::KeysetRetiredOrExpired("mock keyset retired".into()), + ), + SwapResponse::DleqInvalid => Ok(SwapOutcome { + proofs, + dleq_ok: false, + }), } } } @@ -564,10 +628,11 @@ mod tests { .expect("build request with header") } - /// Wrap a raw `cashuB…` token in the Payment envelope with a fake-but-shapely - /// echoed challenge. The middleware does not validate `challenge.id`, so any - /// well-formed echo works. - fn payment_header_with_token(token: &str) -> String { + /// Wrap a raw `cashuB…` token in the Payment envelope around an UNISSUED + /// (never-challenged) echo — fails the stateless binding by construction. + /// For the pre-binding paths (>1-credential 400) and the + /// unissued-echo-rejection test itself. + fn unissued_echo_header(token: &str) -> String { let creds = PaymentCredentials { challenge: EchoedChallenge { id: "test-challenge-id".into(), @@ -578,19 +643,62 @@ mod tests { digest: None, opaque: None, expires: None, + description: None, + }, + payload: CashuPayload { + token: token.into(), }, + source: None, + }; + format!("Payment {}", encode_payment_credentials(&creds)) + } + + /// Fetch a REAL challenge off the router (bare request → 402) and parse + /// its auth-params — the first half of the client dance. + async fn fetch_challenge(app: &Router) -> crate::envelope::PaymentParams { + let response = app + .clone() + .oneshot(bare_request()) + .await + .expect("challenge fetch"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let header = www_authenticate(&response); + crate::envelope::parse_payment_params(&header).expect("challenge params parse") + } + + /// Echo issued params verbatim into the credential's challenge object. + fn echo_of(params: &crate::envelope::PaymentParams) -> EchoedChallenge { + EchoedChallenge { + id: params.id.clone(), + realm: params.realm.clone(), + method: params.method.clone(), + intent: params.intent.clone(), + request: params.request.clone(), + digest: params.digest.clone(), + opaque: params.opaque.clone(), + expires: params.expires.clone(), + description: params.description.clone(), + } + } + + /// The `Authorization: Payment …` header value for `echo` + `token`. + fn header_for_echo(echo: EchoedChallenge, token: &str) -> String { + let creds = PaymentCredentials { + challenge: echo, payload: CashuPayload { - cashu_token: token.into(), + token: token.into(), }, source: None, }; format!("Payment {}", encode_payment_credentials(&creds)) } - /// Build a GET /gated request whose `Authorization` header is the - /// Payment Authentication envelope around `token`. - fn request_with_token(token: &str) -> HttpRequest { - request_with_authorization(&payment_header_with_token(token)) + /// The full client dance: fetch a real challenge from `app`, echo its + /// params faithfully, and build the authenticated retry request carrying + /// `token`. + async fn request_with_token(app: &Router, token: &str) -> HttpRequest { + let params = fetch_challenge(app).await; + request_with_authorization(&header_for_echo(echo_of(¶ms), token)) } /// GET /gated with raw header bytes (for the non-utf8 case). `header()` @@ -732,10 +840,8 @@ mod tests { let encoded = token.to_string(); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&encoded)) - .await - .expect("oneshot"); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); let body_bytes = to_bytes(response.into_body(), 1024) @@ -744,36 +850,207 @@ mod tests { assert_eq!(&body_bytes[..], b"ok:10"); } + // ---- Challenge binding (stateless HMAC id + expires) -------------- + + /// Read the problem body of a response as JSON. + async fn problem_body(response: Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), 1 << 16) + .await + .expect("collect body"); + serde_json::from_slice(&bytes).expect("problem+json body") + } + #[tokio::test] - async fn authorization_blob_echoes_challenge_id() { - // challenge-id binding is not enforced, but the round-trip must still 200. + async fn challenge_emits_hmac_id_and_future_expires() { + let app = router_with(state_with(SwapResponse::Echo)); + let params = fetch_challenge(&app).await; + // The id is base64url-nopad over 32 HMAC-SHA256 bytes — not a UUID. + let id_bytes = URL_SAFE_NO_PAD + .decode(¶ms.id) + .expect("id is base64url-nopad"); + assert_eq!(id_bytes.len(), 32, "id is an HMAC-SHA256 output"); + // expires is REQUIRED under stateless operation, RFC 3339, future. + let expires = params.expires.as_deref().expect("challenge carries expires"); + let ts = chrono::DateTime::parse_from_rfc3339(expires).expect("expires is RFC 3339"); + assert!(ts.with_timezone(&Utc) > Utc::now(), "expires is in the future"); + } + + #[tokio::test] + async fn faithful_echo_of_issued_challenge_passes() { + // The fresh-challenge happy path: fetch → echo verbatim → 200. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); - let encoded = token.to_string(); + let app = router_with(state_with(SwapResponse::Echo)); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::OK); + } - let creds = PaymentCredentials { - challenge: EchoedChallenge { - id: "echoed-id-from-client".into(), - realm: DEFAULT_REALM.into(), - method: "cashu".into(), - intent: INTENT_CHARGE.into(), - request: "echoed-request".into(), - digest: None, - opaque: None, - expires: None, - }, - payload: CashuPayload { - cashu_token: encoded, - }, - source: None, - }; - let header = format!("Payment {}", encode_payment_credentials(&creds)); + #[tokio::test] + async fn unissued_challenge_echo_returns_invalid_challenge() { + // An echo this server never issued (the old fake-fixture shape) fails + // the id recomputation → invalid-challenge 402 + fresh challenge. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let app = router_with(state_with(SwapResponse::Echo)); + let response = app + .oneshot(request_with_authorization(&unissued_echo_header( + &token.to_string(), + ))) + .await + .expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + assert!(response + .headers() + .get(http::header::WWW_AUTHENTICATE) + .is_some()); + let problem = problem_body(response).await; + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/invalid-challenge" + ); + } + + #[tokio::test] + async fn tampering_each_echoed_slot_returns_invalid_challenge() { + // Per HMAC slot reachable through this host: realm, intent, request, + // expires (value), digest (injected), opaque (injected), and the id + // itself. (`method` is covered separately: a non-"cashu" method is the + // framework's method-unsupported 400 at parse time.) + type Tamper = (&'static str, fn(&mut EchoedChallenge)); + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let tampers: Vec = vec![ + ("realm", |e| e.realm = "evil.example".into()), + ("intent", |e| e.intent = "authorize".into()), + ("request", |e| e.request.push('x')), + ("expires", |e| { + e.expires = Some("2999-01-01T00:00:00Z".into()) + }), + ("digest", |e| e.digest = Some("sha-256=:forged:".into())), + ("opaque", |e| e.opaque = Some("Zm9yZ2Vk".into())), + ("id", |e| e.id = "QQ".repeat(22)), + ]; + for (slot, tamper) in tampers { + let app = router_with(state_with(SwapResponse::Echo)); + let params = fetch_challenge(&app).await; + let mut echo = echo_of(¶ms); + tamper(&mut echo); + let response = app + .oneshot(request_with_authorization(&header_for_echo( + echo, + &token.to_string(), + ))) + .await + .expect("oneshot"); + assert_eq!( + response.status(), + StatusCode::PAYMENT_REQUIRED, + "tampered {slot} must 402" + ); + let problem = problem_body(response).await; + assert_eq!( + problem["type"], "https://paymentauth.org/problems/invalid-challenge", + "tampered {slot} must be invalid-challenge" + ); + } + } + #[tokio::test] + async fn echo_missing_expires_returns_invalid_challenge() { + // Every stateless challenge is issued WITH expires; an echo without it + // is not a faithful echo (and payment-expired would be wrong: nothing + // authentic has expired). + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); let app = router_with(state_with(SwapResponse::Echo)); + let params = fetch_challenge(&app).await; + let mut echo = echo_of(¶ms); + echo.expires = None; let response = app + .oneshot(request_with_authorization(&header_for_echo( + echo, + &token.to_string(), + ))) + .await + .expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let problem = problem_body(response).await; + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/invalid-challenge" + ); + } + + #[tokio::test] + async fn stale_expires_returns_payment_expired_before_any_redeem() { + // TTL zero: the issued challenge is authentic but instantly stale — + // payment-expired (NOT invalid-challenge); the redeemer is never + // reached. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let state = Arc::new( + ChargeMiddlewareState::new( + requirement(pop_unit(), vec![mint_a()], 10), + CashuCredential::new(MockMintClient::new(SwapResponse::Echo)), + ) + .with_challenge_ttl(std::time::Duration::ZERO), + ); + let app = router_with(state); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + assert!(response + .headers() + .get(http::header::WWW_AUTHENTICATE) + .is_some()); + let problem = problem_body(response).await; + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/payment-expired" + ); + } + + #[tokio::test] + async fn configured_binding_key_survives_state_rebuild() { + // Two states sharing one configured key accept each other's + // challenges (the restart-with-configured-key story); a state with a + // different (generated) key rejects them. + let key_hex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + + let issuing = router_with(Arc::new( + ChargeMiddlewareState::new( + requirement(pop_unit(), vec![mint_a()], 10), + CashuCredential::new(MockMintClient::new(SwapResponse::Echo)), + ) + .with_binding_key(BindingKey::from_hex(key_hex).expect("hex key")), + )); + let params = fetch_challenge(&issuing).await; + let header = header_for_echo(echo_of(¶ms), &token.to_string()); + + let same_key = router_with(Arc::new( + ChargeMiddlewareState::new( + requirement(pop_unit(), vec![mint_a()], 10), + CashuCredential::new(MockMintClient::new(SwapResponse::Echo)), + ) + .with_binding_key(BindingKey::from_hex(key_hex).expect("hex key")), + )); + let response = same_key .oneshot(request_with_authorization(&header)) .await .expect("oneshot"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.status(), + StatusCode::OK, + "a configured key honors challenges across instances" + ); + + let other_key = router_with(state_with(SwapResponse::Echo)); + let response = other_key + .oneshot(request_with_authorization(&header)) + .await + .expect("oneshot"); + assert_eq!( + response.status(), + StatusCode::PAYMENT_REQUIRED, + "a different key rejects the foreign challenge" + ); } // ---- Validation-failure mapping (all → 402 + re-challenge) ------- @@ -795,10 +1072,8 @@ mod tests { #[tokio::test] async fn malformed_token_returns_402() { let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token("cashuB!!!notbase64!!!")) - .await - .expect("oneshot"); + let request = request_with_token(&app, "cashuB!!!notbase64!!!").await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let body_bytes = to_bytes(response.into_body(), 1024) .await @@ -817,10 +1092,8 @@ mod tests { let encoded = token.to_string(); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&encoded)) - .await - .expect("oneshot"); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let header = www_authenticate(&response); assert!(header.contains(r#"method="cashu""#)); @@ -850,10 +1123,8 @@ mod tests { let encoded = token.to_string(); let app = router_with(state_with(SwapResponse::Unreachable)); - let response = app - .oneshot(request_with_token(&encoded)) - .await - .expect("oneshot"); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body_bytes = to_bytes(response.into_body(), 1024) .await @@ -865,60 +1136,199 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn hung_mint_returns_503_mint_unavailable_within_timeout() { + // END-TO-END through the REAL CdkMintClient: a mint that accepts TCP + // but never answers must produce the 503 mint-unavailable path within + // the configured mint HTTP timeout plus margin — never hang the + // request. (The token is NOT consumed: the hang is on the pre-swap + // keysets GET, the determinate arm.) + use crate::cdk_mint_client::CdkMintClient; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind hung mint"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let _held_open = socket; + std::future::pending::<()>().await; + }); + } + }); + + let hung_mint = + MintUrl::from_str(&format!("http://127.0.0.1:{port}")).expect("local mint url"); + let token = make_token(hung_mint.clone(), pop_unit(), vec![make_proof(10, 0)]); + + type CdkCredential = CashuCredential; + let state: Arc> = + Arc::new(ChargeMiddlewareState::new( + requirement(pop_unit(), vec![hung_mint], 10), + CashuCredential::new(CdkMintClient::with_timeout( + std::time::Duration::from_millis(250), + )), + )); + async fn echo(Extension(redeemed): Extension) -> String { + format!("ok:{}", redeemed.amount) + } + let app = Router::new() + .route("/gated", get(echo)) + .layer(from_fn_with_state(state, require_charge::)); + + let request = request_with_token(&app, &token.to_string()).await; + let started = std::time::Instant::now(); + let response = app.oneshot(request).await.expect("oneshot"); + let elapsed = started.elapsed(); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "a hung mint is the mint-unavailable path" + ); + assert!( + response.headers().get(http::header::RETRY_AFTER).is_some(), + "the 503 carries Retry-After" + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "must answer within the 250ms mint timeout plus margin, took {elapsed:?}" + ); + } + #[tokio::test] - async fn mint_rejected_returns_402() { - // A rejected swap is a verification failure → 402 + re-challenge. + async fn mint_rejected_returns_402_with_neutral_detail() { + // A non-keyset swap rejection the mint did NOT type as already-spent is + // the spec's step-9 else-branch → verification-failed 402 with a + // neutral detail (no double-spend claim the mint never made). let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); let encoded = token.to_string(); let app = router_with(state_with(SwapResponse::RejectedSwap)); - let response = app - .oneshot(request_with_token(&encoded)) - .await - .expect("oneshot"); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let body_bytes = to_bytes(response.into_body(), 1024) .await .expect("collect body"); let body = std::str::from_utf8(&body_bytes).unwrap_or(""); assert!( - body.contains("double-spend"), - "expected double-spend (SAFE interim for any swap rejection) message, got: {body}" + body.contains("the mint rejected the swap"), + "expected the neutral rejected-swap detail, got: {body}" + ); + assert!( + !body.contains("double-spend"), + "an untyped rejection must not claim a double-spend, got: {body}" + ); + assert!( + body.contains("https://paymentauth.org/problems/verification-failed"), + "a swap rejection answers with the verification-failed type, got: {body}" ); } #[tokio::test] - async fn swap_output_dleq_invalid_returns_402_and_does_not_serve_resource() { - // Money-safety end-to-end: a missing/invalid swap-output DLEQ maps to - // ChargeError::DleqInvalid → 402 + re-challenge, and the gated handler - // MUST NOT run, so a malicious/buggy mint never gets the resource served - // against unsigned ecash. + async fn already_spent_rejection_returns_402_with_double_spend_detail() { + // The mint typed the rejection as already-spent (NUT 11001 / + // cdk TokenAlreadySpent) → the spent-specific detail; same + // verification-failed 402. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); let encoded = token.to_string(); - let app = router_with(state_with(SwapResponse::DleqInvalid)); - let response = app - .oneshot(request_with_token(&encoded)) + let app = router_with(state_with(SwapResponse::AlreadySpent)); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let body_bytes = to_bytes(response.into_body(), 1024) .await - .expect("oneshot"); + .expect("collect body"); + let body = std::str::from_utf8(&body_bytes).unwrap_or(""); + assert!( + body.contains("double-spend"), + "expected the double-spend detail, got: {body}" + ); + assert!( + body.contains("https://paymentauth.org/problems/verification-failed"), + "an already-spent rejection answers with the verification-failed type, got: {body}" + ); + } - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); - assert!(response - .headers() - .get(http::header::WWW_AUTHENTICATE) - .is_some()); + #[tokio::test] + async fn keyset_retired_swap_rejection_returns_402_payment_expired() { + // Spec step 9 + Keyset Rotation §: a swap rejected for keyset + // retirement or passed final_expiry answers payment-expired (with a + // fresh challenge), NOT verification-failed — the client re-presents + // the same token once against the fresh challenge. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let encoded = token.to_string(); + let app = router_with(state_with(SwapResponse::KeysetRetiredOrExpired)); + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let header = www_authenticate(&response); + assert!( + header.starts_with("Payment "), + "a fresh challenge must accompany the 402: {header}" + ); let body_bytes = to_bytes(response.into_body(), 1024) .await .expect("collect body"); let body = std::str::from_utf8(&body_bytes).unwrap_or(""); assert!( - !body.starts_with("ok:"), - "gated resource must NOT be served on a DLEQ failure, got: {body}" + body.contains("https://paymentauth.org/problems/payment-expired"), + "a keyset-class rejection answers with the payment-expired type, got: {body}" + ); + assert!( + !body.contains("verification-failed"), + "must not collapse into verification-failed, got: {body}" + ); + } + + #[tokio::test] + async fn swap_output_dleq_failure_serves_resource_with_flag_in_extension() { + // Spec step 9: "a failed or missing DLEQ proof after a successful swap + // is a mint-trust incident, not a payment failure" — the HTTP response + // is the NORMAL success path (200 + receipt), the gated handler runs, + // and the false verdict rides `Extension.dleq_ok` for the + // operator surface. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let encoded = token.to_string(); + + async fn echo_flag(Extension(redeemed): Extension) -> String { + format!("ok:{}:dleq_ok={}", redeemed.amount, redeemed.dleq_ok) + } + let app = Router::new() + .route("/gated", get(echo_flag)) + .layer(from_fn_with_state( + state_with(SwapResponse::DleqInvalid), + require_charge::, + )); + + let request = request_with_token(&app, &encoded).await; + let response = app.oneshot(request).await.expect("oneshot"); + + assert_eq!( + response.status(), + StatusCode::OK, + "the payment settled; §security-dleq forbids failing it (MUST NOT \ + respond with a payment-failure status after a successful swap)" ); assert!( - body.to_ascii_lowercase().contains("dleq"), - "expected a DLEQ failure message, got: {body}" + response.headers().get("payment-receipt").is_some(), + "a settled payment carries its receipt" + ); + + let body_bytes = to_bytes(response.into_body(), 1024) + .await + .expect("collect body"); + let body = std::str::from_utf8(&body_bytes).unwrap_or(""); + assert_eq!( + body, "ok:10:dleq_ok=false", + "the resource is served and the extension carries the false verdict" ); } @@ -928,10 +1338,8 @@ mod tests { // handler never runs. let token = make_token(mint_a(), pop_unit(), vec![p2pk_locked_proof(10, 0)]); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); assert!(response .headers() @@ -958,10 +1366,8 @@ mod tests { vec![make_proof(2, 0), make_proof(4, 1), make_proof(4, 2)], ); let app = router_with(state_with_max_proofs(SwapResponse::Echo, 2)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let body_bytes = to_bytes(response.into_body(), 1024) .await @@ -980,10 +1386,8 @@ mod tests { // changes status, only the operator's checkstate obligation. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); let app = router_with(state_with(SwapResponse::UnreachableIndeterminate)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!( response.status(), StatusCode::SERVICE_UNAVAILABLE, @@ -1071,7 +1475,7 @@ mod tests { async fn json_missing_challenge_field_returns_402() { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; - let blob = URL_SAFE_NO_PAD.encode(br#"{"payload":{"cashu_token":"cashuBabc"}}"#); + let blob = URL_SAFE_NO_PAD.encode(br#"{"payload":{"token":"cashuBabc"}}"#); let header = format!("Payment {blob}"); let app = router_with(state_with(SwapResponse::Echo)); @@ -1100,8 +1504,9 @@ mod tests { } #[tokio::test] - async fn wrong_method_returns_402() { - // Valid envelope but `method="tempo"` → validation failure → 402. + async fn non_cashu_method_returns_400_method_unsupported() { + // Valid envelope but `method="tempo"` → the framework's + // method-unsupported (HTTP 400), NOT a 402 malformed-credential. let creds = PaymentCredentials { challenge: EchoedChallenge { id: "id".into(), @@ -1112,9 +1517,10 @@ mod tests { digest: None, opaque: None, expires: None, + description: None, }, payload: CashuPayload { - cashu_token: "cashuBabc".into(), + token: "cashuBabc".into(), }, source: None, }; @@ -1125,15 +1531,80 @@ mod tests { .oneshot(request_with_authorization(&header)) .await .expect("oneshot"); - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body_bytes = to_bytes(response.into_body(), 1024) .await .expect("collect body"); - let body = std::str::from_utf8(&body_bytes).unwrap_or(""); + let problem: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("problem+json body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/method-unsupported" + ); + assert_eq!(problem["status"], 400); assert!( - body.contains("must be 'cashu'"), - "expected wrong-method message, got: {body}" + problem["detail"].as_str().unwrap_or("").contains("tempo"), + "detail names the offending method: {problem}" + ); + } + + #[tokio::test] + async fn multiple_payment_credentials_return_400_bad_request() { + // Framework: clients MUST send only one Authorization: Payment + // credential; two of them are a malformed request frame → 400 with the + // about:blank type (no registered slug), never the invalid-challenge 402. + // The frame check precedes the binding check, so an unissued echo works. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let header = unissued_echo_header(&token.to_string()); + let mut req = HttpRequest::builder() + .uri("/gated") + .body(Body::empty()) + .expect("build request"); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_str(&header).expect("ascii"), + ); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_str(&header).expect("ascii"), + ); + + let app = router_with(state_with(SwapResponse::Echo)); + let response = app.oneshot(req).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body_bytes = to_bytes(response.into_body(), 1024) + .await + .expect("collect body"); + let problem: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("problem+json body"); + assert_eq!(problem["type"], "about:blank"); + assert_eq!(problem["status"], 400); + } + + #[tokio::test] + async fn one_payment_credential_among_other_schemes_still_verifies() { + // The >1 rule counts PAYMENT credentials only; a Basic header alongside + // the one Payment credential is not a malformed frame. (The Payment + // value must come first for the single-get parse to see it.) + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let app = router_with(state_with(SwapResponse::Echo)); + let params = fetch_challenge(&app).await; + let header = header_for_echo(echo_of(¶ms), &token.to_string()); + let mut req = HttpRequest::builder() + .uri("/gated") + .body(Body::empty()) + .expect("build request"); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_str(&header).expect("ascii"), ); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_static("Basic dXNlcjpwdw=="), + ); + + let response = app.oneshot(req).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] @@ -1146,7 +1617,7 @@ mod tests { assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); } - // ---- Exact-amount enforcement ------------------------------------ + // ---- Value-coverage enforcement ------------------------------------ #[tokio::test] async fn exact_amount_presentation_passes_through() { @@ -1157,10 +1628,8 @@ mod tests { vec![make_proof(8, 0), make_proof(2, 1)], ); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); let body_bytes = to_bytes(response.into_body(), 1024) @@ -1174,50 +1643,121 @@ mod tests { } #[tokio::test] - async fn overfunded_presentation_returns_402() { - // Exact-amount: a 20-against-10 over-funded token is rejected, NOT - // change-made — the holder splits to 10 locally before presenting. + async fn overfunded_presentation_is_accepted_and_excess_retained() { + // Spec step 8: value above `amount + swap_fee` is accepted and + // retained — a 20-against-10 token redeems whole and serves the + // resource; the handler sees the full redeemed value. let token = make_token( mint_a(), pop_unit(), vec![make_proof(16, 0), make_proof(4, 1)], ); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); - assert!(response - .headers() - .get(http::header::WWW_AUTHENTICATE) - .is_some()); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::OK); let body_bytes = to_bytes(response.into_body(), 1024) .await .expect("collect body"); - let body = std::str::from_utf8(&body_bytes).unwrap_or(""); - assert!( - body.contains("amount mismatch"), - "expected amount-mismatch body, got: {body}" + assert_eq!( + &body_bytes[..], + b"ok:20", + "the WHOLE over-funded value is redeemed and retained" ); } #[tokio::test] - async fn underfunded_presentation_returns_402() { + async fn underfunded_presentation_returns_402_payment_insufficient() { let token = make_token(mint_a(), pop_unit(), vec![make_proof(8, 0)]); let app = router_with(state_with(SwapResponse::Echo)); - let response = app - .oneshot(request_with_token(&token.to_string())) - .await - .expect("oneshot"); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let body_bytes = to_bytes(response.into_body(), 1024) .await .expect("collect body"); - let body = std::str::from_utf8(&body_bytes).unwrap_or(""); + let problem: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("problem+json body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/payment-insufficient", + "an under-funded token is the framework's payment-insufficient" + ); + assert_eq!(problem["status"], 402); + } + + #[tokio::test] + async fn mint_unreachable_503_carries_retry_after_and_no_custom_type() { + // Spec Errors §: mint unreachability carries no problem type — plain + // 503 + Retry-After, body about:blank. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let app = router_with(state_with(SwapResponse::Unreachable)); + let request = request_with_token(&app, &token.to_string()).await; + let response = app.oneshot(request).await.expect("oneshot"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + response.headers().get(http::header::RETRY_AFTER).is_some(), + "a 503 SHOULD carry Retry-After" + ); + let body_bytes = to_bytes(response.into_body(), 1024) + .await + .expect("collect body"); + let problem: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("problem+json body"); + assert_eq!( + problem["type"], "about:blank", + "mint unreachability has no custom problem-type URI" + ); + } + + // ---- Payment-Receipt encoding (spec Encoding §: JCS before base64url) -- + + #[test] + fn payment_receipt_bytes_are_jcs_canonical() { + // Hand-derived from RFC 8785: keys sort lexicographically — + // challengeId < externalId < method < reference < status < timestamp — + // matching the spec's decoded receipt example, NOT the struct's + // declaration order. + let receipt = PaymentReceipt { + method: CASHU_METHOD, + challenge_id: "kM9xPqWvT2nJrHsY4aDfEb", + reference: "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7", + status: "success", + timestamp: "2026-03-10T21:00:00Z".to_string(), + external_id: Some("order_12345"), + }; + let json = serde_jcs::to_string(&receipt).expect("receipt serializes"); + assert_eq!( + json, + r#"{"challengeId":"kM9xPqWvT2nJrHsY4aDfEb","externalId":"order_12345","method":"cashu","reference":"9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7","status":"success","timestamp":"2026-03-10T21:00:00Z"}"# + ); + } + + #[test] + fn payment_receipt_header_is_base64url_nopad_of_jcs_bytes() { + let redeemed = Redeemed { + unit: "pop_1700000000".into(), + amount: 10, + proofs: crate::charge::RedeemedProofs { + fresh_proofs: "cashuBfresh".into(), + amount: 10, + unit: "pop_1700000000".into(), + active_keyset_id: "009a1f293253e41e".into(), + token_hash: "ref-hash".into(), + }, + dleq_ok: true, + }; + let header = payment_receipt_header(&redeemed, "ch-1", Some("inv-7")); + let bytes = URL_SAFE_NO_PAD.decode(&header).expect("base64url-nopad"); + let json = std::str::from_utf8(&bytes).expect("utf8"); + // The timestamp is stamped at build time; everything around it is the + // pinned JCS order with the supplied facts. assert!( - body.contains("amount mismatch"), - "expected amount-mismatch body, got: {body}" + json.starts_with( + r#"{"challengeId":"ch-1","externalId":"inv-7","method":"cashu","reference":"ref-hash","status":"success","timestamp":""# + ), + "receipt bytes must be JCS-ordered, got: {json}" ); + assert!(json.ends_with(r#""}"#), "got: {json}"); } } diff --git a/crates/pops-core-verify/src/middleware_xcashu.rs b/crates/pops-core-verify/src/middleware_xcashu.rs index 14f8341..6be6ef5 100644 --- a/crates/pops-core-verify/src/middleware_xcashu.rs +++ b/crates/pops-core-verify/src/middleware_xcashu.rs @@ -12,13 +12,18 @@ //! money core with the `Payment` middleware; only the wire differs (single //! `X-Cashu` header both directions, vs `WWW-Authenticate`/`Authorization`). //! -//! Status mapping (stricter and safer than NUT-24's bare `400`): -//! `MintUnreachable` → `503` (transport blip, token NOT consumed — never a -//! `400`/`402` that says "re-pay" against a valid token); `MalformedRequest` → -//! `400` (server-side requirement is misconfigured); every other validation -//! failure → `402` + a fresh `X-Cashu: ` re-challenge. Amount is EXACT: -//! an over- or under-funded token is an `AmountMismatch` rejection, never -//! silent overage capture. Every `402` carries `Cache-Control: no-store`. +//! Status mapping (stricter and safer than NUT-24's bare `400`) — the +//! single-sourced [`crate::problem`] map, shared with every other host: +//! `MintUnreachable` → `503` + `Retry-After` (transport blip, token NOT +//! consumed — never a `400`/`402` that says "re-pay" against a valid token); +//! `MalformedRequest` → `400` (server-side requirement is misconfigured); every +//! other validation failure → `402` + a fresh `X-Cashu: ` re-challenge. +//! Value follows the spec's step-8 rule: an under-funded token is a +//! `PaymentInsufficient` rejection; value above the requirement is accepted +//! and retained (the server makes no change). Every `402` carries +//! `Cache-Control: no-store`, and every failure body is RFC-9457 +//! `application/problem+json` with the absolute problem-type URI (NUT-24 +//! leaves the body unspecified, so the richer body is compatible). //! //! [`Redeemer`]: crate::redeemer::Redeemer @@ -34,7 +39,9 @@ use crate::charge::ChargeError; use crate::cashu_credential::charge_requirement_from_cashu; use crate::challenge::CashuRequirement; +use crate::http_status::charge_error_status; use crate::middleware::ChargeMiddlewareState; +use crate::problem::{Problem, PROBLEM_JSON}; use crate::redeemer::Redeemer; use crate::xcashu::{xcashu_challenge_value, xcashu_token_from_header}; @@ -65,7 +72,10 @@ where let header_value = match header_raw.to_str() { Ok(v) => v, Err(_) => { - return challenge_response(&ctx.requirement, Some("invalid X-Cashu header encoding")); + return charge_error_to_response( + ChargeError::MalformedCredential("invalid X-Cashu header encoding".to_string()), + &ctx.requirement, + ); } }; @@ -73,7 +83,12 @@ where // Like every other malformed presentation it is non-serving → 402. let token = match xcashu_token_from_header(header_value) { Ok(t) => t, - Err(e) => return challenge_response(&ctx.requirement, Some(&e.to_string())), + Err(e) => { + return charge_error_to_response( + ChargeError::MalformedCredential(e.to_string()), + &ctx.requirement, + ) + } }; // Verify + redeem via the generic seam; the `ChargeError` variant decides the @@ -91,11 +106,11 @@ where } /// Build a `402` carrying a fresh `X-Cashu: ` challenge (always -/// `Cache-Control: no-store`). `failure_reason`, when set, becomes the body so -/// the client sees why the previous attempt failed; a bare "no attempt yet" -/// `402` gets an empty body. NUT-24 has no challenge id / realm / echo, so the -/// header value is the bare `creqA`. -fn challenge_response(requirement: &CashuRequirement, failure_reason: Option<&str>) -> Response { +/// `Cache-Control: no-store`). `problem`, when set, becomes the +/// `application/problem+json` body naming why the previous attempt failed; a +/// bare "no attempt yet" `402` gets an empty body. NUT-24 has no challenge id / +/// realm / echo, so the header value is the bare `creqA`. +fn challenge_response(requirement: &CashuRequirement, problem: Option<&Problem>) -> Response { let creq_a = xcashu_challenge_value(requirement); // The `creqA` is base64url, always a valid header value; the `from_str` @@ -112,35 +127,68 @@ fn challenge_response(requirement: &CashuRequirement, failure_reason: Option<&st }; let cache_control = HeaderValue::from_static("no-store"); - let body = failure_reason.unwrap_or("").to_string(); - ( - StatusCode::PAYMENT_REQUIRED, - [ - (x_cashu_header(), x_cashu), - (http::header::CACHE_CONTROL, cache_control), - ], - body, - ) - .into_response() + match problem { + Some(p) => ( + StatusCode::PAYMENT_REQUIRED, + [ + (x_cashu_header(), x_cashu), + (http::header::CACHE_CONTROL, cache_control), + ( + http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ), + ], + p.to_json(), + ) + .into_response(), + None => ( + StatusCode::PAYMENT_REQUIRED, + [ + (x_cashu_header(), x_cashu), + (http::header::CACHE_CONTROL, cache_control), + ], + String::new(), + ) + .into_response(), + } } -/// Map a [`ChargeError`] to an HTTP response. The three non-collapsing concerns -/// drive the status (the `402` body is the Display string; every `402` is -/// no-store): `MintUnreachable` → `503` (transport, token NOT consumed, NEVER a -/// `402`), `MalformedRequest` → `400` (server-side requirement misconfigured), -/// everything else (verification / malformed-credential / amount mismatch) → -/// `402` + a fresh `X-Cashu: ` re-challenge. +/// Map a [`ChargeError`] to an HTTP response from the single-sourced +/// [`crate::problem`] map (every failure body is `application/problem+json`): +/// `MintUnreachable` → `503` + `Retry-After` (transport, token NOT consumed, +/// NEVER a `402`), `MalformedRequest`/`MethodUnsupported` → `400`, everything +/// else (verification / malformed-credential / insufficient value) → `402` + a +/// fresh `X-Cashu: ` re-challenge. A non-402 carries NO re-challenge — +/// on a 503 re-presenting the SAME token is correct. fn charge_error_to_response(e: ChargeError, requirement: &CashuRequirement) -> Response { - match &e { - ChargeError::MintUnreachable { .. } => { - (StatusCode::SERVICE_UNAVAILABLE, e.to_string()).into_response() - } - ChargeError::MalformedRequest(_) => { - (StatusCode::BAD_REQUEST, e.to_string()).into_response() - } - _ => challenge_response(requirement, Some(&e.to_string())), + let problem = Problem::for_error(&e); + let status = charge_error_status(&e); + if status == StatusCode::PAYMENT_REQUIRED { + return challenge_response(requirement, Some(&problem)); + } + let mut response = ( + status, + [ + ( + http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ), + ( + http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ), + ], + problem.to_json(), + ) + .into_response(); + if status == StatusCode::SERVICE_UNAVAILABLE { + response.headers_mut().insert( + http::header::RETRY_AFTER, + HeaderValue::from_static("2"), + ); } + response } #[cfg(test)] @@ -166,7 +214,7 @@ mod tests { use crate::cashu_credential::CashuCredential; use crate::challenge::CashuRequirement; use crate::middleware::ChargeMiddlewareState; - use crate::mint_client::{MintClient, MintClientError}; + use crate::mint_client::{MintClient, MintClientError, SwapOutcome}; use crate::redeemer::Redeemed; use crate::xcashu::X_CASHU; @@ -177,8 +225,10 @@ mod tests { Unreachable, UnreachableIndeterminate, RejectedSwap, - /// Swap-output DLEQ gate rejected the mint's blind signatures → - /// money-safety path: 402 + re-challenge, resource NOT served. + /// The mint typed the rejection as already-spent. + AlreadySpent, + /// Swap-output DLEQ verdict failed → serve-and-flag path: the swap + /// SUCCEEDED, the response is the success path, `dleq_ok` is false. DleqInvalid, } @@ -202,9 +252,12 @@ mod tests { &self, _mint_url: &MintUrl, proofs: Proofs, - ) -> Result { + ) -> Result { match self.swap_response { - SwapResponse::Echo => Ok(proofs), + SwapResponse::Echo => Ok(SwapOutcome { + proofs, + dleq_ok: true, + }), SwapResponse::Unreachable => { Err(MintClientError::Unreachable("mock unreachable".into())) } @@ -214,9 +267,13 @@ mod tests { SwapResponse::RejectedSwap => { Err(MintClientError::RejectedSwap("mock rejected".into())) } - SwapResponse::DleqInvalid => Err(MintClientError::SwapOutputDleqInvalid( - "mock swap-output DLEQ invalid".into(), - )), + SwapResponse::AlreadySpent => { + Err(MintClientError::AlreadySpent("mock already spent".into())) + } + SwapResponse::DleqInvalid => Ok(SwapOutcome { + proofs, + dleq_ok: false, + }), } } } @@ -447,30 +504,43 @@ mod tests { } #[tokio::test] - async fn dleq_invalid_returns_402_and_does_not_serve_resource() { - // Money-safety: a missing/invalid swap-output DLEQ → 402 re-challenge, - // and the gated handler MUST NOT run. + async fn dleq_failure_serves_resource_with_flag_in_extension() { + // Spec step 9: a failed/missing DLEQ on the swap-RETURNED signatures + // is a mint-trust incident, not a payment failure — the X-Cashu host + // serves the resource too, with the verdict on `Extension`. + async fn echo_flag(Extension(redeemed): Extension) -> String { + format!("ok:{}:dleq_ok={}", redeemed.amount, redeemed.dleq_ok) + } + let app = Router::new() + .route("/gated", get(echo_flag)) + .layer(from_fn_with_state( + state_with(SwapResponse::DleqInvalid), + require_charge_xcashu::, + )); + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); - let app = router_with(state_with(SwapResponse::DleqInvalid)); let response = app .oneshot(request_with_xcashu(&token.to_string())) .await .expect("oneshot"); - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); - assert!(response.headers().get(super::x_cashu_header()).is_some()); + assert_eq!( + response.status(), + StatusCode::OK, + "a settled payment must not be answered with a payment failure" + ); let body = body_string(response).await; - assert!(!body.starts_with("ok:"), "resource must NOT be served on a DLEQ failure"); - assert!( - body.to_ascii_lowercase().contains("dleq"), - "expected a DLEQ failure body, got: {body}" + assert_eq!( + body, "ok:10:dleq_ok=false", + "resource served; extension carries the false verdict" ); } #[tokio::test] async fn double_spend_returns_402_and_does_not_serve() { - // A rejected swap (double-spend / expired) → 402 re-challenge. + // A mint-typed already-spent rejection → 402 re-challenge with the + // spent-specific detail. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); - let app = router_with(state_with(SwapResponse::RejectedSwap)); + let app = router_with(state_with(SwapResponse::AlreadySpent)); let response = app .oneshot(request_with_xcashu(&token.to_string())) .await @@ -481,6 +551,29 @@ mod tests { assert!(body.contains("double-spend"), "expected double-spend body, got: {body}"); } + #[tokio::test] + async fn untyped_rejection_returns_402_with_neutral_detail() { + // A rejection the mint did NOT type as already-spent → the neutral + // rejected-swap detail; same 402 re-challenge. + let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); + let app = router_with(state_with(SwapResponse::RejectedSwap)); + let response = app + .oneshot(request_with_xcashu(&token.to_string())) + .await + .expect("oneshot"); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let body = body_string(response).await; + assert!(!body.starts_with("ok:"), "resource must NOT be served"); + assert!( + body.contains("the mint rejected the swap"), + "expected the neutral detail, got: {body}" + ); + assert!( + !body.contains("double-spend"), + "must not claim a double-spend, got: {body}" + ); + } + // ---- Mint-unreachable → 503, token NOT consumed ------------------ #[tokio::test] @@ -499,6 +592,10 @@ mod tests { response.headers().get(super::x_cashu_header()).is_none(), "a 503 must NOT carry an X-Cashu re-challenge (the token is still good)" ); + assert!( + response.headers().get(http::header::RETRY_AFTER).is_some(), + "a 503 SHOULD carry Retry-After" + ); let body = body_string(response).await; assert!( body.contains("mint unavailable"), diff --git a/crates/pops-core-verify/src/mint_client.rs b/crates/pops-core-verify/src/mint_client.rs index 61c2165..675064c 100644 --- a/crates/pops-core-verify/src/mint_client.rs +++ b/crates/pops-core-verify/src/mint_client.rs @@ -16,6 +16,26 @@ use cashu::nuts::nut02::KeySetInfo; use cashu::{MintUrl, Proofs}; use thiserror::Error; +/// What a successful swap yields: the fresh verifier-held proofs plus the +/// NUT-12 verdict on the blind signatures the mint returned. +/// +/// `dleq_ok: false` is a MINT-TRUST INCIDENT, not a payment failure +/// (`draft-cashu-charge-01` §security-dleq): the client's inputs were genuine +/// and were consumed by the successful swap; only the mint controls the +/// signatures it returns. Callers serve the resource, surface the flag to the +/// operator (alert + quarantine the mint), and MUST NOT answer with a payment +/// failure — the swap succeeded, so a 402 here would charge the client twice +/// for a settled payment. +#[derive(Debug, Clone)] +pub struct SwapOutcome { + /// The unblinded swap outputs, under fresh verifier secrets. + pub proofs: Proofs, + /// Whether EVERY swap-returned blind signature carried a NUT-12 DLEQ that + /// verifies against the active keyset's advertised key. `false` = missing + /// or invalid on at least one signature. + pub dleq_ok: bool, +} + /// Abstraction over the calls the verify core makes to a Cashu mint. #[cfg(not(target_arch = "wasm32"))] #[async_trait] @@ -29,12 +49,13 @@ pub trait MintClient: Send + Sync { ) -> Result, MintClientError>; /// Swap `proofs` for new verifier-held proofs. The mint atomically consumes - /// the inputs (failing if spent/expired/invalid). + /// the inputs (failing if spent/expired/invalid). The returned + /// [`SwapOutcome::dleq_ok`] reports the swap-output DLEQ verdict. async fn swap( &self, mint_url: &MintUrl, proofs: Proofs, - ) -> Result; + ) -> Result; } /// Abstraction over the calls the verify core makes to a Cashu mint @@ -53,7 +74,7 @@ pub trait MintClient { &self, mint_url: &MintUrl, proofs: Proofs, - ) -> Result; + ) -> Result; } /// Errors returned by [`MintClient`] implementations. @@ -73,19 +94,41 @@ pub enum MintClientError { #[error("mint unreachable (indeterminate swap outcome): {0}")] UnreachableIndeterminate(String), - /// The mint refused the swap (expired, double-spent, bad signature, keyset - /// rotated, etc.). + /// The mint refused the swap WITHOUT typing the reason as already-spent or + /// keyset-class (bad signature, unbalanced, etc.) — the definitive-rejection + /// catch-all. The token was NOT consumed. #[error("mint rejected swap: {0}")] RejectedSwap(String), - /// A returned blind signature failed DLEQ verification (NUT-12 proof MISSING - /// or INVALID against the advertised key). - /// - /// SECURITY-CRITICAL, deliberately distinct from [`Self::RejectedSwap`]: the - /// mint did NOT prove it signed the outputs with the advertised key, so the - /// proofs are not provably valid bearer value and MUST NOT be redeemed (no - /// redeemed value without a verified DLEQ). Maps to `DleqInvalid` - /// (402, resource not served), NOT a double-spend. - #[error("swap-output DLEQ verification failed: {0}")] - SwapOutputDleqInvalid(String), + /// The mint refused the swap because an input proof is ALREADY SPENT (NUT + /// error code 11001 / `cdk::Error::TokenAlreadySpent`). Kept apart from + /// [`Self::RejectedSwap`] so only a mint-typed double-spend ever reads as + /// one. + #[error("mint rejected swap: proof already spent: {0}")] + AlreadySpent(String), + + /// The mint refused the call with a KEYSET-class error (NUT error codes + /// 12001 keyset-not-known / 12002 keyset-inactive): the keyset has retired + /// or its `final_expiry` has passed. `draft-cashu-charge-01` step 9 makes + /// this a `payment-expired` condition, distinct from the double-spend / + /// other-rejection family that is `verification-failed`. The token was NOT + /// consumed; the client re-presents the SAME token against a fresh + /// challenge once, then abandons it. + #[error("mint rejected swap (keyset retired or final_expiry passed): {0}")] + KeysetRetiredOrExpired(String), + + /// The active keyset charges an `input_fee_ppk` over the supported maximum + /// (0 in the fee-free profile), detected BEFORE the swap is submitted. The + /// token was NOT consumed. Distinct from [`Self::RejectedSwap`] so the + /// policy reject never reads as a double-spend. + #[error( + "fee-bearing keyset {keyset_id} disallowed: input_fee_ppk {input_fee_ppk} \ + exceeds the fee-free profile" + )] + FeeTooHigh { + /// Keyset whose fee exceeded the profile (hex id). + keyset_id: String, + /// The disallowed `input_fee_ppk` the mint publishes for it. + input_fee_ppk: u64, + }, } diff --git a/crates/pops-core-verify/src/problem.rs b/crates/pops-core-verify/src/problem.rs new file mode 100644 index 0000000..b5a2720 --- /dev/null +++ b/crates/pops-core-verify/src/problem.rs @@ -0,0 +1,457 @@ +//! The single-sourced [`ChargeError`] → RFC-9457 problem mapping: +//! { absolute `type` URI, slug, HTTP status, title }. +//! +//! EVERY host emits its error wire from this one table — the axum +//! [`middleware`](crate::middleware), the NUT-24 +//! [`middleware_xcashu`](crate::middleware_xcashu), `pops-gateway`, and the +//! wasm surface — so the spec mapping cannot drift between them. Statuses are +//! plain `u16` (no `http` dependency) so the table compiles on every feature +//! surface, wasm included; the native [`crate::http_status`] adapter lifts them +//! into `http::StatusCode`. +//! +//! URIs per `draft-cashu-charge-01` Errors § and the framework's problem +//! registry: the method defines NO problem types of its own — every 402 type +//! is a framework-registered `https://paymentauth.org/problems/`, and +//! mint unreachability carries no problem type at all (a plain 503 + +//! `Retry-After`, body `about:blank`). No relative URIs anywhere. + +use serde::Serialize; + +use crate::charge::ChargeError; + +/// One row of the mapping: the spec problem type (or the RFC-9457 `about:blank` +/// fallback) plus the HTTP status a host MUST answer with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProblemMapping { + /// The registered slug (`verification-failed`, `payment-insufficient`, …), + /// or `None` where no problem type is registered (the `about:blank` rows). + pub slug: Option<&'static str>, + /// The ABSOLUTE `type` URI for the problem body. + pub type_uri: &'static str, + /// HTTP status the host answers with. + pub status: u16, + /// Short human-readable summary for the body's `title` member. + pub title: &'static str, +} + +/// The bare "resource requires payment, no attempt yet" challenge body — a +/// framework-registered type with no [`ChargeError`] (nothing failed). +pub const PAYMENT_REQUIRED: ProblemMapping = ProblemMapping { + slug: Some("payment-required"), + type_uri: "https://paymentauth.org/problems/payment-required", + status: 402, + title: "Payment Required", +}; + +/// Map a [`ChargeError`] onto its spec problem type + status. +/// +/// THE load-bearing trichotomy: a transport failure +/// ([`ChargeError::MintUnreachable`]) is `503` — the token is NOT consumed and +/// the caller MAY retry it, so it MUST NEVER collapse into a `402` ("your +/// payment was wrong, re-pay"). A malformed *request frame* +/// ([`ChargeError::MalformedRequest`]) and an unsupported method +/// ([`ChargeError::MethodUnsupported`]) are `400` per the framework's status +/// handling. EVERY other variant — verification failures and a malformed +/// *credential* — is `402` with a fresh re-challenge. +pub fn problem_mapping(e: &ChargeError) -> ProblemMapping { + const fn framework( + slug: &'static str, + type_uri: &'static str, + status: u16, + title: &'static str, + ) -> ProblemMapping { + ProblemMapping { + slug: Some(slug), + type_uri, + status, + title, + } + } + + match e { + // Mint unreachability is an infrastructure failure, not a + // payment-verification outcome, and carries NO problem type (spec + // Errors §): a plain 503 + Retry-After with an about:blank body; the + // token may still be good. + ChargeError::MintUnreachable { .. } => ProblemMapping { + slug: None, + type_uri: "about:blank", + status: 503, + title: "Service Unavailable", + }, + + // The framework's payment-insufficient: the token's total value is + // less than `amount + swap_fee` (spec step 8). There is no + // over-payment counterpart — excess is accepted and retained. + ChargeError::PaymentInsufficient { .. } => framework( + "payment-insufficient", + "https://paymentauth.org/problems/payment-insufficient", + 402, + "Payment Insufficient", + ), + + // Non-amount, non-expiry verification failures, per the spec's Errors § + // list: unit mismatch (declared OR resolved-keyset, step 7), disallowed + // mint, a userinfo-bearing mint URL (mint-trust §), NUT-10 lock, + // unresolvable short keyset id, swap rejection (typed already-spent or + // the neutral else-branch, step 9), and the policy-disallowed + // fee-bearing keyset ("unit otherwise disallowed by server policy"). A + // swap-output DLEQ failure is deliberately NOT here — see the map + // tests: it is no ChargeError at all. + ChargeError::WrongUnit { .. } + | ChargeError::MintNotAllowed { .. } + | ChargeError::MintUrlUserinfo { .. } + | ChargeError::LockedToken + | ChargeError::ShortKeysetIdUnresolved { .. } + | ChargeError::DoubleSpend + | ChargeError::SwapRejected(_) + | ChargeError::FeeTooHigh { .. } => framework( + "verification-failed", + "https://paymentauth.org/problems/verification-failed", + 402, + "Verification Failed", + ), + + // Both expiry causes share one type per the spec (the client needs no + // discriminator: re-present once, then abandon the token). + ChargeError::Expired | ChargeError::ChallengeExpired => framework( + "payment-expired", + "https://paymentauth.org/problems/payment-expired", + 402, + "Payment Expired", + ), + + ChargeError::InvalidChallenge => framework( + "invalid-challenge", + "https://paymentauth.org/problems/invalid-challenge", + 402, + "Invalid Challenge", + ), + + // A bad credential is still a payment attempt → 402 + re-challenge + // (the framework's status table), never a 400. The spec treats a + // multi-mint/multi-unit token as a parse-level fault of the credential + // (a valid TokenV4 structurally carries exactly one mint and unit), so + // MultiMintOrUnit lands here rather than under verification-failed. + ChargeError::MalformedCredential(_) + | ChargeError::MultiMintOrUnit + | ChargeError::TooManyProofs { .. } => framework( + "malformed-credential", + "https://paymentauth.org/problems/malformed-credential", + 402, + "Malformed Credential", + ), + + // Framework-registered 400: the credential names a method ≠ "cashu". + ChargeError::MethodUnsupported { .. } => framework( + "method-unsupported", + "https://paymentauth.org/problems/method-unsupported", + 400, + "Method Unsupported", + ), + + // A malformed REQUEST (multiple Authorization: Payment credentials, + // unparseable server-side requirement) is 400 per the framework WITHOUT + // a registered problem type — RFC 9457's `about:blank` says "no + // semantics beyond the status code". It MUST NOT borrow the + // invalid-challenge slug (that is a 402 about the challenge echo). + ChargeError::MalformedRequest(_) => ProblemMapping { + slug: None, + type_uri: "about:blank", + status: 400, + title: "Bad Request", + }, + // Exhaustive ON PURPOSE (`ChargeError` is non_exhaustive only across + // crates): a new variant fails compilation HERE, forcing a conscious + // mapping decision instead of a silent default. + } +} + +/// An RFC-9457 `application/problem+json` body, identical across hosts: +/// `type` is the mapping's absolute URI, `status` mirrors the HTTP status, +/// `detail` is the error's `Display`. +#[derive(Debug, Clone, Serialize)] +pub struct Problem { + /// Absolute problem-type URI. + #[serde(rename = "type")] + pub type_uri: &'static str, + /// Human-readable summary of the problem type. + pub title: &'static str, + /// HTTP status, mirrored into the body per RFC 9457. + pub status: u16, + /// Human-readable detail for this occurrence. + pub detail: String, +} + +impl Problem { + /// Build the problem body for a [`ChargeError`] from the shared mapping. + pub fn for_error(e: &ChargeError) -> Self { + let mapping = problem_mapping(e); + Self { + type_uri: mapping.type_uri, + title: mapping.title, + status: mapping.status, + detail: e.to_string(), + } + } + + /// Build the bare "payment required, no attempt yet" body. + pub fn payment_required(detail: impl Into) -> Self { + Self { + type_uri: PAYMENT_REQUIRED.type_uri, + title: PAYMENT_REQUIRED.title, + status: PAYMENT_REQUIRED.status, + detail: detail.into(), + } + } + + /// Serialize to the JSON body string. Infallible for these owned fields. + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("Problem always serializes") + } +} + +/// The `Content-Type` every problem body is served under. +pub const PROBLEM_JSON: &str = "application/problem+json"; + +#[cfg(test)] +mod tests { + //! Pins the WHOLE map, documenting the spec choice for each variant + //! (`draft-cashu-charge-01` Errors § + the framework's status table). + + use super::*; + + fn mint_unreachable() -> ChargeError { + ChargeError::MintUnreachable { + mint_url: "https://m.example".into(), + transport_detail: "timeout".into(), + indeterminate: false, + } + } + + #[test] + fn mint_unreachable_is_plain_503_with_no_problem_type() { + // Spec Errors §: mint unreachability is an infrastructure failure and + // carries no problem type — a plain 503 (+ Retry-After at the hosts), + // body about:blank. No cashu/ URI exists. + let m = problem_mapping(&mint_unreachable()); + assert_eq!(m.slug, None); + assert_eq!(m.type_uri, "about:blank"); + assert_eq!(m.status, 503); + } + + #[test] + fn payment_insufficient_is_the_framework_type_402() { + // Spec step 8 + Errors §: an under-funded token is the framework's + // payment-insufficient. Over-funding has no counterpart (accepted). + let m = problem_mapping(&ChargeError::PaymentInsufficient { + required: 10, + presented: 8, + amount: 10, + expected_swap_fee: 0, + }); + assert_eq!(m.slug, Some("payment-insufficient")); + assert_eq!( + m.type_uri, + "https://paymentauth.org/problems/payment-insufficient" + ); + assert_eq!(m.status, 402); + } + + #[test] + fn verification_failures_share_the_framework_type_402() { + // Spec Errors §: every non-amount, non-expiry verification check — + // including the fee-policy reject ("unit otherwise disallowed by + // server policy") and a swap-rejected double-spend (step 9). + // + // DELIBERATELY ABSENT: a swap-output DLEQ failure. Spec step 9 + + // §security-dleq make it a mint-trust incident, not a payment failure + // ("The server MUST NOT fail the payment for it: it SHOULD serve the + // resource, alert the operator, and quarantine the mint") — so the old + // `ChargeError::DleqInvalid` variant was DELETED rather than kept as a + // dead discriminant: no code path can produce a client-facing error + // from that condition anymore. The verdict travels as + // `Redeemed::dleq_ok` on the SUCCESS path instead. + for e in [ + ChargeError::WrongUnit { + expected: "pop_1".into(), + got: "sat".into(), + }, + ChargeError::MintNotAllowed { + got: "https://evil.example".into(), + allowed: vec!["https://m.example".into()], + }, + ChargeError::MintUrlUserinfo { + url: "https://user@mint.example".into(), + }, + ChargeError::LockedToken, + ChargeError::ShortKeysetIdUnresolved { + short_id: "00aabbccddeeff00".into(), + }, + ChargeError::DoubleSpend, + ChargeError::SwapRejected("mint said no".into()), + ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }, + ] { + let m = problem_mapping(&e); + assert_eq!(m.slug, Some("verification-failed"), "{e}"); + assert_eq!( + m.type_uri, + "https://paymentauth.org/problems/verification-failed", + "{e}" + ); + assert_eq!(m.status, 402, "{e}"); + } + } + + #[test] + fn fee_too_high_detail_names_the_policy_not_a_double_spend() { + // The reject must read as a policy denial with an honest detail — + // never as a double-spend. + let e = ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 250, + }; + let p = Problem::for_error(&e); + assert!( + p.detail.contains("policy") && p.detail.contains("input_fee_ppk 250"), + "detail must name the fee policy: {}", + p.detail + ); + assert!( + !p.detail.contains("double-spend"), + "a fee reject must not claim a double-spend: {}", + p.detail + ); + } + + #[test] + fn both_expiry_causes_share_payment_expired_402() { + // Spec: stale challenge echo AND keyset retirement/final_expiry both + // map to payment-expired (the client needs no discriminator). + for e in [ChargeError::Expired, ChargeError::ChallengeExpired] { + let m = problem_mapping(&e); + assert_eq!(m.slug, Some("payment-expired"), "{e}"); + assert_eq!(m.type_uri, "https://paymentauth.org/problems/payment-expired"); + assert_eq!(m.status, 402, "{e}"); + } + } + + #[test] + fn invalid_challenge_is_402() { + let m = problem_mapping(&ChargeError::InvalidChallenge); + assert_eq!(m.slug, Some("invalid-challenge")); + assert_eq!(m.type_uri, "https://paymentauth.org/problems/invalid-challenge"); + assert_eq!(m.status, 402); + } + + #[test] + fn malformed_credential_proof_cap_and_multi_mint_are_402_not_400() { + // Framework status table: a malformed CREDENTIAL is a 402 (it is still + // a payment attempt, answered with a fresh challenge); the spec folds + // the proof-count DoS bound AND the multi-mint/unit parse-level fault + // into the same type (a valid TokenV4 carries exactly one mint/unit). + for e in [ + ChargeError::MalformedCredential("bad base64".into()), + ChargeError::MultiMintOrUnit, + ChargeError::TooManyProofs { got: 99, max: 8 }, + ] { + let m = problem_mapping(&e); + assert_eq!(m.slug, Some("malformed-credential"), "{e}"); + assert_eq!( + m.type_uri, + "https://paymentauth.org/problems/malformed-credential" + ); + assert_eq!(m.status, 402, "{e}"); + } + } + + #[test] + fn method_unsupported_is_400_with_its_own_framework_type() { + // Spec Errors §: a credential naming an unsupported method follows the + // framework's status handling — method-unsupported, HTTP 400, not a + // 402 malformed-credential. + let m = problem_mapping(&ChargeError::MethodUnsupported { + method: "tempo".into(), + }); + assert_eq!(m.slug, Some("method-unsupported")); + assert_eq!(m.type_uri, "https://paymentauth.org/problems/method-unsupported"); + assert_eq!(m.status, 400); + } + + #[test] + fn malformed_request_is_400_about_blank_without_the_invalid_challenge_slug() { + // The framework 400s a malformed request frame but registers NO problem + // type for it; RFC 9457's about:blank is the correct `type`. The old + // pairing (invalid-challenge slug on a 400) violated both halves: + // invalid-challenge is a 402 type about the challenge echo. + let m = problem_mapping(&ChargeError::MalformedRequest("two credentials".into())); + assert_eq!(m.slug, None); + assert_eq!(m.type_uri, "about:blank"); + assert_eq!(m.status, 400); + assert_ne!(m.slug, Some("invalid-challenge")); + } + + #[test] + fn payment_required_constant_matches_the_framework_registry() { + assert_eq!(PAYMENT_REQUIRED.slug, Some("payment-required")); + assert_eq!( + PAYMENT_REQUIRED.type_uri, + "https://paymentauth.org/problems/payment-required" + ); + assert_eq!(PAYMENT_REQUIRED.status, 402); + } + + #[test] + fn every_type_uri_is_absolute_and_no_cashu_namespace_remains() { + // RFC 9457 `type` is a URI reference; the spec demands ABSOLUTE URIs + // (about:blank included — it has a scheme). The method defines no + // problem types of its own, so no cashu/-namespaced URI may exist. + let all = [ + problem_mapping(&mint_unreachable()), + problem_mapping(&ChargeError::DoubleSpend), + problem_mapping(&ChargeError::Expired), + problem_mapping(&ChargeError::InvalidChallenge), + problem_mapping(&ChargeError::MalformedCredential("x".into())), + problem_mapping(&ChargeError::MultiMintOrUnit), + problem_mapping(&ChargeError::MethodUnsupported { method: "x".into() }), + problem_mapping(&ChargeError::MalformedRequest("x".into())), + problem_mapping(&ChargeError::PaymentInsufficient { + required: 2, + presented: 1, + amount: 2, + expected_swap_fee: 0, + }), + PAYMENT_REQUIRED, + ]; + for m in all { + assert!( + m.type_uri.starts_with("https://paymentauth.org/problems/") + || m.type_uri == "about:blank", + "non-absolute or off-registry type URI: {}", + m.type_uri + ); + assert!( + !m.type_uri.contains("/problems/cashu/"), + "no cashu/-namespaced problem type may remain: {}", + m.type_uri + ); + } + } + + #[test] + fn problem_body_mirrors_the_mapping() { + let e = ChargeError::DoubleSpend; + let p = Problem::for_error(&e); + let m = problem_mapping(&e); + assert_eq!(p.type_uri, m.type_uri); + assert_eq!(p.title, m.title); + assert_eq!(p.status, m.status); + assert_eq!(p.detail, e.to_string()); + let v: serde_json::Value = serde_json::from_str(&p.to_json()).expect("valid JSON"); + assert_eq!(v["type"], m.type_uri); + assert_eq!(v["status"], m.status); + } +} diff --git a/crates/pops-core-verify/src/redeemer.rs b/crates/pops-core-verify/src/redeemer.rs index 3c31a62..03a3a9a 100644 --- a/crates/pops-core-verify/src/redeemer.rs +++ b/crates/pops-core-verify/src/redeemer.rs @@ -19,7 +19,8 @@ use serde::{Deserialize, Serialize}; /// What the verifier requires from a holder for a single charge, decoupled /// from any ecash type (the cashu-typed sibling is /// [`CashuRequirement`][crate::challenge::CashuRequirement], used only to -/// build the `creqA`). All fields are plain data. +/// build the `creqA`). All fields are plain data. `amount` is the minimum +/// net value a credential must cover; excess is retained. /// /// `Serialize`/`Deserialize` so the wasm `verify_and_redeem` export can accept /// the requirement as a JSON string from the JS route (the cross-slice seam @@ -27,7 +28,7 @@ use serde::{Deserialize, Serialize}; /// `#[serde(default)]` so a route may omit them. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChargeRequirement { - /// Exact amount of value required (net the server must receive). + /// Amount of value required (the minimum net the server must receive). pub amount: u64, /// Currency unit the presented credential must carry. For PoP this is /// `pop_`. @@ -56,11 +57,20 @@ pub struct ChargeRequirement { pub struct Redeemed { /// Unit of the redeemed value (echoes the requirement's `unit`). pub unit: String, - /// Net value the operator received (the requested `amount`). + /// Net value the operator received: at least the requirement's `amount` + /// (excess presented value is retained, so this MAY exceed it). pub amount: u64, /// The cross-slice redeemed-proofs payload (fresh proofs, active keyset, /// token hash) — `crate::charge::RedeemedProofs`. pub proofs: RedeemedProofs, + /// Verdict of the redemption-output integrity check (for cashu: NUT-12 + /// DLEQ on the swap-RETURNED blind signatures). `false` is a SOURCE-trust + /// incident, not a payment failure (`draft-cashu-charge-01` + /// §security-dleq): the payment settled and the resource is served; hosts + /// surface this flag (it rides the middleware's `Extension`) so + /// the operator can alert and quarantine the source. Implementations + /// without such a check report `true`. + pub dleq_ok: bool, } /// Verify a presented credential against a [`ChargeRequirement`] and, on @@ -88,12 +98,17 @@ pub trait Redeemer { /// 1. **Atomic redeem** — the value is redeemed in one atomic operation; /// partial redemption is impossible. On any failure the credential is /// left unspent at its source (no value-loss). - /// 2. **Output-DLEQ verified** — the returned proofs are verified (NUT-12 - /// DLEQ for cashu) before being treated as value: a malicious or buggy - /// source cannot make the redeemer accept unsigned outputs. - /// 3. **Exact amount** — `Redeemed.amount` is the net value received AND - /// equals `req.amount`. Both overpay and underpay return a - /// [`ChargeError`]; neither is silently accepted. + /// 2. **Output integrity verified and REPORTED** — the returned proofs' + /// integrity check (NUT-12 DLEQ for cashu) always RUNS, and its verdict + /// is returned as [`Redeemed::dleq_ok`]. A failed verdict MUST NOT fail + /// the redemption (`draft-cashu-charge-01` §security-dleq: the + /// credential was already consumed by the successful redemption, so + /// erroring would both destroy the value and fail a settled payment); + /// it is surfaced to the operator instead (flag + WARN log). + /// 3. **Value covered** — `Redeemed.amount` is the net value received and + /// is at least `req.amount`. An under-funded credential returns a + /// [`ChargeError`]; value above the requirement is accepted and + /// retained (the spec's no-change model), surfaced in `Redeemed.amount`. /// 4. **Double-spend caught** — an already-spent or replayed credential /// returns a [`ChargeError`]. /// 5. **Unit + mint match** — the credential's unit and source satisfy diff --git a/crates/pops-core-verify/src/swap_ceremony.rs b/crates/pops-core-verify/src/swap_ceremony.rs index 799d33d..49babd5 100644 --- a/crates/pops-core-verify/src/swap_ceremony.rs +++ b/crates/pops-core-verify/src/swap_ceremony.rs @@ -18,60 +18,66 @@ use cashu::nuts::nut12; use cashu::nuts::ProofsMethods; use cashu::{MintUrl, Proofs}; -use crate::mint_client::MintClientError; - -/// THE money-safety gate: STRICTLY verify every swap-output blind signature's -/// NUT-12 DLEQ against the active keyset's signing key BEFORE the outputs are -/// unblinded. `cashu::dhke::construct_proofs` silently accepts a `dleq: None` -/// signature, so without this a malicious/buggy mint could return UNSIGNED (or -/// wrong-key) outputs and the verifier would treat the resulting proofs as -/// redeemed bearer value — and the gateway would serve the resource against them. +use crate::mint_client::{MintClientError, SwapOutcome}; + +/// STRICTLY verify every swap-output blind signature's NUT-12 DLEQ against the +/// active keyset's signing key. `cashu::dhke::construct_proofs` silently +/// accepts a `dleq: None` signature, so this is the ONLY place the mint is held +/// to proving it signed the outputs with its advertised key. +/// +/// The verdict is a FLAG, not a gate (`draft-cashu-charge-01` verification +/// step 9 + §security-dleq): by the time these signatures exist the swap +/// SUCCEEDED — the presented inputs are consumed and cannot be restored — so a +/// failure here is a mint-trust incident the caller reports (WARN + the +/// [`SwapOutcome::dleq_ok`] flag), never a payment failure. Failing the request +/// instead would both destroy the redeemed value (outputs discarded, inputs +/// spent) and 402 a client whose payment settled, violating the spec's +/// consume-once rule. /// -/// STRICT, not lenient: a redeemed-VALUE path MUST NOT tolerate a missing DLEQ. -/// A [`nut12::Error::MissingDleqProof`] is a HARD REJECT here (an offline wallet -/// check may treat it as acceptable; this path cannot). +/// STRICT in what it checks: a [`nut12::Error::MissingDleqProof`] fails the +/// verdict exactly like a wrong-key proof (an offline wallet check may tolerate +/// a missing DLEQ; a redeemed-value verdict cannot call one "ok"). /// /// Pairs each signature with its blinded message by position — `construct_proofs` /// consumes signatures and secrets in lockstep, so the same positional zip is -/// the correct `B_`. A count mismatch is itself a reject. +/// the correct `B_`. A count mismatch fails the verdict (no pairing exists +/// under which the unpaired tail could have been verified). /// -/// Any failure returns [`MintClientError::SwapOutputDleqInvalid`] (NOT -/// `RejectedSwap`): the contract distinguishes an omitted/forged output DLEQ -/// from an ordinary swap refusal. +/// `Err` carries the human-readable failure detail for the operator log. fn verify_swap_output_dleq( signatures: &[BlindSignature], pre_mint: &PreMintSecrets, keys: &Keys, -) -> Result<(), MintClientError> { - // A count mismatch ⇒ we cannot pair every signature with its blinded message; - // reject rather than verify a prefix and unblind an unverified tail. +) -> Result<(), String> { + // A count mismatch ⇒ we cannot pair every signature with its blinded + // message, so no signature can be called verified. if signatures.len() != pre_mint.secrets.len() { - return Err(MintClientError::SwapOutputDleqInvalid(format!( + return Err(format!( "swap returned {} blind signatures but {} were requested", signatures.len(), pre_mint.secrets.len() - ))); + )); } for (sig, pre_mint_secret) in signatures.iter().zip(pre_mint.secrets.iter()) { // No advertised key for this amount ⇒ the mint signed an amount it never - // published a key for — reject. + // published a key for — nothing to verify the DLEQ against. let key = keys.amount_key(sig.amount).ok_or_else(|| { - MintClientError::SwapOutputDleqInvalid(format!( + format!( "active keyset has no key for swap-output amount {}", sig.amount - )) + ) })?; - // STRICT: both present-but-invalid AND missing reject here. + // STRICT: both present-but-invalid AND missing fail the verdict. sig.verify_dleq(key, pre_mint_secret.blinded_message.blinded_secret) .map_err(|e| match e { - nut12::Error::MissingDleqProof => MintClientError::SwapOutputDleqInvalid( + nut12::Error::MissingDleqProof => { "mint omitted the DLEQ proof on a swap-output blind signature \ (cannot prove the outputs were signed with the advertised key)" - .to_string(), - ), - other => MintClientError::SwapOutputDleqInvalid(other.to_string()), + .to_string() + } + other => other.to_string(), })?; } @@ -166,10 +172,12 @@ async fn resolve_output_keyset( .clone(); if active_keyset.input_fee_ppk != 0 { - return Err(MintClientError::RejectedSwap(format!( - "active keyset {} has non-zero input_fee_ppk; PoP v1 requires zero fee", - active_keyset.id - ))); + // A policy reject, raised BEFORE any swap is submitted (the token is + // not consumed) — typed so it never surfaces as a double-spend. + return Err(MintClientError::FeeTooHigh { + keyset_id: active_keyset.id.to_string(), + input_fee_ppk: active_keyset.input_fee_ppk, + }); } let active_keyset_full = http.get_keyset_keys(mint_url, active_keyset.id).await?; @@ -193,15 +201,19 @@ async fn resolve_output_keyset( /// the [`MintHttp`] seam, so native and wasm share this body. The blinding RNG is /// why the `wasm` feature must select a js `getrandom` backend. /// -/// MONEY-SAFETY INVARIANT: the DLEQ verification (`verify_swap_output_dleq`) -/// runs BEFORE the unblind, so this never returns proofs whose swap-output DLEQ -/// was not verified. A DLEQ failure surfaces as -/// [`MintClientError::SwapOutputDleqInvalid`]. +/// MONEY-SAFETY INVARIANT: the swap-output DLEQ verification +/// (`verify_swap_output_dleq`) ALWAYS runs, and its verdict is returned as +/// [`SwapOutcome::dleq_ok`]. A failed verdict does NOT fail the ceremony +/// (`draft-cashu-charge-01` §security-dleq: a mint-trust incident, not a +/// payment failure — the inputs were consumed by the successful swap, so +/// erroring here would destroy the redeemed value AND fail a settled payment). +/// It is logged at WARN naming the mint so the operator can alert and +/// quarantine. pub async fn swap_to_redeem( http: &H, mint_url: &MintUrl, proofs: Proofs, -) -> Result { +) -> Result { if proofs.is_empty() { // Defensive: the validator already short-circuits on TokenEmpty; surface // as RejectedSwap rather than make a wasted call. @@ -240,7 +252,7 @@ pub async fn swap_to_redeem( // consumed them though we never read a response), so re-tag a determinate // `Unreachable` from THIS call as `UnreachableIndeterminate`. The pre-POST // GETs keep plain `Unreachable` (no inputs submitted → retry is - // authoritative); `RejectedSwap` / `SwapOutputDleqInvalid` are definitive. + // authoritative); `RejectedSwap` is definitive. let response = http .post_swap(mint_url, swap_request) .await @@ -249,10 +261,24 @@ pub async fn swap_to_redeem( other => other, })?; - // MONEY-SAFETY GATE — must precede construct_proofs (see - // `verify_swap_output_dleq`): without it, a `dleq: None` signature would be - // silently unblinded and treated as redeemed bearer value. - verify_swap_output_dleq(&response.signatures, &pre_mint, &active_keys)?; + // The swap has SUCCEEDED: the inputs are spent. Verify the returned + // signatures' DLEQ and record the verdict (see `verify_swap_output_dleq` — + // a flag per §security-dleq, never an error), then unblind regardless: the + // outputs are the only artifact of the consumed value. + let dleq_ok = match verify_swap_output_dleq(&response.signatures, &pre_mint, &active_keys) { + Ok(()) => true, + Err(detail) => { + tracing::warn!( + mint_url = %mint_url, + detail = %detail, + "swap-output DLEQ missing or invalid — mint-trust incident \ + (draft-cashu-charge-01 §security-dleq): payment is settled and \ + the resource will be served; alert the operator and quarantine \ + this mint pending investigation" + ); + false + } + }; let new_proofs = construct_proofs( response.signatures, @@ -262,18 +288,24 @@ pub async fn swap_to_redeem( ) .map_err(|e| MintClientError::RejectedSwap(e.to_string()))?; - Ok(new_proofs) + Ok(SwapOutcome { + proofs: new_proofs, + dleq_ok, + }) } #[cfg(test)] mod tests { - //! Money-safety tests for the swap-output DLEQ gate. A mock [`MintHttp`] signs - //! the blinded outputs as a real mint would, then attaches VALID, MISSING, or - //! PRESENT-BUT-INVALID DLEQ. Invariant under test: NO redeemed proofs unless - //! every swap-output signature carried a DLEQ that verifies against the - //! mint's advertised key. + //! Money-safety tests for the swap-output DLEQ verdict. A mock [`MintHttp`] + //! signs the blinded outputs as a real mint would, then attaches VALID, + //! MISSING, or PRESENT-BUT-INVALID DLEQ. Invariants under test + //! (`draft-cashu-charge-01` step 9 + §security-dleq): the redeemed value is + //! returned in EVERY DLEQ mode (the swap consumed the inputs; discarding + //! outputs would destroy value), `dleq_ok` is `true` iff every signature's + //! DLEQ verified, and a failed verdict WARNs naming the mint. use std::str::FromStr; + use std::sync::{Arc, Mutex}; use cashu::dhke::{hash_to_curve, sign_message}; use cashu::nuts::nut00::{BlindSignature, Proof}; @@ -324,6 +356,8 @@ mod tests { struct MockMint { unit: CurrencyUnit, mode: DleqMode, + /// `input_fee_ppk` the keyset advertises (0 = the fee-free profile). + fee_ppk: u64, } impl MockMint { @@ -331,6 +365,15 @@ mod tests { Self { unit: CurrencyUnit::Custom("pop_1700000000".to_string()), mode, + fee_ppk: 0, + } + } + + /// As [`Self::new`] but the keyset advertises a non-zero fee. + fn with_fee(mode: DleqMode, fee_ppk: u64) -> Self { + Self { + fee_ppk, + ..Self::new(mode) } } @@ -373,7 +416,7 @@ mod tests { id: self.keyset_id(), unit: self.unit.clone(), active: true, - input_fee_ppk: 0, + input_fee_ppk: self.fee_ppk, final_expiry: None, }], }) @@ -484,59 +527,152 @@ mod tests { vec![input_proof(8, 0, id), input_proof(2, 1, id)] } + /// A minimal subscriber capturing WARN-and-above events as formatted + /// strings (field=value pairs), so a test can assert the operator-facing + /// log without pulling in a subscriber crate. + struct WarnCapture { + events: Arc>>, + } + + impl tracing::Subscriber for WarnCapture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + *metadata.level() <= tracing::Level::WARN + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct Collect(String); + impl tracing::field::Visit for Collect { + fn record_debug( + &mut self, + field: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + use std::fmt::Write; + let _ = write!(self.0, "{}={:?} ", field.name(), value); + } + } + let mut collected = Collect(String::new()); + event.record(&mut collected); + self.events.lock().expect("capture lock").push(collected.0); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} + } + #[tokio::test] - async fn swap_valid_dleq_happy_path_redeems() { + async fn swap_valid_dleq_happy_path_redeems_with_flag_true() { let mint = MockMint::new(DleqMode::Valid); let proofs = inputs_for(&mint); - let redeemed = swap_to_redeem(&mint, &mint_url(), proofs) + let outcome = swap_to_redeem(&mint, &mint_url(), proofs) .await .expect("valid-DLEQ swap must redeem"); - let total: u64 = redeemed.iter().map(|p| u64::from(p.amount)).sum(); + let total: u64 = outcome.proofs.iter().map(|p| u64::from(p.amount)).sum(); assert_eq!( total, 10, "redeemed value must equal the swapped input total" ); - assert!(!redeemed.is_empty(), "happy path must yield proofs"); + assert!(!outcome.proofs.is_empty(), "happy path must yield proofs"); + assert!( + outcome.dleq_ok, + "every signature carried a valid DLEQ, so the verdict is ok" + ); } #[tokio::test] - async fn swap_missing_dleq_rejects_and_yields_no_proofs() { - // `dleq: None` signatures. A redeemed-value path does NOT tolerate a - // missing DLEQ, so the gate must REJECT with no proofs. - let mint = MockMint::new(DleqMode::Missing); - let proofs = inputs_for(&mint); - - let err = swap_to_redeem(&mint, &mint_url(), proofs) - .await - .expect_err("missing swap-output DLEQ MUST be rejected"); + async fn swap_missing_dleq_serves_value_with_flag_false_and_warns() { + // `dleq: None` signatures. Step 9: "a failed or missing DLEQ proof + // after a successful swap is a mint-trust incident, not a payment + // failure" — the value is still redeemed (the swap consumed the + // inputs), the verdict flag is false, and the operator is warned with + // the mint named. + let events = Arc::new(Mutex::new(Vec::new())); + let _guard = tracing::subscriber::set_default(WarnCapture { + events: events.clone(), + }); + + let captured = |events: &Arc>>| { + events + .lock() + .expect("capture lock") + .iter() + .any(|w| w.contains("mint.example.com") && w.contains("omitted")) + }; - match err { - MintClientError::SwapOutputDleqInvalid(msg) => { - assert!( - msg.contains("omitted"), - "missing-DLEQ rejection should name the omission, got: {msg}" - ); + // tracing caches per-callsite interest GLOBALLY: a parallel test's + // cold hit on this same warn! callsite can race this thread's + // dispatcher registration and cache `never`. Rebuilding the cache and + // retrying bounds that race out without weakening the assertion — a + // genuinely missing WARN fails every attempt. + for _ in 0..5 { + tracing::callsite::rebuild_interest_cache(); + + let mint = MockMint::new(DleqMode::Missing); + let proofs = inputs_for(&mint); + let outcome = swap_to_redeem(&mint, &mint_url(), proofs) + .await + .expect("missing swap-output DLEQ must NOT fail the redemption"); + + let total: u64 = outcome.proofs.iter().map(|p| u64::from(p.amount)).sum(); + assert_eq!(total, 10, "the consumed inputs' value must be redeemed"); + assert!(!outcome.dleq_ok, "missing DLEQ ⇒ verdict false"); + + if captured(&events) { + break; } - other => panic!("expected SwapOutputDleqInvalid, got {other:?}"), } + + let warns = events.lock().expect("capture lock"); + assert!( + warns + .iter() + .any(|w| w.contains("mint.example.com") && w.contains("omitted")), + "a WARN naming the mint and the omission must fire, got: {warns:?}" + ); } #[tokio::test] - async fn swap_invalid_dleq_rejects_and_yields_no_proofs() { - // Present-but-invalid: DLEQ proved against the wrong key. Must reject. + async fn swap_invalid_dleq_serves_value_with_flag_false() { + // Present-but-invalid: DLEQ proved against the wrong key. Same + // serve-and-flag outcome as missing. let mint = MockMint::new(DleqMode::InvalidWrongKey); let proofs = inputs_for(&mint); - let err = swap_to_redeem(&mint, &mint_url(), proofs) + let outcome = swap_to_redeem(&mint, &mint_url(), proofs) .await - .expect_err("present-but-invalid swap-output DLEQ MUST be rejected"); + .expect("invalid swap-output DLEQ must NOT fail the redemption"); - assert!( - matches!(err, MintClientError::SwapOutputDleqInvalid(_)), - "expected SwapOutputDleqInvalid, got {err:?}" - ); + let total: u64 = outcome.proofs.iter().map(|p| u64::from(p.amount)).sum(); + assert_eq!(total, 10, "the consumed inputs' value must be redeemed"); + assert!(!outcome.dleq_ok, "wrong-key DLEQ ⇒ verdict false"); + } + + #[tokio::test] + async fn fee_bearing_keyset_rejects_as_fee_too_high_before_swap() { + // A non-zero `input_fee_ppk` on the active keyset is a POLICY reject: + // its own typed error (never RejectedSwap → never read as a + // double-spend), raised before any swap is submitted. + let mint = MockMint::with_fee(DleqMode::Valid, 100); + let proofs = inputs_for(&mint); + + let err = swap_to_redeem(&mint, &mint_url(), proofs) + .await + .expect_err("a fee-bearing keyset must be rejected"); + match err { + MintClientError::FeeTooHigh { + keyset_id, + input_fee_ppk, + } => { + assert_eq!(input_fee_ppk, 100, "carries the published fee"); + assert!(!keyset_id.is_empty(), "names the offending keyset"); + } + other => panic!("expected FeeTooHigh, got {other:?}"), + } } /// A mock that fails a chosen call with `Unreachable`, to prove the ceremony @@ -622,19 +758,65 @@ mod tests { } #[tokio::test] - async fn swap_one_missing_dleq_in_batch_rejects_whole() { - // A valid batch with a SINGLE unsigned output smuggled in must still - // reject the entire swap (no partial redemption). + async fn input_keyset_unknown_at_mint_rejects_before_any_swap_post() { + // Inputs on a keyset the mint's published list does not contain: the + // ceremony's pre-POST resolution rejects as a definitive RejectedSwap + // (the validator's verification-failed family — the keyset cannot be + // resolved, so neither its unit nor its fee is checkable) and the swap + // POST is never reached. The wrapper fails post_swap with Unreachable, + // which the ceremony would re-tag indeterminate — so the RejectedSwap + // assertion below doubles as proof the POST was never attempted. + let inner = MockMint::new(DleqMode::Valid); + let foreign_id = { + let mut bytes = [0u8; 32]; + bytes[31] = 0x42; + let k = SecretKey::from_slice(&bytes).expect("non-zero scalar"); + let keys = Keys::new([(Amount::from(1u64), k.public_key())].into_iter().collect()); + Id::v1_from_keys(&keys) + }; + assert_ne!(foreign_id, inner.keyset_id(), "the input id must be foreign"); + let proofs = vec![input_proof(8, 0, foreign_id), input_proof(2, 1, foreign_id)]; + let mint = TransportFailMint { + inner, + fail_on_post_swap: true, + fail_on_keysets: false, + }; + + let err = swap_to_redeem(&mint, &mint_url(), proofs) + .await + .expect_err("an unknown input keyset must be rejected"); + match err { + MintClientError::RejectedSwap(msg) => { + assert!( + msg.contains("unknown at mint"), + "the rejection must name the unresolvable input keyset, got: {msg}" + ); + assert!( + msg.contains(&foreign_id.to_string()), + "the rejection must name the offending keyset id, got: {msg}" + ); + } + other => panic!("expected a pre-POST RejectedSwap, got {other:?}"), + } + } + + #[tokio::test] + async fn swap_one_missing_dleq_in_batch_flags_whole_outcome() { + // A valid batch with a SINGLE unsigned output smuggled in: the verdict + // covers the WHOLE batch (one unproven signature ⇒ dleq_ok false), but + // the redemption still completes — no partial anything. let mint = MockMint::new(DleqMode::ValidButOneMissing); let proofs = inputs_for(&mint); - let err = swap_to_redeem(&mint, &mint_url(), proofs) + let outcome = swap_to_redeem(&mint, &mint_url(), proofs) .await - .expect_err("one missing DLEQ in the batch MUST reject the whole swap"); + .expect("a partially-unproven batch must still redeem"); + let total: u64 = outcome.proofs.iter().map(|p| u64::from(p.amount)).sum(); + assert_eq!(total, 10, "the consumed inputs' value must be redeemed"); assert!( - matches!(err, MintClientError::SwapOutputDleqInvalid(_)), - "expected SwapOutputDleqInvalid, got {err:?}" + !outcome.dleq_ok, + "one missing DLEQ in the batch must flag the whole outcome" ); } } diff --git a/crates/pops-core-verify/src/wasm.rs b/crates/pops-core-verify/src/wasm.rs index bdf92aa..8478817 100644 --- a/crates/pops-core-verify/src/wasm.rs +++ b/crates/pops-core-verify/src/wasm.rs @@ -12,15 +12,17 @@ //! the NUT-03 swap, with HTTP performed by the injected-`fetch` //! [`WasmMintClient`][crate::wasm_mint_client::WasmMintClient]. It is async //! (returns a `Promise`) and resolves to a structured JS object on success -//! or REJECTS with a structured `{ ok:false, code, message }` so the JS -//! route can map the [`ChargeError`] discriminant to an HTTP status -//! (402 / 503 / 400). +//! or REJECTS with a structured +//! `{ ok:false, code, message, status, problem_type, problem_slug }` — the +//! fine-grained [`ChargeError`] discriminant plus the single-sourced +//! [`crate::problem`] mapping (spec status + absolute problem-type URI). use crate::charge::ChargeError; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::future_to_promise; use crate::cashu_credential::CashuCredential; +use crate::problem::problem_mapping; use crate::redeemer::{ChargeRequirement, Redeemer}; use crate::envelope::{ decode_request_object as core_decode_request_object, encode_payment_credentials, @@ -47,7 +49,7 @@ pub fn parse_payment_params(www_authenticate: &str) -> Result { /// Decode the base64url-nopad `request` auth-param into the JSON /// `draft-cashu-charge-01` request object (`{amount, currency, description?, -/// externalId?, methodDetails:{request, mints}}`). +/// externalId?, methodDetails:{paymentRequest}}`). #[wasm_bindgen] pub fn decode_request_object(b64: &str) -> Result { let object = core_decode_request_object(b64).map_err(js_err)?; @@ -95,37 +97,63 @@ pub fn build_payment_credential(credentials_json: &str) -> Result &'static str { match e { ChargeError::MintUnreachable { .. } => "mint-unreachable", - ChargeError::AmountMismatch { .. } => "amount-mismatch", + ChargeError::PaymentInsufficient { .. } => "payment-insufficient", ChargeError::WrongUnit { .. } => "wrong-unit", ChargeError::MintNotAllowed { .. } => "mint-not-allowed", + ChargeError::MintUrlUserinfo { .. } => "mint-url-userinfo", ChargeError::MultiMintOrUnit => "multi-mint-or-unit", ChargeError::LockedToken => "locked-token", - ChargeError::DleqInvalid => "dleq-invalid", + ChargeError::FeeTooHigh { .. } => "fee-too-high", ChargeError::ShortKeysetIdUnresolved { .. } => "short-keyset-id-unresolved", ChargeError::DoubleSpend => "double-spend", + ChargeError::SwapRejected(_) => "swap-rejected", ChargeError::Expired => "expired", ChargeError::ChallengeExpired => "challenge-expired", ChargeError::InvalidChallenge => "invalid-challenge", ChargeError::MalformedCredential(_) => "malformed-credential", + ChargeError::MethodUnsupported { .. } => "method-unsupported", ChargeError::MalformedRequest(_) => "malformed-request", ChargeError::TooManyProofs { .. } => "too-many-proofs", } } -/// Build the structured rejection value `{ ok:false, code, message }` for a -/// [`ChargeError`]. `code` is the stable discriminant; `message` is the -/// human-readable `Display`. +/// Build the structured rejection value +/// `{ ok:false, code, message, status, problem_type, problem_slug }` for a +/// [`ChargeError`]. `code` is the fine-grained discriminant and `message` the +/// human-readable `Display`; `status` (number), `problem_type` (absolute URI), +/// and `problem_slug` (string or null) come from the single-sourced +/// [`crate::problem`] map, so a JS route emits the same wire as the native +/// hosts without re-deriving the mapping. fn charge_error_to_js(e: &ChargeError) -> JsValue { + let mapping = problem_mapping(e); let obj = js_sys::Object::new(); let _ = js_sys::Reflect::set(&obj, &"ok".into(), &JsValue::FALSE); let _ = js_sys::Reflect::set(&obj, &"code".into(), &JsValue::from_str(charge_error_code(e))); let _ = js_sys::Reflect::set(&obj, &"message".into(), &JsValue::from_str(&e.to_string())); + let _ = js_sys::Reflect::set(&obj, &"status".into(), &JsValue::from_f64(mapping.status.into())); + let _ = js_sys::Reflect::set( + &obj, + &"problem_type".into(), + &JsValue::from_str(mapping.type_uri), + ); + let _ = js_sys::Reflect::set( + &obj, + &"problem_slug".into(), + &mapping.slug.map(JsValue::from_str).unwrap_or(JsValue::NULL), + ); obj.into() } @@ -139,9 +167,15 @@ fn charge_error_to_js(e: &ChargeError) -> JsValue { /// via `globalThis.fetch` against the token's mint. /// /// Returns a `Promise` that RESOLVES to -/// `{ ok:true, fresh_proofs, amount, unit, active_keyset_id, token_hash }` on -/// success, or REJECTS with `{ ok:false, code, message }` carrying the -/// [`ChargeError`] discriminant so the JS route maps 402 / 503 / 400. +/// `{ ok:true, fresh_proofs, amount, unit, active_keyset_id, token_hash, +/// dleq_ok }` on success — `dleq_ok: false` means the swap-returned +/// signatures' NUT-12 DLEQ was missing/invalid, a mint-trust incident the +/// route should alert on while STILL serving (spec §security-dleq) — or +/// REJECTS with +/// `{ ok:false, code, message, status, problem_type, problem_slug }` — the +/// fine-grained [`ChargeError`] discriminant plus the mapped spec status and +/// absolute problem-type URI, so the JS route answers 402 / 503 / 400 with the +/// same problem body the native hosts emit. /// /// A malformed `requirement_json` (server-side config error, never the holder's fault) /// rejects with `code = "malformed-request"`. @@ -181,6 +215,7 @@ pub fn verify_and_redeem(presented_token: &str, requirement_json: &str) -> js_sy &JsValue::from_str(&redeemed.proofs.active_keyset_id), ); set("token_hash", &JsValue::from_str(&redeemed.proofs.token_hash)); + set("dleq_ok", &JsValue::from_bool(redeemed.dleq_ok)); Ok(obj.into()) } Err(e) => Err(charge_error_to_js(&e)), diff --git a/crates/pops-core-verify/src/wasm_mint_client.rs b/crates/pops-core-verify/src/wasm_mint_client.rs index ed17082..9d5abab 100644 --- a/crates/pops-core-verify/src/wasm_mint_client.rs +++ b/crates/pops-core-verify/src/wasm_mint_client.rs @@ -19,6 +19,13 @@ //! [`MintClientError::Unreachable`] (retryable); a 4xx (the mint refused) is //! [`MintClientError::RejectedSwap`]. A 2xx whose body fails to deserialize is //! a definitive `RejectedSwap` (the mint answered with something we can't use). +//! +//! Unlike [`CdkMintClient`][crate::cdk_mint_client::CdkMintClient], this +//! client does not parse NUT error codes out of rejection bodies: a swap the +//! mint refused for keyset retirement or expiry surfaces as `RejectedSwap` +//! and therefore `verification-failed`, where the native host answers +//! `payment-expired` — wasm consumers do not receive the spec's +//! re-present-once signal for that case. use async_trait::async_trait; use cashu::nuts::nut02::{Id, KeySet, KeySetInfo, KeysetResponse}; @@ -29,7 +36,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; -use crate::mint_client::{MintClient, MintClientError}; +use crate::mint_client::{MintClient, MintClientError, SwapOutcome}; use crate::swap_ceremony::{swap_to_redeem, MintHttp}; /// `fetch`-backed [`MintClient`] for wasm32. @@ -221,7 +228,7 @@ impl MintClient for WasmMintClient { &self, mint_url: &MintUrl, proofs: Proofs, - ) -> Result { + ) -> Result { // Same shared ceremony as the native client — only the transport below // it differs. swap_to_redeem(self, mint_url, proofs).await diff --git a/crates/pops-core-verify/tests/charge_conformance.rs b/crates/pops-core-verify/tests/charge_conformance.rs index ac1edd0..af03e5b 100644 --- a/crates/pops-core-verify/tests/charge_conformance.rs +++ b/crates/pops-core-verify/tests/charge_conformance.rs @@ -2,14 +2,16 @@ //! (a separate crate sees the same surface a consumer does). //! //! Covers the conformance bar this build raises: the spec request-object -//! round-trip + JCS-canonical bytes; the credential echo carrying the optional -//! `digest`/`opaque`/`expires`/`source`; the `methodDetails.mints` superset -//! rejection; and — driving the `require_charge` middleware through a router with -//! a canned [`Redeemer`] — the `Payment-Receipt` shape + `Cache-Control: -//! private` on 200, an echoed `challenge.expires` in the past → `payment-expired` -//! `application/problem+json`, and each [`ChargeError`] mapping to its spec -//! problem-type + status. The money core is NOT exercised here (it is unchanged); -//! the canned redeemer stands in so the test isolates the WIRE. +//! round-trip + JCS-canonical bytes (`methodDetails.paymentRequest` only, mints +//! gone from the wire); the emitted creqA carrying `a`/`u`/non-empty-`m`; the +//! credential echo carrying the optional `digest`/`opaque`/`expires`/`source`; +//! and — driving the `require_charge` (and `require_charge_xcashu`) middleware +//! through a router with a canned [`Redeemer`] — the `Payment-Receipt` shape + +//! `Cache-Control: private` on 200, an echoed `challenge.expires` in the past → +//! `payment-expired` `application/problem+json`, each [`ChargeError`] mapping +//! to its ABSOLUTE spec problem-type URI + status, and both in-crate hosts +//! emitting identical mappings. The money core is NOT exercised here (it is +//! unchanged); the canned redeemer stands in so the test isolates the WIRE. use std::str::FromStr; use std::sync::Arc; @@ -92,6 +94,7 @@ impl Redeemer for CannedRedeemer { // A stable, recognizable settlement reference for the receipt. token_hash: format!("hash-of-{}", presented.len()), }, + dleq_ok: true, }), Outcome::Err(make) => Err(make()), } @@ -101,34 +104,70 @@ impl Redeemer for CannedRedeemer { /// A router that gates an echo handler behind `require_charge` with the canned /// redeemer. fn router(outcome: Outcome) -> Router { + router_with_ttl(outcome, None) +} + +/// As [`router`] with an explicit challenge TTL (for the expiry tests). +fn router_with_ttl(outcome: Outcome, ttl: Option) -> Router { async fn echo(Extension(redeemed): Extension) -> String { format!("ok:{}", redeemed.amount) } - let state = Arc::new(ChargeMiddlewareState::new( - requirement(), - CannedRedeemer { outcome }, - )); + let mut state = ChargeMiddlewareState::new(requirement(), CannedRedeemer { outcome }); + if let Some(ttl) = ttl { + state = state.with_challenge_ttl(ttl); + } Router::new() .route("/gated", get(echo)) - .layer(from_fn_with_state(state, require_charge::)) + .layer(from_fn_with_state( + Arc::new(state), + require_charge::, + )) } -/// Build an `Authorization: Payment` header around a token with a shapely echoed -/// challenge; `expires` rides the echo when supplied. -fn auth_header(token: &str, expires: Option<&str>) -> String { +/// Fetch a REAL challenge off the router (bare request → 402) and parse its +/// auth-params. +async fn fetch_challenge(app: &Router) -> pops_core_verify::envelope::PaymentParams { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/gated") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("challenge fetch"); + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + let header = resp + .headers() + .get(http::header::WWW_AUTHENTICATE) + .expect("WWW-Authenticate present") + .to_str() + .expect("ASCII") + .to_string(); + parse_payment_params(&header).expect("challenge params parse") +} + +/// Build the `Authorization: Payment` header echoing `params` verbatim around +/// `token` — the faithful client half of the dance. +fn auth_header_for( + params: &pops_core_verify::envelope::PaymentParams, + token: &str, +) -> String { let creds = PaymentCredentials { challenge: EchoedChallenge { - id: "ch-conf".into(), - realm: "pops-core-verify".into(), - method: "cashu".into(), - intent: "charge".into(), - request: "echoed-request".into(), - digest: None, - opaque: None, - expires: expires.map(str::to_string), + id: params.id.clone(), + realm: params.realm.clone(), + method: params.method.clone(), + intent: params.intent.clone(), + request: params.request.clone(), + digest: params.digest.clone(), + opaque: params.opaque.clone(), + expires: params.expires.clone(), + description: params.description.clone(), }, payload: CashuPayload { - cashu_token: token.into(), + token: token.into(), }, source: None, }; @@ -139,30 +178,36 @@ fn body_json(bytes: &[u8]) -> serde_json::Value { serde_json::from_slice(bytes).expect("body is JSON") } -// ──────────────────── request object (D-3 / D-4 / D-8 mints) ───────────────── +// ──────────────────── request object (spec Request Schema + Encoding) ──────── #[test] fn spec_request_object_round_trips_and_is_jcs_canonical() { // requirement → 402 request param → decoded amount/unit/mints + creqA. - let request = encode_charge_request(&requirement()); + let request = encode_charge_request(&requirement()).expect("requirement encodes"); let decoded = decode_charge_request(&request).expect("request object decodes"); assert_eq!(decoded.amount, Amount::from(100)); assert_eq!(decoded.unit, pop_unit()); - assert_eq!(decoded.mints, vec![mint_a()]); + assert_eq!(decoded.mints, vec![mint_a()], "mints derive from the creqA `m`"); assert_eq!(decoded.external_id.as_deref(), Some("inv-42")); assert!(decoded.creq_a.starts_with("creqA")); // The base64url-nopad payload is the JCS-canonical bytes: keys sorted at both - // levels, ECMAScript number/string forms, no insignificant whitespace. + // levels, ECMAScript number/string forms, no insignificant whitespace, and + // methodDetails carrying exactly ONE field — paymentRequest. (Expected JSON + // hand-written from the spec's Request Schema example.) let obj = decode_request_object(&request).expect("request object struct"); let bytes = URL_SAFE_NO_PAD.decode(&request).expect("base64url decodes"); let json = std::str::from_utf8(&bytes).expect("utf8"); let expected = format!( - r#"{{"amount":"100","currency":"pop_1782668279","description":"read access","externalId":"inv-42","methodDetails":{{"mints":["https://mint.example"],"request":"{}"}}}}"#, - obj.method_details.request + r#"{{"amount":"100","currency":"pop_1782668279","description":"read access","externalId":"inv-42","methodDetails":{{"paymentRequest":"{}"}}}}"#, + obj.method_details.payment_request ); assert_eq!(json, expected, "request object must be JCS-canonical"); + assert!( + !json.contains("\"mints\""), + "methodDetails.mints is deleted from the wire: {json}" + ); // It is base64url-nopad (no '+', '/', '='). for c in request.chars() { @@ -174,27 +219,61 @@ fn spec_request_object_round_trips_and_is_jcs_canonical() { } #[test] -fn request_object_rejects_mints_subset() { - // The creqA names mint.example; methodDetails names a DIFFERENT mint only, so - // it is not a superset → reject (draft-cashu-charge-01 §Request Schema). - let creq = encode_challenge(&requirement()); - let object = RequestObject { - amount: "100".into(), - currency: "pop_1782668279".into(), - description: None, - external_id: None, - method_details: MethodDetails { - request: creq, - mints: vec!["https://other.example".into()], - }, - }; - let encoded = encode_request_object(&object); +fn emitted_creqa_carries_amount_unit_and_nonempty_mints() { + // Spec Method Details: the server MUST encode `a` and `u` and MUST populate + // `m` with a non-empty mint set; transports MUST be empty; nut10 MUST be + // absent. Decode the emitted creqA independently and check each. + use cashu::nuts::nut18::PaymentRequest; + use std::str::FromStr as _; + + let request = encode_charge_request(&requirement()).expect("requirement encodes"); + let obj = decode_request_object(&request).expect("request object struct"); + let creq = PaymentRequest::from_str(&obj.method_details.payment_request) + .expect("paymentRequest is a parseable creqA"); + + assert_eq!(creq.amount, Some(Amount::from(100)), "creqA carries `a`"); + assert_eq!(creq.unit, Some(pop_unit()), "creqA carries `u`"); + assert_eq!(creq.mints, vec![mint_a()], "creqA carries a non-empty `m`"); + assert!(creq.transports.is_empty(), "transport set must be empty (in-band)"); + assert!(creq.nut10.is_none(), "bearer profile: nut10 must be absent"); +} + +#[test] +fn requirement_without_mints_cannot_be_emitted() { + // Emit-side a/u/m enforcement: `m` must be non-empty, so a no-mints + // requirement fails at encode (server misconfiguration, caught early). + let mut req = requirement(); + req.mints = vec![]; assert!( - decode_charge_request(&encoded).is_err(), - "a mints-subset request object must be rejected" + encode_charge_request(&req).is_err(), + "a requirement naming no mints must not encode into a challenge" ); } +#[test] +fn request_object_rejects_top_level_fields_disagreeing_with_creqa() { + // The creqA is authoritative; top-level amount/currency must match it + // (amounts compared as integers). Hand-build a disagreeing object. + let creq = encode_challenge(&requirement()); // a=100, u=pop_1782668279 + for (amount, currency) in [("101", "pop_1782668279"), ("100", "sat")] { + let object = RequestObject { + amount: amount.into(), + currency: currency.into(), + description: None, + external_id: None, + method_details: MethodDetails { + payment_request: creq.clone(), + }, + }; + let encoded = encode_request_object(&object); + assert!( + decode_charge_request(&encoded).is_err(), + "amount/currency disagreeing with the creqA must be rejected \ + (amount={amount}, currency={currency})" + ); + } +} + // ──────────────────── credential echo with optional fields ────────────────── #[test] @@ -209,9 +288,10 @@ fn credential_echo_round_trips_optional_fields() { digest: Some("sha-256-digest".into()), opaque: Some("server-opaque".into()), expires: Some("2999-01-01T00:00:00Z".into()), + description: Some("weather report".into()), }, payload: CashuPayload { - cashu_token: "cashuBtok".into(), + token: "cashuBtok".into(), }, source: Some("did:example:abc".into()), }; @@ -224,7 +304,7 @@ fn credential_echo_round_trips_optional_fields() { Some("2999-01-01T00:00:00Z") ); assert_eq!(parsed.source.as_deref(), Some("did:example:abc")); - assert_eq!(parsed.payload.cashu_token, "cashuBtok"); + assert_eq!(parsed.payload.token, "cashuBtok"); } // ──────────────────────── Payment-Receipt (D-5) ────────────────────────────── @@ -235,11 +315,12 @@ async fn success_emits_payment_receipt_and_cache_control_private() { amount: 100, unit: "pop_1782668279".to_string(), }); + let params = fetch_challenge(&app).await; let resp = app .oneshot( Request::builder() .uri("/gated") - .header(AUTHORIZATION, auth_header("cashuBany", None)) + .header(AUTHORIZATION, auth_header_for(¶ms, "cashuBany")) .body(Body::empty()) .unwrap(), ) @@ -271,7 +352,10 @@ async fn success_emits_payment_receipt_and_cache_control_private() { .expect("Payment-Receipt is base64url-nopad"); let receipt = body_json(&receipt_bytes); assert_eq!(receipt["method"], "cashu"); - assert_eq!(receipt["challengeId"], "ch-conf"); + assert_eq!( + receipt["challengeId"], params.id, + "receipt echoes the issued (HMAC-bound) challenge id" + ); assert_eq!(receipt["status"], "success"); assert!( receipt["reference"].as_str().unwrap().starts_with("hash-of-"), @@ -289,24 +373,78 @@ async fn success_emits_payment_receipt_and_cache_control_private() { assert_eq!(&body[..], b"ok:100"); } +#[tokio::test] +async fn downstream_error_after_settlement_carries_no_receipt() { + // Spec receipt §: the receipt rides the 200 and MUST NOT appear on error + // responses — a handler that 500s after a successful redeem answers + // without `Payment-Receipt` (and without the receipt's + // `Cache-Control: private`). + use axum::response::IntoResponse; + async fn failing(Extension(_redeemed): Extension) -> axum::response::Response { + (StatusCode::INTERNAL_SERVER_ERROR, "downstream boom").into_response() + } + let state = ChargeMiddlewareState::new( + requirement(), + CannedRedeemer { + outcome: Outcome::Ok { + amount: 100, + unit: "pop_1782668279".to_string(), + }, + }, + ); + let app = Router::new() + .route("/gated", get(failing)) + .layer(from_fn_with_state( + Arc::new(state), + require_charge::, + )); + + let params = fetch_challenge(&app).await; + let resp = app + .oneshot( + Request::builder() + .uri("/gated") + .header(AUTHORIZATION, auth_header_for(¶ms, "cashuBany")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + resp.headers().get("payment-receipt").is_none(), + "no Payment-Receipt on an error response" + ); + assert_ne!( + resp.headers() + .get(http::header::CACHE_CONTROL) + .map(|v| v.to_str().unwrap_or_default()), + Some("private"), + "the receipt's Cache-Control: private must not ride an error response" + ); +} + // ─────────────────── challenge.expires in the past (D-6) ────────────────────── #[tokio::test] async fn expired_echoed_challenge_returns_payment_expired_problem() { - // An echoed `expires` in the PAST → payment-expired, BEFORE any redeem. The - // canned redeemer is set to Ok so a 402 here proves the swap never ran. - let app = router(Outcome::Ok { - amount: 100, - unit: "pop_1782668279".to_string(), - }); + // A zero-TTL router issues authentic-but-instantly-stale challenges: the + // faithful echo passes the HMAC, fails freshness → payment-expired BEFORE + // any redeem (the canned redeemer is Ok, so a 402 proves it never ran). + let app = router_with_ttl( + Outcome::Ok { + amount: 100, + unit: "pop_1782668279".to_string(), + }, + Some(std::time::Duration::ZERO), + ); + let params = fetch_challenge(&app).await; let resp = app .oneshot( Request::builder() .uri("/gated") - .header( - AUTHORIZATION, - auth_header("cashuBany", Some("2000-01-01T00:00:00Z")), - ) + .header(AUTHORIZATION, auth_header_for(¶ms, "cashuBany")) .body(Body::empty()) .unwrap(), ) @@ -326,25 +464,28 @@ async fn expired_echoed_challenge_returns_payment_expired_problem() { assert!(resp.headers().get(http::header::WWW_AUTHENTICATE).is_some()); let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); let problem = body_json(&body); - assert_eq!(problem["type"], "cashu/payment-expired"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/payment-expired" + ); assert_eq!(problem["status"], 402); } #[tokio::test] async fn unexpired_echoed_challenge_passes_through() { - // A future `expires` does NOT block the success path. + // The fresh-challenge pass: a faithful echo of an unexpired challenge + // (default 300 s TTL) reaches the redeemer and serves. let app = router(Outcome::Ok { amount: 100, unit: "pop_1782668279".to_string(), }); + let params = fetch_challenge(&app).await; + assert!(params.expires.is_some(), "stateless challenge carries expires"); let resp = app .oneshot( Request::builder() .uri("/gated") - .header( - AUTHORIZATION, - auth_header("cashuBany", Some("2999-01-01T00:00:00Z")), - ) + .header(AUTHORIZATION, auth_header_for(¶ms, "cashuBany")) .body(Body::empty()) .unwrap(), ) @@ -355,14 +496,17 @@ async fn unexpired_echoed_challenge_passes_through() { // ─────────────── each ChargeError → its problem-type + status ──────────────── -/// Drive the middleware with a canned error and return (status, problem json). +/// Drive the `Payment` middleware with a canned error and return +/// (status, problem json). The full dance: fetch a real challenge, echo it +/// faithfully (passing the binding), and let the canned redeemer fail. async fn problem_for(make: fn() -> ChargeError) -> (StatusCode, serde_json::Value) { let app = router(Outcome::Err(make)); + let params = fetch_challenge(&app).await; let resp = app .oneshot( Request::builder() .uri("/gated") - .header(AUTHORIZATION, auth_header("cashuBany", None)) + .header(AUTHORIZATION, auth_header_for(¶ms, "cashuBany")) .body(Body::empty()) .unwrap(), ) @@ -383,9 +527,105 @@ async fn problem_for(make: fn() -> ChargeError) -> (StatusCode, serde_json::Valu (status, body_json(&body)) } +/// Drive the NUT-24 `X-Cashu` middleware with the same canned error and return +/// (status, problem json) — the cross-surface comparison arm. +async fn xcashu_problem_for(make: fn() -> ChargeError) -> (StatusCode, serde_json::Value) { + use pops_core_verify::middleware_xcashu::require_charge_xcashu; + async fn echo(Extension(redeemed): Extension) -> String { + format!("ok:{}", redeemed.amount) + } + let state = Arc::new(ChargeMiddlewareState::new( + requirement(), + CannedRedeemer { + outcome: Outcome::Err(make), + }, + )); + let app = Router::new().route("/gated", get(echo)).layer(from_fn_with_state( + state, + require_charge_xcashu::, + )); + let resp = app + .oneshot( + Request::builder() + .uri("/gated") + .header("x-cashu", "cashuBany") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert_eq!( + resp.headers() + .get(http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "application/problem+json", + "X-Cashu error body must be problem+json (status {status})" + ); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + (status, body_json(&body)) +} + +/// Every wire-distinct [`ChargeError`] case (the canned constructors the +/// surface-equality tests iterate). +fn all_charge_error_cases() -> Vec ChargeError> { + vec![ + || ChargeError::MintUnreachable { + mint_url: "https://mint.example".into(), + transport_detail: "timeout".into(), + indeterminate: false, + }, + || ChargeError::PaymentInsufficient { + required: 100, + presented: 90, + amount: 100, + expected_swap_fee: 0, + }, + || ChargeError::WrongUnit { + expected: "pop_1782668279".into(), + got: "sat".into(), + }, + || ChargeError::MintNotAllowed { + got: "https://evil.example".into(), + allowed: vec!["https://mint.example".into()], + }, + || ChargeError::MintUrlUserinfo { + url: "https://user@mint.example".into(), + }, + || ChargeError::MultiMintOrUnit, + || ChargeError::LockedToken, + || ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }, + || ChargeError::ShortKeysetIdUnresolved { + short_id: "00aabbccddeeff00".into(), + }, + || ChargeError::DoubleSpend, + || ChargeError::SwapRejected("mint said no".into()), + || ChargeError::Expired, + || ChargeError::ChallengeExpired, + || ChargeError::InvalidChallenge, + || ChargeError::MalformedCredential("bad".into()), + || ChargeError::TooManyProofs { got: 99, max: 8 }, + || ChargeError::MethodUnsupported { + method: "tempo".into(), + }, + || ChargeError::MalformedRequest("bad config".into()), + ] +} + #[tokio::test] async fn charge_errors_map_to_spec_problem_types_and_statuses() { - // (problem-type, HTTP status) per draft-cashu-charge-01 §Errors. + // (ABSOLUTE problem-type URI, HTTP status) per draft-cashu-charge-01 + // §Errors + the framework's status table. The method defines NO problem + // types of its own: mint unreachability is a plain 503 (about:blank body, + // no custom URI) and an under-funded token is the framework's + // payment-insufficient; MalformedRequest is a 400 with NO registered type + // (about:blank), never the invalid-challenge slug; a non-"cashu" method is + // the framework's method-unsupported 400. type ErrorCase = (fn() -> ChargeError, &'static str, u16); let cases: Vec = vec![ ( @@ -394,17 +634,17 @@ async fn charge_errors_map_to_spec_problem_types_and_statuses() { transport_detail: "timeout".into(), indeterminate: false, }, - "cashu/mint-unavailable", + "about:blank", 503, ), ( - || ChargeError::AmountMismatch { + || ChargeError::PaymentInsufficient { required: 100, presented: 90, amount: 100, expected_swap_fee: 0, }, - "cashu/amount-mismatch", + "https://paymentauth.org/problems/payment-insufficient", 402, ), ( @@ -412,7 +652,7 @@ async fn charge_errors_map_to_spec_problem_types_and_statuses() { expected: "pop_1782668279".into(), got: "sat".into(), }, - "cashu/verification-failed", + "https://paymentauth.org/problems/verification-failed", 402, ), ( @@ -420,37 +660,86 @@ async fn charge_errors_map_to_spec_problem_types_and_statuses() { got: "https://evil.example".into(), allowed: vec!["https://mint.example".into()], }, - "cashu/verification-failed", + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::MintUrlUserinfo { + url: "https://user@mint.example".into(), + }, + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::MultiMintOrUnit, + "https://paymentauth.org/problems/malformed-credential", + 402, + ), + ( + || ChargeError::LockedToken, + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }, + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::ShortKeysetIdUnresolved { + short_id: "00aabbccddeeff00".into(), + }, + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::DoubleSpend, + "https://paymentauth.org/problems/verification-failed", 402, ), - (|| ChargeError::MultiMintOrUnit, "cashu/verification-failed", 402), - (|| ChargeError::LockedToken, "cashu/verification-failed", 402), ( - || ChargeError::DleqInvalid, - "cashu/verification-failed", + || ChargeError::SwapRejected("mint said no".into()), + "https://paymentauth.org/problems/verification-failed", + 402, + ), + ( + || ChargeError::Expired, + "https://paymentauth.org/problems/payment-expired", + 402, + ), + ( + || ChargeError::ChallengeExpired, + "https://paymentauth.org/problems/payment-expired", 402, ), - (|| ChargeError::DoubleSpend, "cashu/verification-failed", 402), - (|| ChargeError::Expired, "cashu/payment-expired", 402), - (|| ChargeError::ChallengeExpired, "cashu/payment-expired", 402), ( || ChargeError::InvalidChallenge, - "cashu/invalid-challenge", + "https://paymentauth.org/problems/invalid-challenge", 402, ), ( || ChargeError::MalformedCredential("bad".into()), - "cashu/malformed-credential", + "https://paymentauth.org/problems/malformed-credential", 402, ), ( || ChargeError::TooManyProofs { got: 99, max: 8 }, - "cashu/malformed-credential", + "https://paymentauth.org/problems/malformed-credential", 402, ), + ( + || ChargeError::MethodUnsupported { + method: "tempo".into(), + }, + "https://paymentauth.org/problems/method-unsupported", + 400, + ), ( || ChargeError::MalformedRequest("bad config".into()), - "cashu/invalid-challenge", + "about:blank", 400, ), ]; @@ -475,10 +764,40 @@ async fn charge_errors_map_to_spec_problem_types_and_statuses() { } } +#[tokio::test] +async fn payment_and_xcashu_surfaces_emit_identical_mappings() { + // The single-source guarantee, observed END-TO-END: for every ChargeError, + // both in-crate hosts answer with the same (status, type, title, status + // member) — and both equal the shared problem_mapping table the gateway + // and wasm surfaces also consume. + use pops_core_verify::problem::problem_mapping; + for make in all_charge_error_cases() { + let mapping = problem_mapping(&make()); + let (payment_status, payment_problem) = problem_for(make).await; + let (xcashu_status, xcashu_problem) = xcashu_problem_for(make).await; + + assert_eq!( + payment_status, xcashu_status, + "status drift between Payment and X-Cashu hosts for {}", + make() + ); + assert_eq!( + payment_problem, xcashu_problem, + "problem-body drift between Payment and X-Cashu hosts for {}", + make() + ); + assert_eq!(payment_status.as_u16(), mapping.status, "{}", make()); + assert_eq!(payment_problem["type"], mapping.type_uri, "{}", make()); + assert_eq!(payment_problem["title"], mapping.title, "{}", make()); + assert_eq!(payment_problem["status"], mapping.status, "{}", make()); + } +} + #[tokio::test] async fn mint_unreachable_is_503_with_no_store_never_a_402() { // The load-bearing invariant: a transport failure is a 503 (token NOT - // consumed), NEVER collapsed into a 402. + // consumed), NEVER collapsed into a 402 — and per the spec's Errors § it + // carries NO problem type (about:blank body, no cashu/ URI). let (status, problem) = problem_for(|| ChargeError::MintUnreachable { mint_url: "https://mint.example".into(), transport_detail: "connect refused".into(), @@ -486,7 +805,7 @@ async fn mint_unreachable_is_503_with_no_store_never_a_402() { }) .await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(problem["type"], "cashu/mint-unavailable"); + assert_eq!(problem["type"], "about:blank"); } // ─────────────────── bare 402 (no attempt) has no problem body ─────────────── diff --git a/crates/pops-core-verify/tests/custom_redeemer.rs b/crates/pops-core-verify/tests/custom_redeemer.rs index 0c85acc..63c91d4 100644 --- a/crates/pops-core-verify/tests/custom_redeemer.rs +++ b/crates/pops-core-verify/tests/custom_redeemer.rs @@ -9,8 +9,9 @@ //! so the impl stays self-contained. A real impl performs the actual redeem //! (for cashu, the atomic NUT-03 swap with NUT-12 output-DLEQ verification); the //! ledger stands in for that source of truth while preserving every observable -//! guarantee: atomic single-use redemption, exact-amount enforcement, -//! double-spend rejection, unit/mint matching, and no value-loss on error. +//! guarantee: atomic single-use redemption, value-coverage enforcement (under +//! rejected, excess retained), double-spend rejection, unit/mint matching, and +//! no value-loss on error. use std::collections::{HashMap, HashSet}; use std::sync::Mutex; @@ -78,9 +79,10 @@ impl Redeemer for VoucherRedeemer { }); } - // Exact amount: overpay and underpay are both rejected (contract 3). - if voucher.amount != req.amount { - return Err(ChargeError::AmountMismatch { + // Value coverage (contract 3): an under-funded voucher is rejected; + // value above the requirement is accepted and retained whole. + if voucher.amount < req.amount { + return Err(ChargeError::PaymentInsufficient { required: req.amount, presented: voucher.amount, amount: req.amount, @@ -115,6 +117,9 @@ impl Redeemer for VoucherRedeemer { unit: voucher.unit.clone(), amount: voucher.amount, proofs, + // The ledger has no output-integrity check (contract 2): an impl + // without one reports a clean verdict. + dleq_ok: true, }) } } @@ -188,19 +193,19 @@ async fn custom_redeemer_honors_the_contract() { } #[tokio::test] -async fn custom_redeemer_rejects_amount_mismatch_without_spending() { +async fn custom_redeemer_rejects_underfunded_without_spending() { let redeemer = sample_ledger(); let mut req = sample_requirement(); - req.amount = 9; // voucher carries 10 → mismatch with the requirement + req.amount = 11; // voucher carries 10 → under-funded - // Exact-amount enforcement (contract 3): rejected, not silently accepted. + // Value-coverage enforcement (contract 3): under-funded is rejected. let err = redeemer .verify_and_redeem("voucher-abc", &req) .await - .expect_err("an amount mismatch must be rejected"); + .expect_err("an under-funded voucher must be rejected"); assert!( - matches!(err, ChargeError::AmountMismatch { .. }), - "expected AmountMismatch, got {err:?}" + matches!(err, ChargeError::PaymentInsufficient { .. }), + "expected PaymentInsufficient, got {err:?}" ); // No value-loss on the error path (contracts 1 + 6): the voucher is still @@ -211,3 +216,19 @@ async fn custom_redeemer_rejects_amount_mismatch_without_spending() { .await .expect("the voucher remained unspent after the rejected attempt"); } + +#[tokio::test] +async fn custom_redeemer_accepts_overfunded_and_retains_excess() { + let redeemer = sample_ledger(); + let mut req = sample_requirement(); + req.amount = 9; // voucher carries 10 → over-funded, accepted whole + + let redeemed = redeemer + .verify_and_redeem("voucher-abc", &req) + .await + .expect("an over-funded voucher redeems (excess retained)"); + assert_eq!( + redeemed.amount, 10, + "the full voucher value is redeemed and retained" + ); +} diff --git a/crates/pops-core-verify/tests/xcashu_adapter.rs b/crates/pops-core-verify/tests/xcashu_adapter.rs index f80a719..6620812 100644 --- a/crates/pops-core-verify/tests/xcashu_adapter.rs +++ b/crates/pops-core-verify/tests/xcashu_adapter.rs @@ -1,10 +1,11 @@ //! Black-box integration tests for the NUT-24 `X-Cashu` HTTP transport, //! driving the public [`require_charge_xcashu`] middleware through an //! `axum::Router` over a mock [`MintClient`]. Covers the value-safety matrix: -//! the `402` challenge shape, the happy path, exact-amount rejection (over- and -//! under-pay), unit/mint/DLEQ/double-spend rejections (resource never served), -//! the cashuB-only rule, malformed input, and the load-bearing -//! mint-unreachable → `503` (token NOT consumed) rule. +//! the `402` challenge shape, the happy path, the under-funded rejection, +//! unit/mint/double-spend rejections (resource never served), the DLEQ +//! serve-and-flag path (resource served, `dleq_ok: false`), the cashuB-only +//! rule, malformed input, and the load-bearing mint-unreachable → `503` +//! (token NOT consumed) rule. use std::str::FromStr; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -29,7 +30,7 @@ use pops_core_verify::cashu_credential::CashuCredential; use pops_core_verify::challenge::CashuRequirement; use pops_core_verify::middleware::ChargeMiddlewareState; use pops_core_verify::middleware_xcashu::require_charge_xcashu; -use pops_core_verify::mint_client::{MintClient, MintClientError}; +use pops_core_verify::mint_client::{MintClient, MintClientError, SwapOutcome}; use pops_core_verify::redeemer::Redeemed; use pops_core_verify::xcashu::X_CASHU; @@ -39,9 +40,10 @@ enum SwapResponse { Echo, /// DETERMINATE unreachable: the token was NOT consumed. Unreachable, - /// Mint refused the swap (double-spent / expired). - RejectedSwap, - /// Swap-output DLEQ verification failed (money-safety path). + /// Mint typed the rejection as already-spent (the double-spend case). + AlreadySpent, + /// Swap SUCCEEDS (consumes the token) but the returned signatures fail + /// the NUT-12 verdict → serve-and-flag (`dleq_ok: false`). DleqInvalid, } @@ -71,21 +73,33 @@ impl MintClient for MockMintClient { Ok(Vec::new()) } - async fn swap(&self, _mint_url: &MintUrl, proofs: Proofs) -> Result { + async fn swap( + &self, + _mint_url: &MintUrl, + proofs: Proofs, + ) -> Result { match self.swap_response { SwapResponse::Echo => { self.swaps_succeeded.fetch_add(1, Ordering::SeqCst); - Ok(proofs) + Ok(SwapOutcome { + proofs, + dleq_ok: true, + }) } SwapResponse::Unreachable => { Err(MintClientError::Unreachable("mock unreachable".into())) } - SwapResponse::RejectedSwap => { - Err(MintClientError::RejectedSwap("mock rejected".into())) + SwapResponse::AlreadySpent => { + Err(MintClientError::AlreadySpent("mock already spent".into())) + } + SwapResponse::DleqInvalid => { + // The swap itself SUCCEEDED — the token is consumed. + self.swaps_succeeded.fetch_add(1, Ordering::SeqCst); + Ok(SwapOutcome { + proofs, + dleq_ok: false, + }) } - SwapResponse::DleqInvalid => Err(MintClientError::SwapOutputDleqInvalid( - "mock swap-output DLEQ invalid".into(), - )), } } } @@ -220,27 +234,25 @@ async fn valid_cashub_returns_200_and_serves_resource() { ); } -// ---- exact amount: overpay → reject, underpay → reject ------------- +// ---- value coverage: overpay → accepted, underpay → reject ---------- #[tokio::test] -async fn overpay_is_rejected_and_resource_not_served() { - // 20 against a required 10: EXACT-amount rejects, never captures the overage. +async fn overpay_is_accepted_and_excess_retained() { + // 20 against a required 10: value above the requirement is accepted and + // retained (spec step 8) — the whole token swaps and the resource serves. let token = make_token(mint_a(), pop_unit(), vec![make_proof(16, 0), make_proof(4, 1)]); let (app, swaps) = router_for(SwapResponse::Echo); let response = app .oneshot(request_with_xcashu(&token.to_string())) .await .expect("oneshot"); - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); - assert!(x_cashu_header(&response).is_some(), "overpay re-challenges"); - let body = body_string(response).await; - assert!(!body.starts_with("ok:"), "resource must NOT be served on overpay"); - assert!(body.contains("amount mismatch"), "expected amount-mismatch body, got: {body}"); + assert_eq!(response.status(), StatusCode::OK); assert_eq!( - swaps.load(Ordering::SeqCst), - 0, - "an over-funded token is rejected pre-swap (no overage capture)" + body_string(response).await, + "ok:20", + "the WHOLE over-funded value is redeemed and retained" ); + assert_eq!(swaps.load(Ordering::SeqCst), 1, "the over-funded accept path swaps once"); } #[tokio::test] @@ -254,7 +266,10 @@ async fn underpay_is_rejected_and_resource_not_served() { assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); let body = body_string(response).await; assert!(!body.starts_with("ok:"), "resource must NOT be served on underpay"); - assert!(body.contains("amount mismatch"), "expected amount-mismatch body, got: {body}"); + assert!( + body.contains("payment-insufficient"), + "expected the payment-insufficient problem body, got: {body}" + ); assert_eq!(swaps.load(Ordering::SeqCst), 0, "under-funded token rejected pre-swap"); } @@ -291,24 +306,49 @@ async fn wrong_mint_is_rejected_and_resource_not_served() { ); } -// ---- DLEQ-invalid → non-serving (resource NOT served) -------------- +// ---- DLEQ failure → serve-and-flag (spec step 9 + §security-dleq) ---- #[tokio::test] -async fn dleq_invalid_does_not_serve_resource() { +async fn dleq_failure_serves_resource_and_flags_redeemed_extension() { + // "a failed or missing DLEQ proof after a successful swap is a mint-trust + // incident, not a payment failure" — the token WAS consumed, so the + // resource is served and the verdict rides `Redeemed.dleq_ok`. let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); - let (app, _swaps) = router_for(SwapResponse::DleqInvalid); + + let (mock, swaps) = MockMintClient::new(SwapResponse::DleqInvalid); + let state = Arc::new(ChargeMiddlewareState::new( + requirement(pop_unit(), vec![mint_a()], 10), + CashuCredential::new(mock), + )); + async fn echo_flag(Extension(redeemed): Extension) -> String { + format!("ok:{}:dleq_ok={}", redeemed.amount, redeemed.dleq_ok) + } + let app = Router::new() + .route("/gated", get(echo_flag)) + .layer(from_fn_with_state( + state, + require_charge_xcashu::, + )); + let response = app .oneshot(request_with_xcashu(&token.to_string())) .await .expect("oneshot"); - assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); - assert!(x_cashu_header(&response).is_some(), "DLEQ failure re-challenges"); + assert_eq!( + response.status(), + StatusCode::OK, + "the payment settled; it must not be answered with a payment failure" + ); let body = body_string(response).await; - assert!( - !body.starts_with("ok:"), - "a malicious/buggy mint must NOT get the resource served against unsigned ecash" + assert_eq!( + body, "ok:10:dleq_ok=false", + "value redeemed, resource served, verdict carried" + ); + assert_eq!( + swaps.load(Ordering::SeqCst), + 1, + "the swap completed (the token was consumed)" ); - assert!(body.to_ascii_lowercase().contains("dleq"), "expected DLEQ body, got: {body}"); } // ---- double-spend → reject ----------------------------------------- @@ -316,7 +356,7 @@ async fn dleq_invalid_does_not_serve_resource() { #[tokio::test] async fn double_spend_is_rejected_and_resource_not_served() { let token = make_token(mint_a(), pop_unit(), vec![make_proof(10, 0)]); - let (app, _swaps) = router_for(SwapResponse::RejectedSwap); + let (app, _swaps) = router_for(SwapResponse::AlreadySpent); let response = app .oneshot(request_with_xcashu(&token.to_string())) .await diff --git a/crates/pops-gateway/Cargo.toml b/crates/pops-gateway/Cargo.toml index 79d8894..f440bfa 100644 --- a/crates/pops-gateway/Cargo.toml +++ b/crates/pops-gateway/Cargo.toml @@ -54,3 +54,5 @@ tower = { version = "0.5", features = ["util"] } async-trait = "0.1" # A throwaway in-process upstream + reading back the persisted JSONL. tempfile = "3" +# Decoding the challenge id in the binding tests (base64url-nopad). +base64 = "0.22" diff --git a/crates/pops-gateway/README.md b/crates/pops-gateway/README.md index deb7ca2..a2464bf 100644 --- a/crates/pops-gateway/README.md +++ b/crates/pops-gateway/README.md @@ -39,6 +39,10 @@ docker run -p 8080:8080 \ ghcr.io/makeprisms/pops-gateway:latest ``` +The gateway reads its config from the path in the **`POPS_GATEWAY_CONFIG`** +env var, defaulting to **`/etc/pops-gateway/config.toml`** (which is why the +mount above lands there). + Prefer to build from source? See [Building the image yourself](#building-the-image-yourself) — the run command is identical, just swap the local tag for the `ghcr.io/...` one. @@ -126,21 +130,34 @@ value — it is the payment for the gated request. 1. **No / invalid credential** → `402 Payment Required` + `WWW-Authenticate: Payment id="…", realm="pops-gateway", method="cashu", intent="charge", - request=""`, body `{"error":"payment_required", …}`, - `Cache-Control: no-store`. -2. **Valid credential** → verify + NUT-03 swap against `mint_url`. + request="", expires="…"`, an RFC-9457 + `application/problem+json` body, and `Cache-Control: no-store`. The `id` is + a per-request HMAC binding every issued param under **`binding_key`** (or + the **`POPS_BINDING_KEY`** env var, which wins; omitted ⇒ a fresh key per + boot), and `expires` stamps **`challenge_ttl_secs`** (default 300) into the + challenge. +2. **Valid credential** → the echoed challenge is authenticated first (the + HMAC recomputed over the echo; `expires` checked), then verify + NUT-03 + swap against `mint_url` (each mint call bounded by + **`mint_http_timeout_secs`**, default 10s; `0` is a config error). 3. On success the gateway **persists `fresh_proofs` durably (append + flush + fsync) BEFORE forwarding** — a crash between forward and persist would otherwise lose already-consumed value. 4. Then the **original** request (method/path/query/headers/body) is forwarded to `upstream_url` (with a bounded **`upstream_timeout_secs`**, default 30s) and the response is streamed back. -5. Error mapping (mirrors the reference verifier): +5. Error mapping (the verifier's single-sourced problem map; every error body + is `application/problem+json` with an absolute problem-type URI): - mint unreachable → `503` + `Retry-After` (token **not** consumed, retry). - request body over **`max_body_bytes`** (default 1 MiB) → `413 Payload Too Large`. On a gated path this is checked **before the charge**, so the pop is **not** consumed. - - malformed request → `400`. + - credential carrying more than **`[charge].max_proofs`** proofs (default + 64) → `402` BEFORE any swap (a pre-swap DoS guard; the pop is **not** + consumed). + - malformed request frame (>1 credential) / non-`cashu` method → `400`. + - tampered or unissued challenge echo → `402 invalid-challenge`; stale + `expires` or a keyset retired at the mint → `402 payment-expired`. - upstream hung past the timeout → `504`; upstream down → `502` (the pop, if gated, is already spent — see the v1 edge below). - any other verification failure → `402` + a fresh challenge. @@ -166,6 +183,14 @@ Both are gateway-own and never forwarded upstream. Logs are **JSON structured** (`tracing-subscriber` json) so an agent or operator can parse outcomes. Set `RUST_LOG` to tune verbosity (default `info`). +Each settlement logs one INFO line (`charge settled and persisted; forwarding +upstream`) carrying `token_hash`, `amount`, `unit`, `active_keyset_id`, and +**`dleq_ok`** — the NUT-12 verdict on the signatures the mint returned from the +redeeming swap. `dleq_ok=false` is a **mint-trust incident**, not a payment +failure: the client's payment settled and was served, but the mint could not +prove it signed your fresh proofs with its advertised key (a WARN naming the +mint fires too). Alert on it and consider quarantining the mint. + --- ## Fail-fast config validation @@ -178,11 +203,15 @@ config field charge.amount: must be greater than 0 ``` Checks: `upstream_url` / `mint_url` parse; `charge.unit` is a well-formed -`pop_`; `charge.amount > 0`; `max_body_bytes > 0`; every `charge.mints` -entry parses; and `proofs_sink`'s parent directory exists **and is actually -writable by the running uid** — verified with a real create+fsync+delete -write-probe (not just an inode mode-bit inspection), so a dir the process can't -write is caught at boot rather than on the first redeemed proof. +`pop_`; `charge.amount > 0`; `max_body_bytes > 0`; +`mint_http_timeout_secs > 0` (an unbounded mint call would hang a request +whose token may already be consumed); `challenge_ttl_secs > 0` (a 0-TTL +challenge is born expired); a configured `binding_key` is plausible hex of at +least 16 bytes; every `charge.mints` entry parses; and `proofs_sink`'s parent +directory exists **and is actually writable by the running uid** — verified +with a real create+fsync+delete write-probe (not just an inode mode-bit +inspection), so a dir the process can't write is caught at boot rather than on +the first redeemed proof. --- diff --git a/crates/pops-gateway/config.example.toml b/crates/pops-gateway/config.example.toml index 9f2af6d..8f96cc8 100644 --- a/crates/pops-gateway/config.example.toml +++ b/crates/pops-gateway/config.example.toml @@ -1,9 +1,11 @@ # pops-gateway — example config. # # Copy this to `config.toml`, fill in the five REQUIRED facts below, then run -# the gateway pointing at it (see the README quickstart). This file is parsed -# by the gateway's `Config` (serde, `deny_unknown_fields`) — keep field names -# and table nesting exactly as shown; an unknown key is a fail-fast error. +# the gateway pointing at it (see the README quickstart). The gateway reads the +# path in the POPS_GATEWAY_CONFIG env var, defaulting to +# /etc/pops-gateway/config.toml. This file is parsed by the gateway's `Config` +# (serde, `deny_unknown_fields`) — keep field names and table nesting exactly +# as shown; an unknown key is a fail-fast error. # ── REQUIRED — the five facts ──────────────────────────────────────────────── @@ -39,6 +41,28 @@ proofs_sink = "/data/proofs.jsonl" # the 504 response reachable. Default: 30. Set to 0 to disable (NOT recommended). # upstream_timeout_secs = 30 +# Per-call MINT HTTP timeout in SECONDS (keysets / keys / swap). Bounds a hung +# mint so the request surfaces as 503 mint-unavailable instead of hanging. +# Default: 10. Must be > 0 (0 is a config error — a mint call can consume the +# token, so it must always be bounded). +# mint_http_timeout_secs = 10 + +# Hex-encoded server SECRET for the stateless challenge binding: each 402's +# `id` is an HMAC-SHA256 over the issued challenge params under this key. At +# least 16 bytes (32 hex chars); 32 bytes (64 hex chars) recommended. The +# POPS_BINDING_KEY env var, when set, OVERRIDES this value (so the secret can +# stay out of the mounted file). Omitted ⇒ a fresh key is generated at boot: +# outstanding challenges then die with the process and clients refetch the +# 402 — fine for a single instance, but configure a stable key for restarts or +# multiple replicas. NEVER log or share it. +# binding_key = "<64 hex chars>" + +# Challenge lifetime in SECONDS, stamped into each challenge's `expires` +# auth-param. A credential echoing a challenge older than this is rejected as +# payment-expired (the client refetches). Default: 300. Must be > 0 (a 0-TTL +# challenge is born expired). +# challenge_ttl_secs = 300 + [charge] # The pop_ currency unit you accept. # @@ -61,6 +85,12 @@ amount = 1 # Human-readable description shown in the 402 challenge. # description = "my API" +# Max proofs accepted per credential — a pre-swap DoS guard (a token stuffed +# with tiny proofs inflates swap cost). Over the cap → 402 BEFORE any swap (the +# pop is not consumed). Default: 64 (generous; an exact-amount token needs only +# a handful). Must be > 0. +# max_proofs = 64 + # OPTIONAL per-path gating rules. Absent ⇒ gate EVERY path. When present, only # paths matching a non-`public` rule are gated; `public = true` paths forward # WITHOUT a gate. Each rule is a [[routes]] table. diff --git a/crates/pops-gateway/src/config.rs b/crates/pops-gateway/src/config.rs index 6217122..29511f4 100644 --- a/crates/pops-gateway/src/config.rs +++ b/crates/pops-gateway/src/config.rs @@ -16,6 +16,7 @@ use cashu::nuts::CurrencyUnit; use cashu::{Amount, MintUrl}; use serde::Deserialize; +use pops_core_verify::binding::BindingKey; use pops_core_verify::challenge::CashuRequirement; /// The default listen address when `listen` is omitted. @@ -30,11 +31,21 @@ pub const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024; /// whose pop was already redeemed isn't stranded, and makes the `504` reachable. pub const DEFAULT_UPSTREAM_TIMEOUT_SECS: u64 = 30; +/// Default per-call mint HTTP timeout (10s). Bounds a hung mint so the request +/// surfaces as the 503 mint-unavailable path (token-consumption contract +/// unchanged: a pre-swap timeout is determinate, a swap-POST timeout is +/// indeterminate) instead of hanging forever. +pub const DEFAULT_MINT_HTTP_TIMEOUT_SECS: u64 = 10; + /// Default cap on proofs per credential. An exact-amount token needs only a /// handful (power-of-two split), so 64 is generous headroom while bounding a /// swap-DoS by a token stuffed with tiny proofs. Over → a pre-swap 402. pub const DEFAULT_MAX_PROOFS: usize = 64; +/// Default challenge lifetime stamped into the `expires` auth-param (300 s — +/// `expires` is MUST under the stateless binding the gateway runs). +pub const DEFAULT_CHALLENGE_TTL_SECS: u64 = 300; + /// Top-level gateway config from the mounted TOML. Required fields are plain (no /// `Option`) so a missing key is a serde error before semantic validation; /// `proofs_sink` is deliberately required with no default. @@ -67,6 +78,25 @@ pub struct Config { #[serde(default = "default_upstream_timeout_secs")] pub upstream_timeout_secs: u64, + /// Per-call mint HTTP timeout (s); must be > 0 — an unbounded mint call + /// would hang the request while a possibly-consumed token's fate stays + /// unresolved. + #[serde(default = "default_mint_http_timeout_secs")] + pub mint_http_timeout_secs: u64, + + /// Hex-encoded server secret for the stateless challenge binding (the + /// HMAC-SHA256 challenge `id`; 32 bytes / 64 hex chars RECOMMENDED, ≥ 16 + /// bytes required). Also settable via the `POPS_BINDING_KEY` env var + /// (which wins). Omitted ⇒ a fresh key is generated at boot — outstanding + /// challenges then die with the process and clients refetch the 402. + #[serde(default)] + pub binding_key: Option, + + /// Challenge lifetime in seconds, stamped into the `expires` auth-param + /// (must be > 0; default 300). + #[serde(default = "default_challenge_ttl_secs")] + pub challenge_ttl_secs: u64, + /// The charge advertised on the 402 + enforced on retry. pub charge: ChargeConfig, @@ -124,10 +154,18 @@ fn default_upstream_timeout_secs() -> u64 { DEFAULT_UPSTREAM_TIMEOUT_SECS } +fn default_mint_http_timeout_secs() -> u64 { + DEFAULT_MINT_HTTP_TIMEOUT_SECS +} + fn default_max_proofs() -> usize { DEFAULT_MAX_PROOFS } +fn default_challenge_ttl_secs() -> u64 { + DEFAULT_CHALLENGE_TTL_SECS +} + /// A semantic config failure, naming the field and the human reason. Rendered /// by `main` as `config field : ` to stderr before a nonzero /// exit (never a panic / stacktrace). @@ -173,12 +211,19 @@ pub struct ValidatedConfig { pub max_body_bytes: usize, /// Upstream request timeout (`None` ⇒ no timeout, from `0`). pub upstream_timeout: Option, + /// Per-call mint HTTP timeout (always bounded; `0` is a config error). + pub mint_http_timeout: std::time::Duration, /// The cashu requirement advertised on the 402 + enforced on retry. pub requirement: CashuRequirement, /// Per-token max proof count (pre-swap DoS guard; over → 402). pub max_proofs: usize, /// Per-path gating rules (empty ⇒ gate all). pub routes: Vec, + /// The challenge-binding key (configured, or generated at boot when the + /// config named none). + pub binding_key: BindingKey, + /// Challenge lifetime stamped into `expires`. + pub challenge_ttl: std::time::Duration, } impl Config { @@ -237,6 +282,34 @@ impl Config { Some(std::time::Duration::from_secs(self.upstream_timeout_secs)) }; + // Unlike the upstream timeout, the mint call may have CONSUMED the + // token — it must always be bounded so the 503 path is reachable. + if self.mint_http_timeout_secs == 0 { + return Err(ConfigError::new( + "mint_http_timeout_secs", + "must be greater than 0 (an unbounded mint call would hang the request)", + )); + } + let mint_http_timeout = std::time::Duration::from_secs(self.mint_http_timeout_secs); + + // A 0-TTL challenge is born expired — every payment would 402. + if self.challenge_ttl_secs == 0 { + return Err(ConfigError::new( + "challenge_ttl_secs", + "must be greater than 0", + )); + } + let challenge_ttl = std::time::Duration::from_secs(self.challenge_ttl_secs); + + // A configured key must be plausible hex (BindingKey enforces ≥ 16 + // bytes); absent ⇒ generate at boot (restart invalidates outstanding + // challenges; clients refetch). + let binding_key = match self.binding_key.as_deref() { + Some(hex) => BindingKey::from_hex(hex) + .map_err(|e| ConfigError::new("binding_key", e))?, + None => BindingKey::generate(), + }; + // Default to [mint_url] when empty; otherwise parse each. let mints: Vec = if self.charge.mints.is_empty() { vec![mint_url.clone()] @@ -270,9 +343,12 @@ impl Config { listen: self.listen, max_body_bytes: self.max_body_bytes, upstream_timeout, + mint_http_timeout, requirement, max_proofs: self.charge.max_proofs, routes: self.routes, + binding_key, + challenge_ttl, }) } } @@ -360,6 +436,14 @@ impl MintUrlFieldExt for MintUrl { } fn from_str_for_field(s: &str, field: &str) -> Result { use std::str::FromStr; + // Spec mint-trust §: a mint URL carrying userinfo is rejected outright + // — on this side as a fail-fast config error. + if pops_core_verify::challenge::mint_url_has_userinfo(s) { + return Err(ConfigError::new( + field, + "mint URL must not contain userinfo (user@host)", + )); + } MintUrl::from_str(s) .map_err(|e| ConfigError::new(field, format!("not a valid mint URL: {e}"))) } @@ -585,6 +669,67 @@ max_proofs = 0 assert_eq!(err.field, "charge.max_proofs"); } + #[test] + fn challenge_ttl_defaults_to_300s() { + let cfg = Config::from_toml_str(&valid_toml("/tmp/pops-proofs.jsonl")).expect("parses"); + let v = cfg.validate().expect("validates"); + assert_eq!( + v.challenge_ttl, + std::time::Duration::from_secs(DEFAULT_CHALLENGE_TTL_SECS) + ); + } + + #[test] + fn zero_challenge_ttl_is_named_field_error() { + let toml = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" +challenge_ttl_secs = 0 + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + let cfg = Config::from_toml_str(toml).expect("parses"); + let err = cfg.validate().expect_err("ttl=0 must fail"); + assert_eq!(err.field, "challenge_ttl_secs"); + } + + #[test] + fn configured_binding_key_round_trips_and_bad_hex_is_named_field_error() { + let good = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" +binding_key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + Config::from_toml_str(good) + .expect("parses") + .validate() + .expect("a 32-byte hex key validates"); + + let bad = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" +binding_key = "not-hex" + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + let err = Config::from_toml_str(bad) + .expect("parses") + .validate() + .expect_err("a non-hex key must fail"); + assert_eq!(err.field, "binding_key"); + } + #[test] fn zero_max_body_bytes_is_named_field_error() { let toml = r#" @@ -602,6 +747,53 @@ amount = 1 assert_eq!(err.field, "max_body_bytes"); } + #[test] + fn mint_http_timeout_defaults_to_10s() { + let cfg = Config::from_toml_str(&valid_toml("/tmp/pops-proofs.jsonl")).expect("parses"); + let v = cfg.validate().expect("validates"); + assert_eq!( + v.mint_http_timeout, + std::time::Duration::from_secs(DEFAULT_MINT_HTTP_TIMEOUT_SECS) + ); + assert_eq!(DEFAULT_MINT_HTTP_TIMEOUT_SECS, 10); + } + + #[test] + fn custom_mint_http_timeout_round_trips() { + let toml = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" +mint_http_timeout_secs = 3 + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + let cfg = Config::from_toml_str(toml).expect("parses"); + let v = cfg.validate().expect("validates"); + assert_eq!(v.mint_http_timeout, std::time::Duration::from_secs(3)); + } + + #[test] + fn zero_mint_http_timeout_is_named_field_error() { + // Unlike upstream_timeout_secs, 0 here is NOT "disabled": an unbounded + // mint call hangs a request whose token may already be consumed. + let toml = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" +mint_http_timeout_secs = 0 + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + let cfg = Config::from_toml_str(toml).expect("parses"); + let err = cfg.validate().expect_err("mint_http_timeout_secs=0 must fail"); + assert_eq!(err.field, "mint_http_timeout_secs"); + } + #[test] fn zero_upstream_timeout_means_no_timeout() { let toml = r#" @@ -685,6 +877,41 @@ amount = 1 ); } + #[test] + fn userinfo_mint_url_is_named_field_error() { + // Spec mint-trust §: user@host in a mint URL is rejected outright — + // here, fail-fast at config validation, for both `mint_url` and each + // `charge.mints` entry. + let toml = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://user@mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" + +[charge] +unit = "pop_1782668279" +amount = 1 +"#; + let cfg = Config::from_toml_str(toml).expect("parses"); + let err = cfg.validate().expect_err("userinfo mint_url must fail"); + assert_eq!(err.field, "mint_url"); + assert!(err.reason.contains("userinfo"), "got: {}", err.reason); + + let toml = r#" +upstream_url = "http://127.0.0.1:9999" +mint_url = "https://mint.example.com" +proofs_sink = "/tmp/pops-proofs.jsonl" + +[charge] +unit = "pop_1782668279" +amount = 1 +mints = ["https://user:pw@mint-b.example.com"] +"#; + let cfg = Config::from_toml_str(toml).expect("parses"); + let err = cfg.validate().expect_err("userinfo charge.mints must fail"); + assert_eq!(err.field, "charge.mints[0]"); + assert!(err.reason.contains("userinfo"), "got: {}", err.reason); + } + #[test] fn explicit_mints_override_default() { let toml = r#" diff --git a/crates/pops-gateway/src/gateway.rs b/crates/pops-gateway/src/gateway.rs index a4646da..94855d0 100644 --- a/crates/pops-gateway/src/gateway.rs +++ b/crates/pops-gateway/src/gateway.rs @@ -4,9 +4,9 @@ //! ([`CashuCredential::verify_and_redeem`]): it decides whether a path is gated, //! runs the gate, **persists `fresh_proofs` durably BEFORE forwarding**, then //! proxies the ORIGINAL request to `upstream_url` and streams back. The -//! `ChargeError` → HTTP status mapping (503/400/402) is the single-sourced -//! [`charge_error_status`], -//! shared with `middleware.rs`. +//! `ChargeError` → status/problem-type mapping (503/400/402 + RFC-9457 body) is +//! the single-sourced [`pops_core_verify::problem`] map, shared with the core +//! middlewares. use std::sync::Arc; @@ -15,14 +15,19 @@ use axum::extract::{Request, State}; use axum::response::{IntoResponse, Response}; use http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use pops_core_verify::binding::{issue_challenge, validate_challenge_echo}; use pops_core_verify::charge::ChargeError; use pops_core_verify::cashu_credential::{charge_requirement_from_cashu, CashuCredential}; use pops_core_verify::cdk_mint_client::CdkMintClient; -use pops_core_verify::challenge::{encode_challenge, CashuRequirement}; +use pops_core_verify::challenge::encode_charge_request; use pops_core_verify::http_status::charge_error_status; +use pops_core_verify::middleware::{ + count_payment_credentials, payment_receipt_header, PAYMENT_RECEIPT_HEADER, +}; +use pops_core_verify::problem::{Problem, PROBLEM_JSON}; use pops_core_verify::redeemer::Redeemer; use pops_core_verify::envelope::{ - encode_request_envelope, parse_payment_authorization, AuthParseError, PAYMENT_SCHEME, + parse_payment_authorization, AuthParseError, PaymentCredentials, CASHU_METHOD, }; use crate::config::ValidatedConfig; @@ -35,21 +40,18 @@ pub const REALM: &str = "pops-gateway"; /// The `intent` value — a one-shot charge. pub const INTENT_CHARGE: &str = "charge"; -/// A fixed challenge `id`: the gate does not enforce challenge-id binding, so a -/// constant keeps the prebuilt header truly constant. -pub const CHALLENGE_ID: &str = "pops-gateway"; - /// Per-request shared state, built once at startup (`Arc`). `C` is the credential /// seam (production: `CashuCredential` via [`AppState::production`]). pub struct AppState { - /// The pre-parsed config. + /// The pre-parsed config (carries the binding key + challenge TTL). pub config: ValidatedConfig, /// The credential that verifies + redeems on retry. pub credential: Arc, /// Durable sink for redeemed proofs (persist-before-forward). pub sink: Arc, - /// The prebuilt `WWW-Authenticate: Payment …` value (cloned onto every 402). - pub www_authenticate: HeaderValue, + /// The request object emitted in every challenge (constant per config; the + /// per-challenge id + expires are stamped per request). + pub request_object: String, /// HTTP client for forwarding gated requests upstream. pub upstream: reqwest::Client, } @@ -58,13 +60,15 @@ impl AppState { /// Build the shared state. The forwarding client gets the configured request /// + connect timeout so a hung upstream is bounded (and `504` is reachable). pub fn new(config: ValidatedConfig, credential: C, sink: ProofsSink) -> Self { - let www_authenticate = build_www_authenticate(&config.requirement); + let request_object = encode_charge_request(&config.requirement).expect( + "ValidatedConfig guarantees a non-empty mint set (charge.mints defaults to [mint_url])", + ); let upstream = build_upstream_client(config.upstream_timeout); Self { config, credential: Arc::new(credential), sink: Arc::new(sink), - www_authenticate, + request_object, upstream, } } @@ -85,25 +89,35 @@ fn build_upstream_client(timeout: Option) -> reqwest::Clien } impl AppState> { - /// Production wiring: the real cdk-backed credential, with the configured - /// per-token `max_proofs` DoS cap enforced pre-swap. + /// Production wiring: the real cdk-backed credential with the configured + /// per-call mint HTTP timeout, and the per-token `max_proofs` DoS cap + /// enforced pre-swap. pub fn production(config: ValidatedConfig, sink: ProofsSink) -> Self { - let credential = - CashuCredential::with_max_proofs(CdkMintClient::new(), config.max_proofs); + let credential = CashuCredential::with_max_proofs( + CdkMintClient::with_timeout(config.mint_http_timeout), + config.max_proofs, + ); Self::new(config, credential, sink) } } -/// Build the `WWW-Authenticate: Payment …` value once from the requirement. -fn build_www_authenticate(requirement: &CashuRequirement) -> HeaderValue { - let encoded_challenge = encode_challenge(requirement); - let request_envelope = encode_request_envelope(&encoded_challenge); - let header = format!( - r#"{PAYMENT_SCHEME} id="{CHALLENGE_ID}", realm="{REALM}", method="cashu", intent="{INTENT_CHARGE}", request="{request_envelope}""# +/// Build one fresh `WWW-Authenticate: Payment …` value: the constant request +/// object (the shared `draft-cashu-charge-01` codec — the same object the core +/// middleware emits, so the two hosts speak ONE wire) plus a per-request +/// `expires` (`now + challenge_ttl`) and the framework's stateless HMAC `id` +/// binding every issued param under the configured key. +fn fresh_www_authenticate(state: &AppState) -> HeaderValue { + let issued = issue_challenge( + &state.config.binding_key, + REALM, + CASHU_METHOD, + INTENT_CHARGE, + &state.request_object, + state.config.challenge_ttl, ); // All components are base64url-nopad / ASCII; from_str validates as a guard. - HeaderValue::from_str(&header) - .expect("WWW-Authenticate value is ASCII (creqA envelope is base64url-nopad)") + HeaderValue::from_str(&issued.header_value) + .expect("WWW-Authenticate value is ASCII (request object is base64url-nopad)") } /// The axum catch-all handler. Every request (except the gateway-own health @@ -123,9 +137,9 @@ where /// The gated path: enforce payment, persist on success, then forward. /// /// The ordering is load-bearing for value-safety + DoS-resistance: -/// 1. extract the credential first — a bare/malformed request 402s without ever -/// buffering its body (an unauthenticated caller can't make us buffer up to -/// the cap); +/// 1. extract the credential and authenticate its challenge echo first — a +/// bare/malformed/unbound request 402s without ever buffering its body (an +/// unauthenticated caller can't make us buffer up to the cap); /// 2. buffer the body (capped) before the swap — over-cap → 413, read failure → /// 4xx, both while the pop is still unspent (so we never spend a pop on a /// request we then can't read); @@ -137,14 +151,24 @@ where { let (parts, body) = req.into_parts(); - // Extract the credential first (a bare/malformed request 402s without + // Extract the credential first (a bare/malformed request errors without // buffering its body). - let token = match extract_token(&parts.headers) { - Ok(t) => t, + let credentials = match extract_credentials(&parts.headers) { + Ok(c) => c, Err(TokenExtract::NoAttempt) => return challenge_402(&state, None), - Err(TokenExtract::Malformed(reason)) => return challenge_402(&state, Some(&reason)), + Err(TokenExtract::Failed(e)) => return charge_error_to_response(&state, e), }; + // Spec verification step 3, before the body buffer and any swap: + // authenticate the echoed challenge (recompute the id-HMAC; tampered / + // inconsistent → invalid-challenge) and check `expires` freshness + // (stale → payment-expired). + if let Err(e) = validate_challenge_echo(&state.config.binding_key, &credentials.challenge) + { + return charge_error_to_response(&state, e); + } + let token = credentials.payload.token; + // Buffer the body (capped) before the swap, while the pop is still unspent. let body_bytes = match read_body_capped(body, state.config.max_body_bytes).await { Ok(b) => b, @@ -187,16 +211,43 @@ where .into_response(); } + // `dleq_ok: false` was already WARN-logged (mint named) by the swap + // ceremony; carried here for settle-line correlation. tracing::info!( token_hash = %redeemed.proofs.token_hash, amount = redeemed.proofs.amount, unit = %redeemed.proofs.unit, active_keyset_id = %redeemed.proofs.active_keyset_id, + dleq_ok = redeemed.dleq_ok, "charge settled and persisted; forwarding upstream" ); + // The receipt facts (shared builder with the core middleware): the + // redeemed token_hash + the echoed challenge id; `externalId` is the + // requirement's correlation id when configured. + let receipt_header = payment_receipt_header( + &redeemed, + &credentials.challenge.id, + state.config.requirement.payment_id.as_deref(), + ); + // Forward the already-buffered body (no read can fail after the charge). - forward_buffered(&state, parts, body_bytes).await + let mut response = forward_buffered(&state, parts, body_bytes).await; + + // Spec receipt §: `Payment-Receipt` + `Cache-Control: private` ride the + // paid SUCCESS response only (never an error response), and `private` + // OVERRIDES whatever Cache-Control the upstream sent — a paid response + // must never be shared-cacheable. + if response.status().is_success() { + if let Ok(value) = HeaderValue::from_str(&receipt_header) { + response.headers_mut().insert(PAYMENT_RECEIPT_HEADER, value); + } + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private"), + ); + } + response } /// Forward a PUBLIC request (no gate, no persist). The body cap is the primary @@ -386,50 +437,72 @@ fn is_hop_by_hop(name: &str) -> bool { ) } -/// Outcome of trying to pull a cashu token out of the `Authorization` header. +/// Outcome of trying to pull payment credentials out of the `Authorization` +/// header. enum TokenExtract { /// No header, or a non-`Payment` scheme — identical to "no payment attempt". NoAttempt, - /// A `Payment` attempt that failed to parse — surfaced as a 402 + reason. - Malformed(String), + /// A `Payment` attempt that failed — the [`ChargeError`] picks the + /// status/problem-type via the shared map (malformed credential → 402, + /// non-"cashu" method → 400 method-unsupported, >1 credential → 400). + Failed(ChargeError), } -/// Extract the `cashuB…` token from `Authorization: Payment `. A non-Payment -/// scheme is treated as no attempt. -fn extract_token(headers: &HeaderMap) -> Result { +/// Extract the full credentials object from `Authorization: Payment ` (the +/// echoed challenge feeds the binding check; `payload.token` feeds the redeem). +/// A non-Payment scheme is treated as no attempt; more than one Payment +/// credential is a malformed request frame per the framework (counted by the +/// shared [`count_payment_credentials`]). +fn extract_credentials(headers: &HeaderMap) -> Result { + if count_payment_credentials(headers) > 1 { + return Err(TokenExtract::Failed(ChargeError::MalformedRequest( + "request bears more than one Authorization: Payment credential".to_string(), + ))); + } + let Some(raw) = headers.get(header::AUTHORIZATION) else { return Err(TokenExtract::NoAttempt); }; - let value = raw - .to_str() - .map_err(|_| TokenExtract::Malformed("invalid Authorization header encoding".into()))?; + let value = raw.to_str().map_err(|_| { + TokenExtract::Failed(ChargeError::MalformedCredential( + "invalid Authorization header encoding".into(), + )) + })?; match parse_payment_authorization(value) { - Ok(creds) => Ok(creds.payload.cashu_token), + Ok(creds) => Ok(creds), Err(AuthParseError::UnknownScheme) => Err(TokenExtract::NoAttempt), - Err(e) => Err(TokenExtract::Malformed(e.to_string())), + Err(AuthParseError::WrongMethod(method)) => { + Err(TokenExtract::Failed(ChargeError::MethodUnsupported { + method, + })) + } + Err(e) => Err(TokenExtract::Failed(ChargeError::MalformedCredential( + e.to_string(), + ))), } } -/// Build a 402 carrying the prebuilt challenge + a JSON body (always -/// `Cache-Control: no-store`). -fn challenge_402(state: &AppState, failure: Option<&str>) -> Response +/// Build a 402 carrying a FRESH challenge (per-request HMAC id + expires; +/// always `Cache-Control: no-store`). The body is RFC-9457 +/// `application/problem+json` from the shared [`pops_core_verify::problem`] +/// map: the supplied failure problem, or the framework's `payment-required` +/// type on a bare "no attempt yet" challenge. +fn challenge_402(state: &AppState, problem: Option<&Problem>) -> Response where C: Redeemer, { - let body = match failure { - Some(detail) => format!( - r#"{{"error":"payment_required","detail":{},"realm":"{REALM}"}}"#, - json_string(detail) - ), - None => format!(r#"{{"error":"payment_required","realm":"{REALM}"}}"#), + let body = match problem { + Some(p) => p.to_json(), + None => Problem::payment_required(format!("payment required for realm {REALM:?}")) + .to_json(), }; ( StatusCode::PAYMENT_REQUIRED, [ - (header::WWW_AUTHENTICATE, state.www_authenticate.clone()), + (header::WWW_AUTHENTICATE, fresh_www_authenticate(state)), ( header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), + HeaderValue::from_static(PROBLEM_JSON), ), (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), ], @@ -438,71 +511,45 @@ where .into_response() } -/// Map a [`ChargeError`] to an HTTP response. The status is the single-sourced -/// [`charge_error_status`] trichotomy (shared with `middleware.rs` so the two +/// Map a [`ChargeError`] to an HTTP response from the single-sourced +/// [`pops_core_verify::problem`] map (shared with the core middlewares so the /// hosts cannot drift): [`ChargeError::MintUnreachable`] → `503` + `Retry-After` -/// (token NOT consumed; NEVER a 402), [`ChargeError::MalformedRequest`] → `400`, -/// everything else (verification / malformed-credential) → `402` + a fresh -/// challenge. Only the JSON *body* is gateway-specific (a flat advisory frame, -/// not the middleware's RFC-9457 `problem+json`). +/// (token NOT consumed; NEVER a 402), [`ChargeError::MalformedRequest`] / +/// [`ChargeError::MethodUnsupported`] → `400`, everything else (verification / +/// malformed-credential) → `402` + a fresh challenge. Every body is RFC-9457 +/// `application/problem+json` with the absolute problem-type URI. fn charge_error_to_response(state: &AppState, e: ChargeError) -> Response where C: Redeemer, { - match charge_error_status(&e) { + let problem = Problem::for_error(&e); + let status = charge_error_status(&e); + match status { + StatusCode::PAYMENT_REQUIRED => challenge_402(state, Some(&problem)), StatusCode::SERVICE_UNAVAILABLE => ( - StatusCode::SERVICE_UNAVAILABLE, + status, [ (header::RETRY_AFTER, HeaderValue::from_static("2")), ( header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), + HeaderValue::from_static(PROBLEM_JSON), ), (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), ], - format!( - r#"{{"error":"mint_unavailable","detail":{}}}"#, - json_string(&e.to_string()) - ), + problem.to_json(), ) .into_response(), - - StatusCode::BAD_REQUEST => ( - StatusCode::BAD_REQUEST, + _ => ( + status, [ ( header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), + HeaderValue::from_static(PROBLEM_JSON), ), (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), ], - format!( - r#"{{"error":"bad_request","detail":{}}}"#, - json_string(&e.to_string()) - ), + problem.to_json(), ) .into_response(), - - _ => challenge_402(state, Some(&e.to_string())), - } -} - -/// Minimal JSON string escaper for a hand-built JSON body (advisory bodies; keeps -/// the dep surface small). -fn json_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } } - out.push('"'); - out } diff --git a/crates/pops-gateway/src/lib.rs b/crates/pops-gateway/src/lib.rs index 7007922..4203722 100644 --- a/crates/pops-gateway/src/lib.rs +++ b/crates/pops-gateway/src/lib.rs @@ -3,11 +3,12 @@ //! //! The gateway is a THIN HOST around the existing verify gate in //! `pops-core-verify`: it reuses `CashuCredential` (verify + -//! NUT-03 swap), the cashu-typed challenge codec, and the request envelope, and -//! adds only the host concerns — config, durable proof persistence -//! (persist-before-forward), upstream forwarding, health/readiness, and JSON -//! structured logs. Operator-run, non-custodial: redeemed bearer proofs settle -//! into the operator's `proofs_sink` (their money). +//! NUT-03 swap), the shared `draft-cashu-charge-01` request-object codec, and +//! the single-sourced problem map, and adds only the host concerns — config, +//! durable proof persistence (persist-before-forward), upstream forwarding, +//! health/readiness, and JSON structured logs. Operator-run, non-custodial: +//! redeemed bearer proofs settle into the operator's `proofs_sink` (their +//! money). //! //! See [`config`] for the declarative surface, [`gateway`] for the per-request //! orchestration, and [`build_router`] to assemble the axum app. diff --git a/crates/pops-gateway/src/main.rs b/crates/pops-gateway/src/main.rs index 28c94ed..31ff827 100644 --- a/crates/pops-gateway/src/main.rs +++ b/crates/pops-gateway/src/main.rs @@ -67,7 +67,9 @@ fn main() -> ExitCode { } /// Resolve the config path, read it, parse it, and validate it. Any failure is -/// returned as a fully-formed stderr message string (already structured). +/// returned as a fully-formed stderr message string (already structured). The +/// `POPS_BINDING_KEY` env var (hex), when set, overrides the TOML +/// `binding_key` — secrets travel better in env than in mounted files. fn load_config() -> Result { let path = std::env::var("POPS_GATEWAY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_string()); @@ -75,9 +77,13 @@ fn load_config() -> Result { let raw = std::fs::read_to_string(&path) .map_err(|e| format!("config file {path}: could not read: {e}"))?; - let config = Config::from_toml_str(&raw) + let mut config = Config::from_toml_str(&raw) .map_err(|e| format!("config file {path}: invalid TOML: {e}"))?; + if let Ok(key_hex) = std::env::var("POPS_BINDING_KEY") { + config.binding_key = Some(key_hex); + } + config.validate().map_err(|e| e.to_string()) } diff --git a/crates/pops-gateway/src/proofs_sink.rs b/crates/pops-gateway/src/proofs_sink.rs index 72fe64f..a54071b 100644 --- a/crates/pops-gateway/src/proofs_sink.rs +++ b/crates/pops-gateway/src/proofs_sink.rs @@ -29,7 +29,8 @@ pub struct ProofsRecord<'a> { pub received_at: u64, /// SHA-256 (lowercase hex) of the presented credential — the receipt ref. pub token_hash: &'a str, - /// Net value received (the requested `amount`). + /// Net value received: at least the requested `amount` (excess presented + /// value is retained, so this MAY exceed it). pub amount: u64, /// Unit of the redeemed value (`pop_`). pub unit: &'a str, diff --git a/crates/pops-gateway/tests/gateway.rs b/crates/pops-gateway/tests/gateway.rs index 4a37be3..f061c45 100644 --- a/crates/pops-gateway/tests/gateway.rs +++ b/crates/pops-gateway/tests/gateway.rs @@ -35,7 +35,10 @@ use pops_core_verify::challenge::CashuRequirement; use pops_core_verify::envelope::{ encode_payment_credentials, CashuPayload, EchoedChallenge, PaymentCredentials, }; -use pops_core_verify::mint_client::{MintClient, MintClientError}; +use pops_core_verify::mint_client::{MintClient, MintClientError, SwapOutcome}; + +use pops_core_verify::binding::BindingKey; +use pops_core_verify::envelope::{parse_payment_params, PaymentParams}; use pops_gateway::build_router; use pops_gateway::config::{Config, RouteConfig, ValidatedConfig}; @@ -50,6 +53,9 @@ enum SwapResponse { Echo, /// Transport failure → ChargeError::MintUnreachable. Unreachable, + /// Success whose swap-output DLEQ verdict failed — the serve-and-flag + /// path; the settle log must carry `dleq_ok=false`. + DleqInvalid, } struct MockMintClient { @@ -76,13 +82,24 @@ impl MintClient for MockMintClient { Ok(Vec::new()) } - async fn swap(&self, _mint_url: &MintUrl, proofs: Proofs) -> Result { + async fn swap( + &self, + _mint_url: &MintUrl, + proofs: Proofs, + ) -> Result { self.swap_calls.fetch_add(1, Ordering::SeqCst); match self.swap_response { - SwapResponse::Echo => Ok(proofs), + SwapResponse::Echo => Ok(SwapOutcome { + proofs, + dleq_ok: true, + }), SwapResponse::Unreachable => { Err(MintClientError::Unreachable("mock unreachable".into())) } + SwapResponse::DleqInvalid => Ok(SwapOutcome { + proofs, + dleq_ok: false, + }), } } } @@ -120,8 +137,10 @@ fn valid_token_string() -> String { .to_string() } -/// Wrap a raw cashuB token in the `Payment` auth envelope. -fn payment_header(token: &str) -> String { +/// Wrap a raw cashuB token in the `Payment` auth envelope around an UNISSUED +/// echo — fails the gateway's stateless binding by construction. For the +/// pre-binding paths (>1-credential 400) and the rejection test itself. +fn unissued_echo_header(token: &str) -> String { let creds = PaymentCredentials { challenge: EchoedChallenge { id: "test-id".into(), @@ -132,9 +151,58 @@ fn payment_header(token: &str) -> String { digest: None, opaque: None, expires: None, + description: None, }, payload: CashuPayload { - cashu_token: token.into(), + token: token.into(), + }, + source: None, + }; + format!("Payment {}", encode_payment_credentials(&creds)) +} + +/// Fetch a REAL challenge off the gateway (bare request → 402) and parse its +/// auth-params — the first half of the client dance. +async fn fetch_challenge(app: &Router) -> PaymentParams { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("challenge fetch"); + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + let www = resp + .headers() + .get(http::header::WWW_AUTHENTICATE) + .expect("WWW-Authenticate present") + .to_str() + .expect("ASCII") + .to_string(); + parse_payment_params(&www).expect("challenge params parse") +} + +/// The full client dance: fetch a real challenge from `app` and build the +/// `Authorization` header echoing every issued param verbatim around `token`. +async fn paid_header(app: &Router, token: &str) -> String { + let params = fetch_challenge(app).await; + let creds = PaymentCredentials { + challenge: EchoedChallenge { + id: params.id.clone(), + realm: params.realm.clone(), + method: params.method.clone(), + intent: params.intent.clone(), + request: params.request.clone(), + digest: params.digest.clone(), + opaque: params.opaque.clone(), + expires: params.expires.clone(), + description: params.description.clone(), + }, + payload: CashuPayload { + token: token.into(), }, source: None, }; @@ -169,9 +237,19 @@ fn validated_config( upstream_timeout: Some(std::time::Duration::from_secs( pops_gateway::config::DEFAULT_UPSTREAM_TIMEOUT_SECS, )), + mint_http_timeout: std::time::Duration::from_secs( + pops_gateway::config::DEFAULT_MINT_HTTP_TIMEOUT_SECS, + ), requirement: requirement(), max_proofs: pops_gateway::config::DEFAULT_MAX_PROOFS, routes, + binding_key: BindingKey::from_hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + ) + .expect("test binding key"), + challenge_ttl: std::time::Duration::from_secs( + pops_gateway::config::DEFAULT_CHALLENGE_TTL_SECS, + ), } } @@ -282,6 +360,22 @@ async fn bare_request_returns_402_with_www_authenticate() { .unwrap(), "no-store" ); + // The bare-402 body is the framework's payment-required problem. + assert_eq!( + resp.headers() + .get(http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "application/problem+json" + ); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/payment-required" + ); + assert_eq!(problem["status"], 402); // Neither the mint nor the upstream was contacted. assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on bare req"); assert_eq!(up_hits.load(Ordering::SeqCst), 0, "upstream not hit on 402"); @@ -289,6 +383,44 @@ async fn bare_request_returns_402_with_www_authenticate() { assert!(read_lines(&sink).is_empty(), "no proofs on a bare request"); } +// The gateway's challenge is the SHARED draft-cashu-charge-01 request object — +// the flat {"cashu_request": ...} dialect is dead. +#[tokio::test] +async fn gateway_challenge_request_param_is_the_spec_request_object() { + use pops_core_verify::challenge::decode_charge_request; + use pops_core_verify::envelope::parse_payment_params; + + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, _up_hits) = spawn_upstream("SECRET").await; + let (app, _swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let www = resp + .headers() + .get(http::header::WWW_AUTHENTICATE) + .expect("WWW-Authenticate present") + .to_str() + .unwrap() + .to_string(); + let params = parse_payment_params(&www).expect("Payment params parse"); + let decoded = decode_charge_request(¶ms.request) + .expect("request param decodes via the shared spec codec"); + assert_eq!(decoded.amount, Amount::from(10)); + assert_eq!(decoded.unit, pop_unit()); + assert_eq!(decoded.mints, vec![mint_a()], "mints derive from the creqA"); + assert!(decoded.creq_a.starts_with("creqA")); +} + // (b) valid credential → fresh_proofs line + upstream hit + body returned. #[tokio::test] async fn valid_credential_persists_then_forwards_and_returns_body() { @@ -297,11 +429,12 @@ async fn valid_credential_persists_then_forwards_and_returns_body() { let (upstream, up_hits) = spawn_upstream("THE-SECRET-PAYLOAD").await; let (app, swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + let auth = paid_header(&app, &valid_token_string()).await; let resp = app .oneshot( Request::builder() .uri("/protected") - .header(AUTHORIZATION, payment_header(&valid_token_string())) + .header(AUTHORIZATION, auth) .body(Body::empty()) .unwrap(), ) @@ -336,6 +469,108 @@ async fn valid_credential_persists_then_forwards_and_returns_body() { assert!(v["active_keyset_id"].as_str().is_some()); } +// Spec receipt §: the paid SUCCESS response carries Payment-Receipt + +// Cache-Control: private, the latter OVERRIDING the upstream's own +// Cache-Control (a paid response must never be shared-cacheable). +#[tokio::test] +async fn paid_success_carries_receipt_and_private_overrides_upstream_cache_control() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + + // An upstream that answers 200 with a PUBLIC Cache-Control. + let app_upstream = Router::new().fallback(any(|| async { + ( + [(http::header::CACHE_CONTROL, "public, max-age=600")], + "PAID-CONTENT", + ) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind upstream"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app_upstream).await; + }); + + let (app, _swaps) = gateway(&format!("http://{addr}"), &sink, SwapResponse::Echo, vec![]); + + // Inline the client dance so the challenge id is in hand for the receipt + // echo assertion. + let params = fetch_challenge(&app).await; + let creds = PaymentCredentials { + challenge: EchoedChallenge { + id: params.id.clone(), + realm: params.realm.clone(), + method: params.method.clone(), + intent: params.intent.clone(), + request: params.request.clone(), + digest: params.digest.clone(), + opaque: params.opaque.clone(), + expires: params.expires.clone(), + description: params.description.clone(), + }, + payload: CashuPayload { + token: valid_token_string(), + }, + source: None, + }; + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header( + AUTHORIZATION, + format!("Payment {}", encode_payment_credentials(&creds)), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get(http::header::CACHE_CONTROL) + .expect("Cache-Control on the paid 200") + .to_str() + .unwrap(), + "private", + "the upstream's public Cache-Control must not survive on a paid response" + ); + + let receipt_raw = resp + .headers() + .get("payment-receipt") + .expect("Payment-Receipt on the paid 200") + .to_str() + .unwrap() + .to_string(); + let receipt_bytes = URL_SAFE_NO_PAD + .decode(&receipt_raw) + .expect("Payment-Receipt is base64url-nopad"); + let receipt: serde_json::Value = + serde_json::from_slice(&receipt_bytes).expect("receipt JSON"); + assert_eq!(receipt["method"], "cashu"); + assert_eq!(receipt["status"], "success"); + assert_eq!( + receipt["challengeId"], params.id, + "the receipt echoes the issued challenge id" + ); + assert_eq!( + receipt["reference"].as_str().unwrap().len(), + 64, + "reference is the 64-hex token_hash" + ); + assert!(receipt["timestamp"].is_string()); + + let body = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + assert_eq!(&body[..], b"PAID-CONTENT"); +} + // (c) MintUnreachable → 503, no persist, not forwarded. #[tokio::test] async fn mint_unreachable_returns_503_no_persist_no_forward() { @@ -344,11 +579,12 @@ async fn mint_unreachable_returns_503_no_persist_no_forward() { let (upstream, up_hits) = spawn_upstream("SECRET").await; let (app, _swap_calls) = gateway(&upstream, &sink, SwapResponse::Unreachable, vec![]); + let auth = paid_header(&app, &valid_token_string()).await; let resp = app .oneshot( Request::builder() .uri("/protected") - .header(AUTHORIZATION, payment_header(&valid_token_string())) + .header(AUTHORIZATION, auth) .body(Body::empty()) .unwrap(), ) @@ -379,11 +615,12 @@ async fn upstream_down_still_persists_proofs() { let dead_upstream = "http://127.0.0.1:1"; // port 1: connection refused. let (app, swap_calls) = gateway(dead_upstream, &sink, SwapResponse::Echo, vec![]); + let auth = paid_header(&app, &valid_token_string()).await; let resp = app .oneshot( Request::builder() .uri("/protected") - .header(AUTHORIZATION, payment_header(&valid_token_string())) + .header(AUTHORIZATION, auth) .body(Body::empty()) .unwrap(), ) @@ -396,6 +633,11 @@ async fn upstream_down_still_persists_proofs() { "expected 502/504 on dead upstream, got {}", resp.status() ); + // A paid-but-failed response is an ERROR response: no receipt rides it. + assert!( + resp.headers().get("payment-receipt").is_none(), + "no Payment-Receipt on a post-charge error response" + ); // The swap DID run (we charged) ... assert_eq!(swap_calls.load(Ordering::SeqCst), 1, "charge executed"); // ... and CRUCIALLY the proofs are persisted despite the failed forward. @@ -531,15 +773,16 @@ async fn oversized_body_on_gated_path_returns_413_before_charge() { let dir = tempfile::tempdir().unwrap(); let sink = dir.path().join("proofs.jsonl"); let (upstream, up_hits) = spawn_upstream("SECRET").await; - // Cap at 64 bytes; send a valid credential + a 4 KiB body. + // Cap at 64 bytes; send a validly-bound credential + a 4 KiB body. let (app, swap_calls) = gateway_with_cap(&upstream, &sink, SwapResponse::Echo, vec![], 64); + let auth = paid_header(&app, &valid_token_string()).await; let resp = app .oneshot( Request::builder() .method("POST") .uri("/protected") - .header(AUTHORIZATION, payment_header(&valid_token_string())) + .header(AUTHORIZATION, auth) .body(Body::from(vec![b'x'; 4096])) .unwrap(), ) @@ -560,6 +803,356 @@ async fn oversized_body_on_gated_path_returns_413_before_charge() { assert_eq!(up_hits.load(Ordering::SeqCst), 0, "not forwarded"); } +// ───────────── error map: the gateway emits the shared problem wire ────────── + +/// A [`Redeemer`] whose failure is fixed up front, so the map test drives every +/// `ChargeError` through the REAL gateway response path. +struct CannedFailRedeemer { + make: fn() -> pops_core_verify::charge::ChargeError, +} + +#[async_trait] +impl pops_core_verify::redeemer::Redeemer for CannedFailRedeemer { + async fn verify_and_redeem( + &self, + _presented: &str, + _req: &pops_core_verify::redeemer::ChargeRequirement, + ) -> Result { + Err((self.make)()) + } +} + +#[tokio::test] +async fn gateway_emits_the_shared_problem_mapping_for_every_charge_error() { + // The gateway's (status, problem body) must equal the single-sourced + // problem_mapping table for every variant — the same table the core + // middlewares are tested against, so all hosts emit identically. + use pops_core_verify::charge::ChargeError; + use pops_core_verify::problem::problem_mapping; + + let cases: Vec ChargeError> = vec![ + || ChargeError::MintUnreachable { + mint_url: "https://mint-a.example.com".into(), + transport_detail: "timeout".into(), + indeterminate: false, + }, + || ChargeError::PaymentInsufficient { + required: 10, + presented: 8, + amount: 10, + expected_swap_fee: 0, + }, + || ChargeError::WrongUnit { + expected: "pop_1700000000".into(), + got: "sat".into(), + }, + || ChargeError::MintNotAllowed { + got: "https://evil.example".into(), + allowed: vec!["https://mint-a.example.com".into()], + }, + || ChargeError::MintUrlUserinfo { + url: "https://user@mint-a.example.com".into(), + }, + || ChargeError::MultiMintOrUnit, + || ChargeError::LockedToken, + || ChargeError::FeeTooHigh { + keyset_id: "009a1f293253e41e".into(), + input_fee_ppk: 100, + }, + || ChargeError::ShortKeysetIdUnresolved { + short_id: "00aabbccddeeff00".into(), + }, + || ChargeError::DoubleSpend, + || ChargeError::SwapRejected("mint said no".into()), + || ChargeError::Expired, + || ChargeError::ChallengeExpired, + || ChargeError::InvalidChallenge, + || ChargeError::MalformedCredential("bad".into()), + || ChargeError::TooManyProofs { got: 99, max: 8 }, + || ChargeError::MethodUnsupported { + method: "tempo".into(), + }, + || ChargeError::MalformedRequest("bad config".into()), + ]; + + for make in cases { + let dir = tempfile::tempdir().unwrap(); + let sink_path = dir.path().join("proofs.jsonl"); + let (upstream, _hits) = spawn_upstream("SECRET").await; + let credential = CannedFailRedeemer { make }; + let sink = ProofsSink::open(&sink_path).expect("open sink"); + let state = Arc::new(AppState::new( + validated_config(&upstream, &sink_path, vec![]), + credential, + sink, + )); + let app = build_router(state); + + let auth = paid_header(&app, &valid_token_string()).await; + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header(AUTHORIZATION, auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let mapping = problem_mapping(&make()); + assert_eq!( + resp.status().as_u16(), + mapping.status, + "gateway status drift for {}", + make() + ); + assert_eq!( + resp.headers() + .get(http::header::CONTENT_TYPE) + .expect("Content-Type present") + .to_str() + .unwrap(), + "application/problem+json", + "gateway error body must be problem+json for {}", + make() + ); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = + serde_json::from_slice(&body).expect("gateway problem body"); + assert_eq!(problem["type"], mapping.type_uri, "{}", make()); + assert_eq!(problem["title"], mapping.title, "{}", make()); + assert_eq!(problem["status"], mapping.status, "{}", make()); + assert!(problem["detail"].is_string(), "{}", make()); + } +} + +#[tokio::test] +async fn gateway_non_cashu_method_returns_400_method_unsupported() { + // A credential naming method="tempo" → the framework's method-unsupported + // 400, identical to the core middleware's mapping. + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, up_hits) = spawn_upstream("SECRET").await; + let (app, swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let creds = PaymentCredentials { + challenge: EchoedChallenge { + id: "test-id".into(), + realm: "pops-gateway".into(), + method: "tempo".into(), + intent: "charge".into(), + request: "echoed".into(), + digest: None, + opaque: None, + expires: None, + description: None, + }, + payload: CashuPayload { + token: "cashuBabc".into(), + }, + source: None, + }; + let header = format!("Payment {}", encode_payment_credentials(&creds)); + + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header(AUTHORIZATION, header) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/method-unsupported" + ); + assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on a 400"); + assert_eq!(up_hits.load(Ordering::SeqCst), 0, "not forwarded on a 400"); + assert!(read_lines(&sink).is_empty(), "nothing persisted on a 400"); +} + +// ───────────── challenge binding (per-request HMAC id + expires) ───────────── + +#[tokio::test] +async fn gateway_challenges_carry_per_request_hmac_ids_and_expires() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, _hits) = spawn_upstream("SECRET").await; + let (app, _swaps) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let first = fetch_challenge(&app).await; + // The id is a 32-byte HMAC output, not the dead fixed "pops-gateway". + let id_bytes = URL_SAFE_NO_PAD + .decode(&first.id) + .expect("id is base64url-nopad"); + assert_eq!(id_bytes.len(), 32, "id is an HMAC-SHA256 output"); + assert_ne!(first.id, "pops-gateway"); + // Stateless operation: every challenge carries expires. + assert!(first.expires.is_some(), "challenge carries expires"); +} + +#[tokio::test] +async fn gateway_rejects_unbound_challenge_echo_as_invalid_challenge() { + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, up_hits) = spawn_upstream("SECRET").await; + let (app, swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header(AUTHORIZATION, unissued_echo_header(&valid_token_string())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/invalid-challenge" + ); + // The token was never swapped or forwarded — binding precedes the charge. + assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on a bad echo"); + assert_eq!(up_hits.load(Ordering::SeqCst), 0, "not forwarded"); + assert!(read_lines(&sink).is_empty(), "nothing persisted"); +} + +#[tokio::test] +async fn gateway_rejects_tampered_request_echo_as_invalid_challenge() { + // Echo a REAL challenge but swap in a different request blob — the + // redirection the binding exists to catch. + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, _hits) = spawn_upstream("SECRET").await; + let (app, swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let params = fetch_challenge(&app).await; + let creds = PaymentCredentials { + challenge: EchoedChallenge { + id: params.id.clone(), + realm: params.realm.clone(), + method: params.method.clone(), + intent: params.intent.clone(), + request: format!("{}x", params.request), + digest: None, + opaque: None, + expires: params.expires.clone(), + description: None, + }, + payload: CashuPayload { + token: valid_token_string(), + }, + source: None, + }; + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header( + AUTHORIZATION, + format!("Payment {}", encode_payment_credentials(&creds)), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/invalid-challenge" + ); + assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on a tampered echo"); +} + +#[tokio::test] +async fn gateway_stale_challenge_returns_payment_expired() { + // Zero TTL ⇒ authentic-but-instantly-stale challenges: a faithful echo + // passes the HMAC, fails freshness → payment-expired, token untouched. + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, _hits) = spawn_upstream("SECRET").await; + let (mock, swap_calls) = MockMintClient::new(SwapResponse::Echo); + let credential = CashuCredential::new(mock); + let proofs_sink = ProofsSink::open(&sink).expect("open sink"); + let mut cfg = validated_config(&upstream, &sink, vec![]); + cfg.challenge_ttl = std::time::Duration::ZERO; + let state = Arc::new(AppState::new(cfg, credential, proofs_sink)); + let app = build_router(state); + + let auth = paid_header(&app, &valid_token_string()).await; + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header(AUTHORIZATION, auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::PAYMENT_REQUIRED); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + "https://paymentauth.org/problems/payment-expired" + ); + assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on a stale echo"); +} + +// Framework: a request bearing more than one Authorization: Payment credential +// is rejected with 400 (about:blank body), before any binding or swap. +#[tokio::test] +async fn gateway_multiple_payment_credentials_return_400() { + let dir = tempfile::tempdir().unwrap(); + let sink = dir.path().join("proofs.jsonl"); + let (upstream, up_hits) = spawn_upstream("SECRET").await; + let (app, swap_calls) = gateway(&upstream, &sink, SwapResponse::Echo, vec![]); + + let header = paid_header(&app, &valid_token_string()).await; + let mut req = Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_str(&header).expect("ascii"), + ); + req.headers_mut().append( + AUTHORIZATION, + http::HeaderValue::from_str(&header).expect("ascii"), + ); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(resp.into_body(), 1 << 16).await.unwrap(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!(problem["type"], "about:blank"); + assert_eq!(problem["status"], 400); + assert_eq!(swap_calls.load(Ordering::SeqCst), 0, "no swap on a 400"); + assert_eq!(up_hits.load(Ordering::SeqCst), 0, "not forwarded on a 400"); + assert!(read_lines(&sink).is_empty(), "nothing persisted on a 400"); +} + // A body AT/under the cap still forwards normally (the cap doesn't break // legitimate request bodies — guards against an off-by-one rejecting valid load). #[tokio::test] @@ -592,3 +1185,114 @@ async fn body_at_cap_forwards_normally() { ); assert_eq!(up_hits.load(Ordering::SeqCst), 1, "forwarded to upstream"); } + +// ─────────────── The settle log carries the DLEQ verdict ──────────────────── + +/// A minimal subscriber capturing INFO-and-above events as `field=value` +/// strings, so the test can assert the gateway's settle line without pulling +/// in a subscriber crate. +struct InfoCapture { + events: Arc>>, +} + +impl tracing::Subscriber for InfoCapture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + *metadata.level() <= tracing::Level::INFO + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct Collect(String); + impl tracing::field::Visit for Collect { + fn record_debug( + &mut self, + field: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + use std::fmt::Write; + let _ = write!(self.0, "{}={:?} ", field.name(), value); + } + } + let mut collected = Collect(String::new()); + event.record(&mut collected); + self.events.lock().expect("capture lock").push(collected.0); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} +} + +#[tokio::test] +async fn settle_log_carries_dleq_ok_verdict() { + // §security-dleq serve-and-flag at the gateway: a swap whose returned + // signatures failed DLEQ still settles (200, persisted, forwarded), and + // the operator-facing settle line carries `dleq_ok=false` so the incident + // is visible. The settle line is the gateway's ONLY dleq_ok surface. + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let _guard = tracing::subscriber::set_default(InfoCapture { + events: events.clone(), + }); + + let settled_with = |events: &Arc>>, verdict: &str| { + events + .lock() + .expect("capture lock") + .iter() + .any(|e| e.contains("charge settled") && e.contains(verdict)) + }; + + let dir = tempfile::tempdir().unwrap(); + // tracing caches per-callsite interest globally; a parallel test's cold + // hit on the settle callsite can cache `never` before this thread's + // dispatcher registers. Rebuild + retry bounds that race out without + // weakening the assertion. + for attempt in 0..5 { + tracing::callsite::rebuild_interest_cache(); + + let sink = dir.path().join(format!("proofs-{attempt}.jsonl")); + let (upstream, _hits) = spawn_upstream("GATED OK").await; + let (app, _swaps) = gateway(&upstream, &sink, SwapResponse::DleqInvalid, vec![]); + + let header = paid_header(&app, &valid_token_string()).await; + let resp = app + .oneshot( + Request::builder() + .uri("/protected") + .header(AUTHORIZATION, header) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "a failed swap-output DLEQ verdict still settles and serves" + ); + assert_eq!( + read_lines(&sink).len(), + 1, + "the redeemed value is persisted despite the failed verdict" + ); + + if settled_with(&events, "dleq_ok=false") { + break; + } + } + + let captured = events.lock().expect("capture lock"); + let settle_line = captured + .iter() + .find(|e| e.contains("charge settled")) + .unwrap_or_else(|| panic!("no settle line captured, got: {captured:?}")); + assert!( + settle_line.contains("dleq_ok=false"), + "the settle line must carry the DLEQ verdict, got: {settle_line}" + ); + assert!( + settle_line.contains("token_hash="), + "the settle line correlates by token_hash, got: {settle_line}" + ); +} diff --git a/skills/gate-a-service.md b/skills/gate-a-service.md index 130a32d..22162c5 100644 --- a/skills/gate-a-service.md +++ b/skills/gate-a-service.md @@ -45,8 +45,17 @@ The **five required config facts** at a glance: | `[charge].unit` | the `pop_` unit you accept (rotates — see below) | | `[charge].amount` | exact net value required per request (must be > 0) | -Authoritative doc (optionals, per-path `[[routes]]` gating, value model, -build-from-source): **[crates/pops-gateway/README.md](../crates/pops-gateway/README.md)**. +The knobs worth knowing about (all optional, sane defaults): + +| key | default | what it does | +|---|---|---| +| `binding_key` | generated at boot | Hex server secret the stateless challenge binding HMACs ids under (≥ 16 bytes; 32 recommended). The **`POPS_BINDING_KEY`** env var overrides it. Without a configured key, a restart invalidates outstanding challenges (clients just refetch the 402). The key is a secret — never log or share it. | +| `challenge_ttl_secs` | `300` | Lifetime stamped into each challenge's `expires` (must be > 0 — a 0-TTL challenge is born expired). | +| `mint_http_timeout_secs` | `10` | Bound on each mint HTTP call (keysets/keys/swap); a hung mint surfaces as the 503 mint-unavailable path. Must be > 0 — **`0` is a config error** (an unbounded mint call would hang a request whose token may already be consumed). | + +Authoritative doc (all optionals, per-path `[[routes]]` gating, value model, +build-from-source): **[crates/pops-gateway/README.md](../crates/pops-gateway/README.md)** +and the commented [`config.example.toml`](../crates/pops-gateway/config.example.toml). --- @@ -65,12 +74,19 @@ re-exported from feature): - **`CashuRequirement`** — what a holder must present: `unit` (a - `CurrencyUnit::Custom("pop_")`), `mints` (accepted mint set; empty means - "any"), `amount` (exact), and optional `payment_id` / `description` / - `single_use`. This is the config you build the challenge from. (Defined in + `CurrencyUnit::Custom("pop_")`), `mints` (accepted mint set; **must be + non-empty** — the challenge's `creqA` requires a non-empty `m`, so a Payment + host cannot emit a challenge from an empty set and the middleware answers a + bare request with `500` if misconfigured that way), `amount` (exact), and + optional `payment_id` / `description` / `single_use`. This is the config you + build the challenge from. (Defined in [`challenge.rs`](../crates/pops-core-verify/src/challenge.rs).) - **`require_charge_state(requirement) -> ChargeMiddlewareState>`** - — the convenience constructor that wires the default cdk-backed mint client. + — the convenience constructor that wires the default cdk-backed mint client + (mint HTTP bounded at 10s; `require_charge_state_with_mint_timeout` takes an + explicit bound). Chain **`.with_binding_key(BindingKey::from_hex(…)?)`** to + keep challenges valid across restarts (default: a fresh per-boot key) and + **`.with_challenge_ttl(Duration)`** to override the 300s `expires` TTL. - **`require_charge`** — the axum middleware function. Register it with `axum::middleware::from_fn_with_state(Arc::new(state), require_charge)`. @@ -90,12 +106,17 @@ let app = Router::new() ``` On a bare request the middleware returns `402` with the `WWW-Authenticate: -Payment` challenge; on a valid `Authorization: Payment ` retry it runs the -full verify + NUT-03 swap, then inserts the redeemed result into the request +Payment` challenge (HMAC-bound `id` + `expires`); on a valid `Authorization: +Payment ` retry it authenticates the echoed challenge, runs the full +verify + NUT-03 swap, then inserts the redeemed result into the request extensions, so your handler can read it via `Extension` (the redeemed -proofs + amount/unit/token_hash — that is the value you now hold). Failure +proofs + amount/unit/token_hash — that is the value you now hold — plus +**`dleq_ok`**: `false` means the mint's swap-returned signatures failed their +NUT-12 check, a mint-trust incident to alert on while still serving). Failure mapping: mint unreachable → `503` (token NOT consumed, retry); malformed request -→ `400`; any other verification failure → `402` + a fresh challenge. +/ non-`cashu` method → `400`; stale challenge or retired keyset → +`402 payment-expired`; any other verification failure → `402` + a fresh +challenge. Every error body is RFC-9457 `application/problem+json`. Authoritative source: the module docs in [`middleware.rs`](../crates/pops-core-verify/src/middleware.rs). @@ -111,22 +132,34 @@ in-process over an injected `globalThis.fetch` — no proxy, no Rust at runtime. **Pick this when** your service is a JS/TS serverless function (Vercel/Node) and you want the gate inside the function. +**Scope this mode honestly: it is an advisory demo surface.** The wasm package +exports the credential codec + `verify_and_redeem` only — **no challenge +issuance or binding API** (a wasm issuance API is recorded backlog). So the +challenge-side MUSTs the two Rust hosts enforce (HMAC-bound `id`, `expires`, +echo authentication, the `Payment-Receipt` on the 200) are YOUR route's job in +JS; the vercel demo issues a fixed `id` with no `expires` and emits no receipt +— fine for a demo, not the full Payment wire. + Minimal shape — the route loads the WASM, 402s a bare request, and on retry calls `parse_payment_credential` then `verify_and_redeem`: ```js const credsJson = wasm.parse_payment_credential(authorization); // extract the cashuB token -const cashuToken = JSON.parse(credsJson).payload.cashu_token; +const cashuToken = JSON.parse(credsJson).payload.token; const redeemed = await wasm.verify_and_redeem( // full verify + NUT-03 swap cashuToken, JSON.stringify(requirement), // { amount, unit, mints, payment_id, description, single_use } -); // resolves { ok, fresh_proofs, amount, unit, active_keyset_id, token_hash } +); // resolves { ok, fresh_proofs, amount, unit, active_keyset_id, token_hash, dleq_ok } ``` -`verify_and_redeem` **resolves** with the fresh proofs you now hold, or -**rejects** with `{ ok:false, code, message }` whose `code` maps to a status: -`mint-unreachable` → `503`, `malformed-request` → `400`, everything else → `402` -+ a fresh challenge. +`verify_and_redeem` **resolves** with the fresh proofs you now hold (PERSIST +them — they are the money; `dleq_ok: false` flags a mint-trust incident to +alert on while still serving), or **rejects** with +`{ ok:false, code, message, status, problem_type, problem_slug }` — answer with +the mapped `status` (`503` mint-unreachable + `Retry-After`, `400` +malformed-request/method-unsupported, everything else `402` + a fresh +challenge) and use `problem_type` for the RFC-9457 body, so the JS route emits +the same wire as the native hosts. Install the bindings prebuilt from GitHub — no Rust/wasm toolchain, no npm-registry auth: @@ -162,13 +195,30 @@ These hold no matter which mode you pick: given unit eventually goes **inactive**. Use your mint's currently-active unit: `GET /v1/keysets` and pick a keyset with `active: true`. A stale unit in your config silently stops accepting valid current pops. +- **Challenges are bound and they expire — in the two Rust hosts.** Every + challenge the gateway (mode 1) and the axum middleware (mode 2) issue carries + an HMAC-bound `id` (under `binding_key` / the middleware's `BindingKey`) and + an `expires` (default TTL 300s). A credential must echo every issued param + byte-for-byte or it is rejected as `invalid-challenge`; a stale echo is + `payment-expired`. Configure a stable `binding_key` if challenges must + survive a restart. Mode 3 has no issuance API, so binding/expiry are + whatever your JS route implements (the demo implements neither — see the + mode-3 scope note). +- **DLEQ serve-and-flag.** A missing/invalid NUT-12 DLEQ on the signatures the + mint returns from the redeeming swap is a mint-trust incident, NOT a payment + failure: the request still succeeds (the client's payment settled) and the + verdict surfaces to YOU — `dleq_ok=false` on the gateway's settle log line + (plus a WARN naming the mint), on `Extension.dleq_ok` in-process, + and on the WASM success object. Alert on it and consider quarantining the + mint. - **Health + fail-fast (gateway).** `GET /healthz` → `200` whenever the process is up; `GET /readyz` → `200` only if the mint is reachable (a cheap `GET /v1/keysets`), else `503`. On boot the gateway validates the config and **exits nonzero** with a single structured stderr line on any problem - (bad URL, malformed `pop_` unit, `amount <= 0`, unwritable `proofs_sink` - parent) — never a panic. The in-process and WASM modes surface the same - reachability concern as the `503` (mint-unreachable) mapping. + (bad URL, malformed `pop_` unit, `amount <= 0`, `mint_http_timeout_secs + = 0`, `challenge_ttl_secs = 0`, unwritable `proofs_sink` parent) — never a + panic. The in-process and WASM modes surface the same reachability concern as + the `503` (mint-unreachable) mapping. - **The "paid-but-upstream-down" v1 edge (gateway).** The pop is **redeemed before** the upstream call. If the upstream is down after a successful charge, the gateway returns `502`/`504`, the value is **already persisted** (you keep @@ -180,15 +230,29 @@ These hold no matter which mode you pick: ## Test your gate -To exercise your own `402` by hand — build a credential, present it, and watch -it gate through — you need the wire format. Build the credential with the -canonical encoders (don't hand-roll the base64/JSON): -**[skills/payment-credential.md](payment-credential.md)**. +The easiest end-to-end test is the `pop` CLI with a **real** held pop: -To pay your gate with a **real** held pop instead, drive the `pop` CLI: -`pop pay --token ` runs the full 402 dance — see +```sh +pop pay --token --max-amount 5000 +``` + +It runs the full dance against the current wire: fetches the 402, refuses an +expired challenge outright (`challenge_expired`), decodes and cross-checks the +challenge's `paymentRequest` (amount/unit/mints), splits the held token to the +exact charge, echoes every issued challenge param verbatim (so the gate's +HMAC binding verifies), and presents. A `paid: true` JSON result (with any +`change_token`) proves the whole gate. See **[skills/pop-wallet.md](pop-wallet.md)**. +To exercise the `402` by hand instead — build a credential yourself and watch +it gate through — you need the wire format: +**[skills/payment-credential.md](payment-credential.md)**. Two things bite +hand-rollers under the bound challenge: the credential must echo a challenge +**this server actually issued** (fetch a fresh 402 first; you cannot invent +`id`/`expires`), and the `request` param must be echoed **byte-for-byte** +(never decode-and-re-encode it). Build with the canonical encoders, not by +hand-assembling base64/JSON. + --- ## Which mode for which stack diff --git a/skills/payment-credential.md b/skills/payment-credential.md index 709a118..0e9e1df 100644 --- a/skills/payment-credential.md +++ b/skills/payment-credential.md @@ -1,8 +1,9 @@ # The `Payment` credential — wire format The single canonical description of the `Payment` HTTP auth-scheme that pops -uses: the `402` challenge a gate sends, and the `Authorization: Payment` -credential a client sends back. **Source of truth:** +uses (the `draft-cashu-charge-01` wire): the `402` challenge a gate sends, and +the `Authorization: Payment` credential a client sends back. **Source of +truth:** [`crates/pops-core-verify/src/envelope.rs`](../crates/pops-core-verify/src/envelope.rs) (every field name + encoding below matches it exactly). @@ -14,26 +15,57 @@ runs the whole dance for you (see **[skills/pop-wallet.md](pop-wallet.md)**). ## 1. The 402 challenge (`WWW-Authenticate`) -A gated resource answers a bare request with `402` and this response header: +A gated resource answers a bare request with `402` and this response header, +plus `Cache-Control: no-store`. A problem body on the 402 is a SHOULD, not a +uniform fact: the gateway's bare 402 carries an `application/problem+json` +`payment-required` body, while the in-process middleware's bare (no-attempt) +402 has an **empty body** — spec-legal; only failed attempts get a problem +body there: ``` -WWW-Authenticate: Payment id="…", realm="…", method="cashu", intent="charge", request="" +WWW-Authenticate: Payment id="…", realm="…", method="cashu", intent="charge", request="", expires="2026-03-15T12:05:00Z" ``` -- Five quoted-string auth-params: `id`, `realm`, `method`, `intent`, `request`. -- `request` is a **base64url-nopad**-encoded JSON object `{"cashu_request":"creqA…"}`. - The inner `creqA…` is a Cashu payment-request describing the charge (amount, - unit, accepted mints). Decode `request` (base64url-nopad), then read - `cashu_request` to get the `creqA…`. - -The scheme prefix (`Payment`) is tolerated present or absent when parsing the -params, and extra/reordered params are ignored — only those five are surfaced. +- Quoted-string auth-params. `id`, `realm`, `method`, `intent`, `request` are + always present; the two RUST hosts (pops-gateway and the axum middleware) + also always emit `expires` (RFC 3339) — their challenge is + **stateless-bound**: `id` is an HMAC-SHA256 over the issued params under a + server secret, and `expires` is the challenge's only expiry signal. A server + MAY additionally issue `digest` and/or `opaque`. (The wasm surface exports + no issuance/binding API; the serverless demo issues a fixed `id` with no + `expires` — an advisory demo surface, see + [gate-a-service.md](gate-a-service.md) mode 3.) +- `request` is a **base64url-nopad** (padding rejected) encoding of the + JCS-canonical (RFC 8785) JSON **request object**: + + ```json + { + "amount": "1", + "currency": "pop_1780372941", + "description": "optional memo", + "methodDetails": { "paymentRequest": "creqA…" } + } + ``` + + `methodDetails` carries exactly ONE field: **`paymentRequest`**, a NUT-18 + Cashu payment request (`creqA…`). It is **authoritative** for every payment + parameter and MUST encode `a` (amount), `u` (unit), and a non-empty `m` + (accepted mint URLs); its transport set is empty (in-band: the credential + comes back over this same HTTP channel) and it carries no `nut10` + spending-condition kind (bearer proofs only). `amount`/`currency` at the top + level MUST equal the creqA's `a`/`u` (amount compared as integers). + +**Client MUSTs before paying:** decode the payment request yourself and verify +its amount, unit, and mint set (reject a challenge whose creqA omits `a`/`u`/`m` +or disagrees with the top-level `amount`/`currency` — the codec in §4 does +this); and **do not submit a credential against a challenge whose `expires` is +past** — re-fetch the resource for a fresh challenge instead. ## 2. The credential to build (`Authorization`) Retry the **same URL and method** with an `Authorization` header carrying a -single opaque token: the scheme `Payment`, a space, then a -**base64url-nopad**-encoded JSON object of this exact shape: +single opaque token: the scheme `Payment`, a space, then a **base64url-nopad** +encoding of the JCS-canonical JSON object of this exact shape: ```json { @@ -41,61 +73,104 @@ single opaque token: the scheme `Payment`, a space, then a "id": "", "realm": "", "method": "cashu", - "intent": "", - "request": "" + "intent": "charge", + "request": "", + "expires": "" }, - "payload": { "cashu_token": "cashuB…" } + "payload": { "token": "cashuB…" }, + "source": "" } ``` -- `challenge` echoes all five `WWW-Authenticate` fields **verbatim**. -- `payload.cashu_token` is the `cashuB…` token you mint/select (with your Cashu - wallet) worth the charge's amount + unit at one of its accepted mints. - -Then: `header value = "Payment " + base64url_nopad(JSON.stringify(that object))`. - -On retry you get `200` (gated content) or `402` (re-challenge — e.g. wrong -amount/unit/mint, double-spend, or mint unreachable; on a `503` the token was -**not** consumed, so retry). - -## 3. Rules (exactly what the verifier enforces) - -From `envelope.rs` (`parse_payment_authorization` + the struct definitions): - -1. **Scheme is case-insensitive.** `Payment`, `PAYMENT`, `payment` all parse - (`PAYMENT_SCHEME`, matched with `eq_ignore_ascii_case`). Surrounding - whitespace is trimmed. -2. **The blob is base64url-nopad.** No padding (`=`), URL-safe alphabet - (`-`/`_`, never `+`/`/`). Any other form — including a legacy - `key="value"` param style — fails the base64 decode and is rejected. -3. **All five `challenge` fields are REQUIRED** — `id`, `realm`, `method`, - `intent`, `request` — and `payload.cashu_token` is required. A missing field - fails JSON parse. -4. **`method` must equal `cashu`** — exact, lowercase, case-sensitive - (`CASHU_METHOD`). Any other value is rejected as a wrong-method error. -5. **Extra fields are tolerated and ignored.** Unknown keys on `challenge` - (`source`, `description`, `opaque`, `digest`, `expires`, …) or on `payload` - round-trip silently — they neither help nor break parsing. +- `challenge` echoes **every issued auth-param verbatim** — `digest`, + `opaque`, and `description` too, iff the 402 carried them. The server + recomputes the HMAC `id` over the echo, so a dropped, altered, or invented + param (a decode/re-encode of `request` included) makes the credential + `invalid-challenge`. +- `payload.token` is the `cashuB…` (TokenV4) token you present. Its **total + value MUST be at least `amount + swap_fee`**. In THIS implementation + `swap_fee` is always 0: the verifier runs a **fee-free profile** and + **rejects fee-bearing keysets outright** (`input_fee_ppk > 0` → + `verification-failed`) — a bigger token does not make a fee-charging keyset + payable here. So present exactly `amount`. The server makes **no change**: + value above the requirement is accepted and **retained by the server**, so + present the exact total (split your token locally first; `pop pay` + automates this). + +Then: `header value = "Payment " + base64url_nopad(JCS(that object))`. + +## 3. What the verifier enforces + +From `envelope.rs` (`parse_payment_authorization`) and the verify core: + +1. **Scheme is case-insensitive** (`Payment`/`PAYMENT`/`payment`); surrounding + whitespace trimmed. More than one `Authorization: Payment` credential on a + request → `400`. +2. **The blob is base64url-nopad.** No `=` padding (a padded blob is + rejected), URL-safe alphabet. The `creqA…`/`cashuB…` strings *inside* the + JSON keep their own padding-tolerant encodings. +3. **Required fields:** all five `challenge` echoes plus `payload.token`. + `source` is optional and never required. Unknown extra fields are + tolerated. +4. **`method` must equal `cashu`** — exact, lowercase. Any other value is + `method-unsupported` (HTTP **400**, not 402). +5. **The echo must authenticate (the two Rust hosts):** the gateway and the + axum middleware recompute the `id`-HMAC over the echoed params — any + mismatch is `invalid-challenge` (402). A past `expires` is + `payment-expired` (402): re-fetch and pay the fresh challenge. (The + serverless demo performs neither check — mode-3 scope note in + [gate-a-service.md](gate-a-service.md).) +6. **The token must be a `cashuB` (TokenV4)** carrying ≥ 1 proof — + `cashuA…`/TokenV3 and over-the-proof-cap tokens are `malformed-credential`. + Bearer proofs only: a NUT-10 (P2PK/HTLC) locked proof is + `verification-failed`. The token's mint must be in the creqA's mint set + (canonicalized-URL comparison) and every proof's **resolved keyset** must + carry the required unit (the mint's published keyset, not the token's + declared unit, is the authority). +7. **Value check:** total < `amount + swap_fee` → `payment-insufficient` + (402). There is no over-payment rejection — excess is retained. +8. **Redemption = a NUT-03 swap at the issuing mint.** A swap rejected because + the keyset retired or its `final_expiry` passed → `payment-expired`; any + other rejection (a spent proof above all) → `verification-failed`. A + missing/invalid NUT-12 DLEQ on the signatures the swap *returns* is a + mint-trust incident, **not** a payment failure: the request still succeeds + and the server surfaces `dleq_ok: false` to its operator (the gateway's + settle log / `Redeemed.dleq_ok` / the wasm success object). + +### Outcomes + +| Status | Body `type` | Meaning | +|---|---|---| +| `200` | — | Paid. Both Rust hosts carry `Payment-Receipt` (base64url-nopad over JCS-canonical JSON: `method`, `challengeId`, `reference` = SHA-256 hex of your exact token string, `status`, `timestamp`, plus the optional `externalId` echoing the request's correlation id when one was issued) + `Cache-Control: private`, which overrides any upstream Cache-Control at the gateway. Mode-3 exception: the serverless demo emits neither (advisory demo surface). | +| `402` | `https://paymentauth.org/problems/` | A payment-verification failure + a **fresh challenge** in `WWW-Authenticate`. Slugs: `payment-required` (no attempt yet), `malformed-credential`, `invalid-challenge`, `payment-expired`, `payment-insufficient`, `verification-failed`. On `payment-expired`, re-present the **same token** against the fresh challenge once; a second consecutive `payment-expired` means the token's keyset expired — abandon it. | +| `400` | `https://paymentauth.org/problems/method-unsupported`, or `about:blank` | A malformed *request frame* (non-`cashu` method; >1 `Payment` credential), not a payment failure. | +| `503` | `about:blank` (+ `Retry-After`) | Mint unreachable — an infrastructure failure with **no problem type**. If the swap was never transmitted your token is NOT consumed: retry the same token. If a swap's outcome is unknown, check your proofs' state (NUT-07) before reusing or writing off the token. | + +Error bodies are RFC 9457 `application/problem+json` with absolute `type` +URIs. ## 4. Canonical builders — don't hand-roll the encoding -The same crate that parses the credential exposes the inverse builder, so you -never assemble the base64/JSON yourself: +The same crate that parses the credential exposes the inverse builders: - **WASM (JS/TS):** `build_payment_credential(credentials_json)` — takes the JSON object from §2 **as a string**, returns the bare base64url-nopad blob; - you prepend `Payment ` to form the header value. (Also available: - `parse_payment_params(www_authenticate)` to read the 402 header, - `decode_request_envelope(b64)` to unwrap the `request` → `creqA…`, and - `parse_payment_credential(authorization)` to parse a credential. See + you prepend `Payment `. Also: `parse_payment_params(www_authenticate)` to + read the 402 header, `decode_request_object(b64)` / + `encode_request_object(json)` for the `request` object, + `parse_payment_credential(authorization)` to parse a credential, and the + full `verify_and_redeem(token, requirement_json)` for the server side. (See [`crates/pops-core-verify/src/wasm.rs`](../crates/pops-core-verify/src/wasm.rs).) -- **Native (Rust):** `encode_payment_credentials(&PaymentCredentials)` — returns - the same bare base64url-nopad blob; prepend `Payment `. The inverse is - `parse_payment_authorization(header_value)`. (See `envelope.rs`.) - -Both live in -[`crates/pops-core-verify/src/envelope.rs`](../crates/pops-core-verify/src/envelope.rs) -(the WASM names are thin wrappers over it in `wasm.rs`). + One divergence from the native hosts: the wasm mint client does not parse + NUT error codes, so a swap the mint rejected for keyset retirement or + expiry answers `verification-failed` rather than `payment-expired` (the + client gets no re-present-once signal on that path). +- **Native (Rust):** `encode_payment_credentials(&PaymentCredentials)` — + returns the same bare blob; prepend `Payment `. The inverse is + `parse_payment_authorization(header_value)`; the request object codec is + `encode_request_object`/`decode_request_object` (cashu-aware: + `challenge::encode_charge_request`/`decode_charge_request`, which also + enforce the creqA `a`/`u`/`m` rules). (See `envelope.rs`.) --- @@ -104,15 +179,22 @@ Both live in 402 challenge in: ``` -WWW-Authenticate: Payment id="ch-1", realm="my-gate", method="cashu", intent="charge", request="eyJjYXNo…" +WWW-Authenticate: Payment id="kM9xPqWvT2nJrHsY4aDfEb", realm="my-gate", method="cashu", intent="charge", request="eyJhbW91bnQ…", expires="2026-03-15T12:05:00Z" ``` -Credential out (before base64url-nopad-encoding the object): +Credential out (before base64url-nopad-encoding the JCS object): ```json { - "challenge": { "id": "ch-1", "realm": "my-gate", "method": "cashu", "intent": "charge", "request": "eyJjYXNo…" }, - "payload": { "cashu_token": "cashuBpGFt…" } + "challenge": { + "expires": "2026-03-15T12:05:00Z", + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "intent": "charge", + "method": "cashu", + "realm": "my-gate", + "request": "eyJhbW91bnQ…" + }, + "payload": { "token": "cashuBpGFt…" } } ``` diff --git a/skills/pop-wallet.md b/skills/pop-wallet.md index f2ddf57..038e618 100644 --- a/skills/pop-wallet.md +++ b/skills/pop-wallet.md @@ -64,7 +64,7 @@ CONTRACT below and `pop-wallet.schema.json` for the schema. - **Failure**: `{ "schema_version": 1, "error": { "code", "retriable", "message", "details"? } }` on **stdout**, with a **non-zero exit (1)**. Two failure signals: the `error` key AND the non-zero exit. - - `code` — stable lower_snake_case enum (the 31 codes below). **Branch on + - `code` — stable lower_snake_case enum (the 33 codes below). **Branch on `code`, never on `message`.** - `retriable` — bool; `true` ⟺ the failure is transient (safe to retry the same call as-is). @@ -445,10 +445,11 @@ print as a readable totals-plus-per-state table. Append one JSON object per line to `~/.pop-wallet/agent-activity.jsonl` for **every** wallet action you take. This is your memory of *when* and *why*; the -wallet's `pop list --json` is the source of truth for current *state*. (Note: the -wallet's `list/status --json` exposes `ts_expiry` and `recover_after_utc` but NOT -the deposit's creation time — so your log's `at` timestamp is what lets you -answer "the bitcoin I locked last Tuesday".) +wallet's `pop list --json` is the source of truth for current *state*. (The +deposit JSON exposes the creation time too — `created_at` (unix seconds) and +`created_at_utc` — alongside `ts_expiry` / `recover_after_utc`, so "the bitcoin +I locked last Tuesday" is answerable from `list` alone; your log's `at` adds +the *why* and the human context.) **Line format** (compact JSON, one per line): @@ -540,7 +541,7 @@ Schema: `pop-wallet.schema.json` (next to this file). Filled example --- -## ERROR CONTRACT — the 31 codes (FROZEN, additive-only) +## ERROR CONTRACT — the 33 codes (FROZEN, additive-only) On failure, `pop` writes `{ "schema_version": 1, "error": { "code", "retriable", "message", "details"? } }` to **stdout** and exits **1**. Branch on `code` + @@ -563,17 +564,19 @@ from them. | `quote_expired` | false | needs_input | `{quote_id, expired_at}` REQ | STOP polling + re-`quote`; funds sent are recoverable after CLTV | | `amount_mismatch` | false | needs_input | `{expected_sats, funded_sats}` REQ | PoP is exact-amount; funds are safe on-chain — `recover` that deposit | | `value_below_fee` | false | needs_input | `{value_sats, fee_sats}` REQ | UTXO uneconomical to sweep — lower `--fee` or wait for feerate | +| `fee_too_high` | false | needs_input | `{fee_sats, value_sats, max_percent}` REQ | (`recover`) the AUTO-estimated sweep fee would eat ≥ `max_percent`% of the UTXO — refused BEFORE broadcast. Pass an explicit `--fee ` if intentional, `--no-broadcast` to inspect, or wait for the feerate to drop | | `not_402` | false | terminal | `{url, status_got}` REQ | (`pay`) the URL answered neither 2xx nor 402 — it isn't gating with payment; check the URL | | `payment_rejected` | false | terminal/needs_input | `{required_amount?, unit?, reason?}` REQ when the 402 told us | (`pay`) service rejected the payment | | `no_payment_challenge` | false | terminal | `{url, reason}` REQ | (`pay`) the 402 had no parseable `WWW-Authenticate: Payment` challenge; nothing to satisfy | -| `challenge_parse_failed` | false | terminal | `{reason}` REQ | (`pay`) the challenge didn't decode into a charge (bad request envelope/`creqA`, or missing amount) | +| `challenge_parse_failed` | false | terminal | `{reason}` REQ | (`pay`) the challenge didn't decode into a charge (bad request object/`creqA`, or missing amount) | +| `challenge_expired` | false | needs_input | `{url, expires}` REQ | (`pay`) the 402 challenge's `expires` is already past — a credential must NOT be submitted against it, so NOTHING was sent and the held token is intact. Re-request the resource for a fresh challenge, then pay that | | `token_unit_mismatch` | false | needs_input | `{required, got}` REQ | (`pay`) `--token` unit ≠ the charge's unit; present a token in `required` — SENT NOTHING | | `token_mint_mismatch` | false | needs_input | `{token_mint, accepted_mints}` REQ | (`pay`) `--token`'s mint isn't accepted by the charge (or the charge named no mints); use an accepted-mint token — SENT NOTHING | | `insufficient_token_value` | false | needs_input | `{have, need}` REQ | (`pay`) `--token` is worth less than the charge (+ any swap fee, folded into `need`); present a bigger token — SENT NOTHING | | `amount_exceeds_cap` | false | needs_input | `{amount, cap}` REQ | (`pay`) the charge exceeds `--max-amount`; raise the cap only if you trust the charge — SENT NOTHING | | `swap_failed` | false | terminal | `{reason}` REQ | (`pay`) the NUT-03 swap-to-exact failed; the token may be unspent (verify) — nothing presented to the gateway | | `exact_amount_assertion_failed` | false | terminal | `{required, got}` REQ | (`pay`) INTERNAL money-safety abort — the send set didn't equal the charge; SENT NOTHING. Must never happen — report it | -| `gateway_rejected_payment` | false | terminal | `{status, body, send_token, change_token?}` REQ(status, body, send_token) | (`pay`) the gateway answered non-2xx after a valid payment; surface `body`. The gateway did NOT redeem, so **`send_token` (worth the charge) AND any `change_token` are unspent ecash — RECOVER BOTH** (the pop's input was spent by the swap). `--human` mode prints both tokens to stderr | +| `gateway_rejected_payment` | false | terminal | `{status, body, send_token, change_token?}` REQ(status, body, send_token) | (`pay`) the gateway answered non-2xx after the token was sent; surface `body`. On a **4xx** (verification rejection) the gateway did NOT redeem — `send_token` (worth the charge) AND any `change_token` are unspent ecash; RECOVER BOTH (the pop's input was spent by the swap). On a **5xx** the error can FOLLOW a successful swap, so `send_token`'s redemption state is UNKNOWN — hold it and verify before assuming it is spendable (or spent); `change_token` was never sent and stays unspent. `--human` mode prints both tokens to stderr | | `gateway_retry_failed` | false | terminal | `{reason, send_token, change_token?}` REQ(reason, send_token) | (`pay`) the payment-retry HTTP call failed at the transport layer AFTER the swap spent the input — the retry never reached the gateway. **`send_token` (worth the charge) AND any `change_token` are unspent ecash — RECOVER BOTH and present `send_token` to the gateway directly; do NOT retry with the original `--token` (it is spent).** `--human` prints both to stderr | | `token_encode_failed` | false | terminal | `{reason, send_proofs?, change_proofs?}` REQ(reason) | (`pay`/`mint`) INTERNAL: the ecash was issued (`mint`) or swapped (`pay`) but a proof set could not be encoded to a `cashuB` string. The raw proofs are in `details.send_proofs`/`details.change_proofs` (and printed in `--human`) — they ARE your ecash; re-encode to recover. On `mint` only `send_proofs` is present (the freshly-issued token). Must never happen — report it | | `address_mismatch` | false | terminal (security) | `{expected, got}` REQ | mint's address ≠ our reconstruction — do NOT fund; tell the human | @@ -599,14 +602,15 @@ Notes: - `cltv_not_expired` is the **single**-`--deposit` immature signal; a `--all` sweep instead returns a per-deposit `"status":"immature"` row (see recover). - **`pay`-path codes** (`not_402`, `payment_rejected`, `no_payment_challenge`, - `challenge_parse_failed`, `token_unit_mismatch`, `token_mint_mismatch`, - `insufficient_token_value`, `amount_exceeds_cap`, `swap_failed`, - `exact_amount_assertion_failed`, `gateway_rejected_payment`, + `challenge_parse_failed`, `challenge_expired`, `token_unit_mismatch`, + `token_mint_mismatch`, `insufficient_token_value`, `amount_exceeds_cap`, + `swap_failed`, `exact_amount_assertion_failed`, `gateway_rejected_payment`, `gateway_retry_failed`, `token_encode_failed`) all belong to `pop pay` — EXCEPT `token_encode_failed`, which `pop mint` ALSO emits if a freshly-issued token cannot be encoded to its `cashuB` string (then only `send_proofs` is present; the issued ecash is recoverable from them). The - validation codes (`token_*`, `insufficient_token_value`, `amount_exceeds_cap`) + validation codes (`challenge_expired`, `token_*`, + `insufficient_token_value`, `amount_exceeds_cap`) and `swap_failed` are raised BEFORE / without a completed swap — when you see them, **no token was sent and the held pop is intact** (verify on `swap_failed`). `exact_amount_assertion_failed` also sends nothing (it fires before the gateway). diff --git a/ts/README.md b/ts/README.md index d1a2e1c..98c0458 100644 --- a/ts/README.md +++ b/ts/README.md @@ -107,8 +107,8 @@ for production.) The package exports (see `pkg/pops_core_verify.d.ts` after a build): `verify_and_redeem` (the full verify+redeem, async), `parse_payment_credential`, -`build_payment_credential`, `parse_payment_params`, `encode_request_envelope`, -`decode_request_envelope`. +`build_payment_credential`, `parse_payment_params`, `encode_request_object`, +`decode_request_object`. --- @@ -131,8 +131,14 @@ curl -i localhost:3000/api/secret # 402 + WWW-Authenticate: Payment chall `vercel-demo/package.json` also exposes `npm run build:wasm` (a passthrough to `../build-wasm.sh`) so the bindings can be (re)built from inside the demo. -The mint the demo redeems against defaults to a local pops mint; override at -runtime with `POPS_MINT_URL` / `POPS_UNIT` / `POPS_AMOUNT`. The gated route runs +The mint the demo redeems against defaults to `http://localhost:3338` (a +placeholder for a locally-run pops mint — configure it for anything real); +override at runtime with `POPS_MINT_URL` / `POPS_UNIT` / `POPS_AMOUNT` **and** +`POPS_CREQ_A`. The trap: the route's challenge embeds a pre-encoded NUT-18 +`creqA` whose `a`/`u`/`m` must equal the requirement, so overriding +`POPS_MINT_URL`/`POPS_UNIT`/`POPS_AMOUNT` **without** a matching `POPS_CREQ_A` +yields a self-contradicting challenge that conformant clients (e.g. `pop pay`) +refuse. The gated route runs on the **Node** runtime (not Edge) — the wasm-pack nodejs glue reads its `.wasm` via `fs.readFileSync`, and `next.config.js` keeps the package un-bundled (`serverExternalPackages` + a webpack external) so `__dirname` stays intact for diff --git a/ts/vercel-demo/app/api/secret/route.ts b/ts/vercel-demo/app/api/secret/route.ts index 0494f68..c95efc5 100644 --- a/ts/vercel-demo/app/api/secret/route.ts +++ b/ts/vercel-demo/app/api/secret/route.ts @@ -6,32 +6,64 @@ * (WASM `parse_payment_credential`) and runs the full verify+redeem (WASM * `verify_and_redeem`), which performs the NUT-03 swap against the pops mint * over an injected `globalThis.fetch`, inside this Node serverless function. On - * success it returns 200 + the gated payload; a `ChargeError` becomes 402 (or - * 503 for a transport failure / 400 for a malformed request), mapped off the - * structured `code` the WASM rejection carries. + * success it returns 200 + the gated payload; a rejection carries its mapped + * HTTP `status` and absolute `problem_type` from the verifier's single-sourced + * problem map, so this route answers with the same RFC-9457 wire as the native + * hosts (402 + fresh challenge / 503 + Retry-After / 400). + * + * DEMO SIMPLIFICATIONS (deliberate; a production host does more): + * - The challenge id is a fixed string and the echoed challenge is NOT + * authenticated. The framework's stateless HMAC binding + `expires` live in + * the Rust hosts (pops-gateway / the axum middleware); the wasm surface + * exposes the codec + verify_and_redeem, not challenge issuance. + * - `CREQ_A` is pre-encoded for the DEFAULT requirement below. If you override + * POPS_MINT_URL / POPS_UNIT / POPS_AMOUNT you must supply a matching creqA + * (POPS_CREQ_A): clients cross-check the request object's amount/currency + * against the creqA's `a`/`u` and refuse a challenge where they disagree. * * Node runtime, not Edge: the wasm-pack `nodejs` glue reads its `.wasm` via * `fs.readFileSync`, which Edge cannot do. */ import { NextRequest } from "next/server"; +import { appendFileSync } from "node:fs"; // Node runtime is required: Edge cannot `fs.readFileSync` the wasm. export const runtime = "nodejs"; // Always run the gate; never cache the 402/200 decision. export const dynamic = "force-dynamic"; +/** What `verify_and_redeem` resolves with: the swap executed and the server + * (you) now holds `fresh_proofs` — spendable bearer value. `dleq_ok: false` + * means the mint's swap-returned signatures failed their NUT-12 check: a + * mint-trust incident to alert on, NOT a payment failure (the request is + * still served). */ +type Redeemed = { + ok: boolean; + amount: number; + unit: string; + active_keyset_id: string; + token_hash: string; + fresh_proofs: string; + dleq_ok: boolean; +}; + +/** What `verify_and_redeem` rejects with: the fine-grained `code` plus the + * verifier's problem mapping (HTTP `status`, absolute `problem_type` URI, + * `problem_slug` or null). */ +type ChargeRejection = { + ok: false; + code: string; + message: string; + status: number; + problem_type: string; + problem_slug: string | null; +}; + // Structural type for the wasm-pack (nodejs target) module's exports. type PopsWasm = { - encode_request_envelope(creqA: string): string; + encode_request_object(requestObjectJson: string): string; parse_payment_credential(authorization: string): string; - verify_and_redeem(presented: string, reqJson: string): Promise<{ - ok: boolean; - amount: number; - unit: string; - active_keyset_id: string; - token_hash: string; - fresh_proofs: string; - }>; + verify_and_redeem(presented: string, reqJson: string): Promise; }; // Lazy, request-time load of the WASM package. Deferred (not a top-level @@ -52,93 +84,138 @@ const REALM = "pops-vercel-demo"; const CHALLENGE_ID = "pops-demo"; // The demo charge requirement that gates verification, passed to the WASM -// `verify_and_redeem`. Points at the local pops mint; override the mint at -// deploy time via POPS_MINT_URL if reachable from the function. -const MINT_URL = process.env.POPS_MINT_URL || "http://100.96.251.111:3338"; +// `verify_and_redeem`. The default mint is a PLACEHOLDER for a locally-run +// pops mint — it MUST be configured (POPS_MINT_URL) for any real deployment. +// TRAP: overriding POPS_MINT_URL / POPS_UNIT / POPS_AMOUNT without a matching +// POPS_CREQ_A yields a SELF-CONTRADICTING challenge that conformant clients +// refuse (they cross-check the request object against the creqA's a/u/m). +const MINT_URL = process.env.POPS_MINT_URL || "http://localhost:3338"; const UNIT = process.env.POPS_UNIT || "pop_1780372941"; const AMOUNT = Number(process.env.POPS_AMOUNT || "1"); +const DESCRIPTION = "pops Vercel-Node gating demo"; const requirement = { amount: AMOUNT, unit: UNIT, mints: [MINT_URL], payment_id: CHALLENGE_ID, - description: "pops Vercel-Node gating demo", + description: DESCRIPTION, single_use: true, }; -// A fixed pre-encoded `creqA` matching `requirement`. Wrapped in the request -// envelope at response time via the WASM `encode_request_envelope`. +// A pre-encoded NUT-18 `creqA` matching the DEFAULT requirement above +// (i=pops-demo, a=1, u=pop_1780372941, m=["http://localhost:3338"], empty +// transports, no nut10). The authoritative payment artifact inside the request +// object — any env override of the requirement needs a matching POPS_CREQ_A. const CREQ_A = - "creqApmFpaXBvcHMtZGVtb2FhAWF1bnBvcF8xNzgwMzcyOTQxYXP1YW2BeBpodHRwOi8vMTAwLjk2LjI1MS4xMTE6MzMzOGFkeBxwb3BzIFZlcmNlbC1Ob2RlIGdhdGluZyBkZW1v"; + process.env.POPS_CREQ_A || + "creqApmFpaXBvcHMtZGVtb2FhAWF1bnBvcF8xNzgwMzcyOTQxYXP1YW2BdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGFkeBxwb3BzIFZlcmNlbC1Ob2RlIGdhdGluZyBkZW1v"; + +// Where redeemed proofs are appended, one JSON line per settlement (same line +// shape as pops-gateway's proofs_sink). Set it to a path on durable storage +// when running on a real Node host (`next start`, a container, …). +const PROOFS_SINK = process.env.POPS_PROOFS_SINK; -/** Build the `WWW-Authenticate: Payment …` header value for a 402. */ +/** Build the `WWW-Authenticate: Payment …` header value for a 402. The + * `request` param is the base64url-nopad JCS request object — amount/currency + * at the top level, the authoritative creqA under + * `methodDetails.paymentRequest`. */ function wwwAuthenticate(wasm: PopsWasm): string { - const requestEnvelope = wasm.encode_request_envelope(CREQ_A); + const requestObject = wasm.encode_request_object( + JSON.stringify({ + amount: String(AMOUNT), + currency: UNIT, + description: DESCRIPTION, + methodDetails: { paymentRequest: CREQ_A }, + }), + ); return [ `Payment id="${CHALLENGE_ID}"`, `realm="${REALM}"`, `method="cashu"`, `intent="charge"`, - `request="${requestEnvelope}"`, + `request="${requestObject}"`, ].join(", "); } -/** A fresh 402 carrying the challenge. */ -function challenge402(wasm: PopsWasm, detail?: { code: string; message: string }) { - const body = { - error: "payment_required", - ...(detail ? { code: detail.code, detail: detail.message } : {}), - realm: REALM, +/** RFC-9457 problem response helpers (`application/problem+json`). */ +function problemBody(type: string, status: number, detail: string): string { + return JSON.stringify({ type, status, detail }); +} + +/** A fresh 402 carrying the challenge. `problem` defaults to the framework's + * bare payment-required type when no attempt failed. */ +function challenge402( + wasm: PopsWasm, + problem?: { type: string; detail: string }, +): Response { + const p = problem ?? { + type: "https://paymentauth.org/problems/payment-required", + detail: `payment required for realm "${REALM}"`, }; - return new Response(JSON.stringify(body), { + return new Response(problemBody(p.type, 402, p.detail), { status: 402, headers: { "WWW-Authenticate": wwwAuthenticate(wasm), - "Content-Type": "application/json", + "Content-Type": "application/problem+json", "Cache-Control": "no-store", }, }); } -/** - * Map a structured `ChargeError` rejection from the WASM boundary onto an HTTP - * status. These three concerns map to distinct statuses: - * (A) transport -> 503 (token not consumed, retryable) - * (B) verification -> 402 (+ fresh challenge, terminal for this token) - * (C) malformed request -> 400 (not 402) - */ -function errorToResponse(wasm: PopsWasm, err: unknown): Response { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code: unknown }).code) - : "charge-error"; - const message = - err && typeof err === "object" && "message" in err - ? String((err as { message: unknown }).message) - : String(err); - - // (A) transport — keep the token, retry. - if (code === "mint-unreachable") { - return new Response( - JSON.stringify({ error: "mint_unavailable", code, detail: message }), - { - status: 503, - headers: { "Content-Type": "application/json", "Retry-After": "2", "Cache-Control": "no-store" }, - }, - ); - } +/** Map a `verify_and_redeem` rejection onto the HTTP answer using the mapped + * `status` + `problem_type` it carries — the same wire the native hosts emit: + * 503 (+ Retry-After; token not consumed, retryable), 400 (malformed request + * frame), anything else 402 + a fresh challenge. */ +function rejectionToResponse(wasm: PopsWasm, err: unknown): Response { + const r = (err ?? {}) as Partial; + const status = typeof r.status === "number" ? r.status : 402; + const type = typeof r.problem_type === "string" ? r.problem_type : "about:blank"; + const detail = + typeof r.message === "string" ? r.message : String(err ?? "charge error"); - // (C) malformed request (server-side config / method) — 400, not 402. - if (code === "malformed-request") { - return new Response(JSON.stringify({ error: "bad_request", code, detail: message }), { - status: 400, - headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, - }); + if (status === 402) { + return challenge402(wasm, { type, detail }); + } + const headers: Record = { + "Content-Type": "application/problem+json", + "Cache-Control": "no-store", + }; + if (status === 503) { + headers["Retry-After"] = "2"; } + return new Response(problemBody(type, status, detail), { status, headers }); +} - // (B) everything else is a verification failure — 402 + fresh challenge. - return challenge402(wasm, { code, message }); +/** Keep the redeemed value. `fresh_proofs` ARE the money: the presented token + * is consumed by the swap, and these proofs are its only surviving form. A + * real deployment MUST persist them durably (a database, the gateway's + * proofs_sink pattern, …) — discarding them destroys the value the client + * just paid. This demo appends to POPS_PROOFS_SINK when configured (fine on a + * persistent Node host; a Vercel function has no durable disk) and otherwise + * falls back to logging the proofs so they are at least recoverable from the + * function logs. */ +function keepRedeemedValue(redeemed: Redeemed): void { + const line = JSON.stringify({ + received_at: Math.floor(Date.now() / 1000), + token_hash: redeemed.token_hash, + amount: redeemed.amount, + unit: redeemed.unit, + active_keyset_id: redeemed.active_keyset_id, + fresh_proofs: redeemed.fresh_proofs, + }); + if (PROOFS_SINK) { + appendFileSync(PROOFS_SINK, line + "\n"); + return; + } + // LAST RESORT — proofs in logs are spendable bearer secrets; anyone who can + // read the logs can spend them. Configure POPS_PROOFS_SINK (or persist to + // your own store) before taking real value. + console.error( + "POPS_PROOFS_SINK is not set; logging redeemed proofs so the value is not destroyed. " + + "PERSIST THIS LINE — it is spendable money:", + line, + ); } export async function GET(req: NextRequest): Promise { @@ -156,19 +233,34 @@ export async function GET(req: NextRequest): Promise { try { const credsJson = wasm.parse_payment_credential(authorization); const creds = JSON.parse(credsJson); - cashuToken = creds?.payload?.cashu_token; + cashuToken = creds?.payload?.token; if (typeof cashuToken !== "string" || cashuToken.length === 0) { - throw new Error("credential payload missing cashu_token"); + throw new Error("credential payload missing token"); } } catch (e) { - return challenge402(wasm, { code: "malformed-credential", message: String(e) }); + return challenge402(wasm, { + type: "https://paymentauth.org/problems/malformed-credential", + detail: String(e), + }); } // FULL verify + NUT-03 swap over injected fetch, in this Node function. try { const redeemed = await wasm.verify_and_redeem(cashuToken, JSON.stringify(requirement)); - // Success: the swap executed, the operator now holds `fresh_proofs`. A real - // operator would stash those; here we confirm settlement and gate. + + // The swap consumed the client's token; keep its only surviving form. + keepRedeemedValue(redeemed); + + if (!redeemed.dleq_ok) { + // Mint-trust incident (NOT a payment failure — the payment settled and + // the resource is served): the mint could not prove it signed the fresh + // proofs with its advertised key. Alert; consider quarantining the mint. + console.warn( + `swap-output DLEQ missing/invalid from mint ${MINT_URL} ` + + `(token_hash=${redeemed.token_hash}) — serving anyway per the spec`, + ); + } + return new Response( JSON.stringify({ secret: "the eagle lands at midnight", @@ -177,11 +269,12 @@ export async function GET(req: NextRequest): Promise { unit: redeemed.unit, active_keyset_id: redeemed.active_keyset_id, token_hash: redeemed.token_hash, + dleq_ok: redeemed.dleq_ok, }, }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }, ); } catch (err) { - return errorToResponse(wasm, err); + return rejectionToResponse(wasm, err); } }