diff --git a/proto/parser/parser.proto b/proto/parser/parser.proto index c9ee495e5..b29c246df 100644 --- a/proto/parser/parser.proto +++ b/proto/parser/parser.proto @@ -55,6 +55,25 @@ message ParseRequest { // Default false preserves the pre-feature behavior byte-for-byte: the parser // emits an empty `intermediate_output` and the signed digest is unchanged. bool include_intermediate_output = 4; + + // Borsh-encoded SignedVerifiedPaymentMarker: a VerifiedPaymentMarker plus + // the gateway's P256 signature over it. Note the wrapper, not the bare + // VerifiedPaymentMarker: an external signer that emits the inner struct + // produces bytes the enclave cannot deserialize. parser_app verifies this + // before processing when payment enforcement is on (PaymentPolicy::Required); + // today every call site passes PaymentPolicy::Disabled, so verification is a + // no-op and this field is not yet enforced. Empty for the open v1 routes and + // for local-dev / gRPC-direct callers. Field 5, not 4: field 4 shipped as + // include_intermediate_output (#414) while payment_marker was still on an + // unmerged branch, so payment_marker moved rather than breaking the wire. + // Size bound: parser_app rejects a payment_marker over 8 KiB on length + // alone, before Borsh-decoding it + // (parser_app::payment_verify::MAX_PAYMENT_MARKER_BYTES). A real marker is + // well under 1 KiB, so the limit is headroom, not a constraint a signer + // has to plan around. The server's whole-request gRPC cap (25MB, + // host_primitives::GRPC_MAX_RECV_MSG_SIZE) still applies on top, as it + // does to every other field on this message. + bytes payment_marker = 5; } message ChainMetadata { diff --git a/src/Cargo.lock b/src/Cargo.lock index 5601305a8..c68e67b03 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4635,6 +4635,7 @@ dependencies = [ "generated", "prost 0.12.6", "qos_core", + "qos_crypto", "serde", "serde_json", "tonic 0.10.2", @@ -7311,6 +7312,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "thiserror 2.0.17", "tokio", "visualsign", "visualsign-ethereum", diff --git a/src/generated/src/generated/descriptor.bin b/src/generated/src/generated/descriptor.bin index 60d2e930d..007c2be29 100644 Binary files a/src/generated/src/generated/descriptor.bin and b/src/generated/src/generated/descriptor.bin differ diff --git a/src/generated/src/generated/parser.rs b/src/generated/src/generated/parser.rs index 67aa80e87..52f7af317 100644 --- a/src/generated/src/generated/parser.rs +++ b/src/generated/src/generated/parser.rs @@ -58,6 +58,25 @@ pub struct ParseRequest { /// emits an empty `intermediate_output` and the signed digest is unchanged. #[prost(bool, tag = "4")] pub include_intermediate_output: bool, + /// Borsh-encoded SignedVerifiedPaymentMarker: a VerifiedPaymentMarker plus + /// the gateway's P256 signature over it. Note the wrapper, not the bare + /// VerifiedPaymentMarker: an external signer that emits the inner struct + /// produces bytes the enclave cannot deserialize. parser_app verifies this + /// before processing when payment enforcement is on (PaymentPolicy::Required); + /// today every call site passes PaymentPolicy::Disabled, so verification is a + /// no-op and this field is not yet enforced. Empty for the open v1 routes and + /// for local-dev / gRPC-direct callers. Field 5, not 4: field 4 shipped as + /// include_intermediate_output (#414) while payment_marker was still on an + /// unmerged branch, so payment_marker moved rather than breaking the wire. + /// Size bound: parser_app rejects a payment_marker over 8 KiB on length + /// alone, before Borsh-decoding it + /// (parser_app::payment_verify::MAX_PAYMENT_MARKER_BYTES). A real marker is + /// well under 1 KiB, so the limit is headroom, not a constraint a signer + /// has to plan around. The server's whole-request gRPC cap (25MB, + /// host_primitives::GRPC_MAX_RECV_MSG_SIZE) still applies on top, as it + /// does to every other field on this message. + #[prost(bytes = "vec", tag = "5")] + pub payment_marker: ::prost::alloc::vec::Vec, } #[cfg_attr( feature = "serde_derive", diff --git a/src/host_primitives/Cargo.toml b/src/host_primitives/Cargo.toml index 4c8532eb7..f1dc48705 100644 --- a/src/host_primitives/Cargo.toml +++ b/src/host_primitives/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] qos_core = { workspace = true} +qos_crypto = { workspace = true } # sha_256 for payment_marker hashing tonic = { version = "0.10", default-features = false } prost = { version = "0.12", features = [ diff --git a/src/host_primitives/src/lib.rs b/src/host_primitives/src/lib.rs index afc7ddb35..1ad886179 100644 --- a/src/host_primitives/src/lib.rs +++ b/src/host_primitives/src/lib.rs @@ -9,6 +9,7 @@ use prost::Message; use qos_core::protocol::{ProtocolError, msg::ProtocolMsg}; use tonic::Status; +pub mod payment_marker; pub mod turnkey; /// Buffer size for socket message queue. diff --git a/src/host_primitives/src/payment_marker.rs b/src/host_primitives/src/payment_marker.rs new file mode 100644 index 000000000..b6982784c --- /dev/null +++ b/src/host_primitives/src/payment_marker.rs @@ -0,0 +1,387 @@ +//! VerifiedPaymentMarker, the signed proof the gateway hands to parser_app +//! to certify that an x402 payment was verified + settled before the parse +//! request was forwarded. +//! +//! Wire shape: `borsh(SignedVerifiedPaymentMarker)` rides as +//! `ParseRequest.payment_marker` (bytes field). Same type defined here so +//! both gateway (signer) and parser_app (verifier) deserialize identical +//! bytes, no schema drift. +//! +//! Trust model: parser_app verifies the gateway's P256 signature against a +//! pubkey pinned at TVC deploy time (`GATEWAY_SIGNING_PUBKEY_HEX`), and +//! binds the marker to the full authenticated request body via +//! `request_hash` (all of `chain`, `unsigned_payload`, `chain_metadata`, and +//! `include_intermediate_output`). This stops a party holding one valid +//! marker (but not the gateway's signing key) from replaying it against a +//! different request with different metadata or intermediate-output +//! settings; it does not protect against a compromised signing key itself, +//! which could mint a fresh marker for any request. The settlement-side +//! fields live in [`PaymentDetails`] and are carried in the signed marker +//! for the record, but are not independently cross-checked by parser_app +//! today; that deeper verification (including recomputing +//! `x_payment_hash` from the forwarded `X-PAYMENT` bytes, which has no +//! transport in this proto yet) is deferred to v3.1. + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// The settlement facts for one payment, keyed by the scheme that produced +/// them. +/// +/// Borsh encodes an enum as a 1-byte little-endian variant index followed by +/// that variant's fields, so the variant order here is part of the wire +/// contract rather than an implementation detail: the gateway-side signer +/// writes the discriminant by hand. Add schemes by appending variants, never +/// by inserting or reordering. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub enum PaymentDetails { + /// A single x402 payment settled straight from one payer to one + /// recipient. The only scheme the gateway mints today. + X402Direct { + /// On-chain settlement signature, base58 (Solana). + txid: String, + /// Payer pubkey, base58 (Solana). + payer: String, + /// Recipient pubkey, base58 (Solana). + pay_to: String, + /// Atomic units paid (USDC has 6 decimals; "1000" = $0.001). + amount: String, + /// Asset mint, base58 (Solana). E.g. devnet USDC + /// `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`. + mint: String, + /// SHA-256 of the inner base64-decoded `X-PAYMENT` body. Intended to + /// let parser_app confirm the gateway didn't pair the buyer's signed + /// Solana tx with a different VPM, but this proto has no field + /// carrying the forwarded `X-PAYMENT` bytes yet and parser_app does + /// not check this value today; recomputing and checking it is + /// deferred to v3.1. + x_payment_hash: [u8; 32], + /// CAIP-2 network identifier (e.g. `solana:EtWTRABZaYq6...` for + /// devnet). + network: String, + }, +} + +/// The signed payload parser_app verifies. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct VerifiedPaymentMarker { + /// Bumped if this envelope changes incompatibly. A new payment scheme is + /// a new [`PaymentDetails`] variant instead of a bump: a verifier that + /// predates the variant fails the Borsh decode, which fails closed. + pub version: u32, + /// SHA-256 binding this marker to one specific parse request: see + /// [`request_hash`] for the exact preimage (chain, unsigned_payload, + /// chain_metadata, include_intermediate_output). + pub request_hash: [u8; 32], + /// What was paid, and under which scheme. Carried in the signed marker + /// for the record; parser_app does not cross-check these values today. + pub details: PaymentDetails, + /// Unix millis at which the gateway received the facilitator's settle + /// response. + /// + /// Signed for the record; nothing reads it today. There is deliberately + /// no max-age check in `parser_app`: `request_hash` already binds a + /// marker to one exact request, so a replayed marker can only re-run the + /// byte-identical parse, and parse is read-only. The exposure is + /// therefore repeat parses of an already-paid request, not a bypass, + /// which doesn't justify making enclave verification depend on clock + /// skew between the signer and the enclave. Revisit if a future scheme + /// makes markers fungible across requests. + pub settled_at_ms: u64, + /// SEC1-uncompressed hex of the gateway's P256 signing public key. + /// MUST decode to the same bytes as the pinned + /// `GATEWAY_SIGNING_PUBKEY_HEX` on parser_app. The verifier compares + /// decoded bytes, so an optional `0x` prefix and either case are + /// accepted on both sides. + pub gateway_pubkey_hex: String, +} + +pub const VPM_VERSION: u32 = 1; + +/// `borsh(SignedVerifiedPaymentMarker)` is what rides in +/// `ParseRequest.payment_marker`. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug)] +pub struct SignedVerifiedPaymentMarker { + pub vpm: VerifiedPaymentMarker, + /// P256 ECDSA (SHA-256) signature over the *message* + /// `qos_crypto::sha_256(borsh(vpm))` (i.e. `qos_p256::P256SignPair::sign` + /// hashes this 32-byte value again internally, so the effective digest + /// signed is `sha256(sha256(borsh(vpm)))`). Raw `r||s`, 64 bytes, no DER. + pub signature: Vec, +} + +impl VerifiedPaymentMarker { + /// Borsh-encode + SHA-256 the encoded bytes. This is what the gateway + /// signs and parser_app verifies against. + /// + /// # Errors + /// + /// Returns an error if Borsh serialization fails. This should not + /// happen in practice (all fields are owned scalar/String/array types), + /// but callers must propagate the error rather than substitute a fixed + /// fallback digest: signer and verifier run identical code, so a fixed + /// fallback would let a signature over that fallback verify against any + /// VPM that hits the same serialization failure. + pub fn signing_digest(&self) -> Result<[u8; 32], borsh::io::Error> { + let bytes = borsh::to_vec(self)?; + Ok(qos_crypto::sha_256(&bytes)) + } +} + +/// Compute `request_hash` over the full authenticated request: `chain`, +/// `unsigned_payload`, `chain_metadata` (pass `borsh::to_vec` of the inner +/// `ChainMetadata` value when present, NOT wrapped in `Option` (no +/// discriminant byte), or an empty slice if absent; matching the convention +/// used for `ParsedTransactionPayload::metadata_digest`), and +/// `include_intermediate_output`. Both gateway and parser_app call this so +/// the binding is unambiguous; every variable-length field is +/// length-prefixed so no two distinct inputs can hash to the same preimage, +/// as long as `unsigned_payload` and `chain_metadata_bytes` each stay under +/// 2^32 bytes (their length prefixes are `as u32`). The gRPC server's own +/// message-size cap is far below that bound today, so this is a +/// documentation caveat, not a live gap. +#[must_use] +pub fn request_hash( + chain: i32, + unsigned_payload: &str, + chain_metadata_bytes: &[u8], + include_intermediate_output: bool, +) -> [u8; 32] { + let mut buf = + Vec::with_capacity(4 + 4 + unsigned_payload.len() + 4 + chain_metadata_bytes.len() + 1); + buf.extend_from_slice(&chain.to_le_bytes()); + buf.extend_from_slice(&(unsigned_payload.len() as u32).to_le_bytes()); + buf.extend_from_slice(unsigned_payload.as_bytes()); + buf.extend_from_slice(&(chain_metadata_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(chain_metadata_bytes); + buf.push(u8::from(include_intermediate_output)); + qos_crypto::sha_256(&buf) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn vpm_round_trip_borsh() { + let vpm = VerifiedPaymentMarker { + version: VPM_VERSION, + request_hash: [1u8; 32], + details: PaymentDetails::X402Direct { + txid: "abc".into(), + payer: "Pay".into(), + pay_to: "Recv".into(), + amount: "1000".into(), + mint: "Mint".into(), + x_payment_hash: [2u8; 32], + network: "solana:test".into(), + }, + settled_at_ms: 1_700_000_000_000, + gateway_pubkey_hex: "04abcd".into(), + }; + let bytes = borsh::to_vec(&vpm).unwrap(); + let decoded = VerifiedPaymentMarker::try_from_slice(&bytes).unwrap(); + assert_eq!(vpm, decoded); + } + + #[test] + fn signing_digest_is_deterministic() { + let vpm = VerifiedPaymentMarker { + version: VPM_VERSION, + request_hash: [0u8; 32], + details: PaymentDetails::X402Direct { + txid: "tx".into(), + payer: String::new(), + pay_to: String::new(), + amount: "0".into(), + mint: String::new(), + x_payment_hash: [0u8; 32], + network: String::new(), + }, + settled_at_ms: 0, + gateway_pubkey_hex: String::new(), + }; + assert_eq!(vpm.signing_digest().unwrap(), vpm.signing_digest().unwrap()); + } + + #[test] + fn signing_digest_matches_hand_encoded_wire_bytes() { + // `signing_digest_is_deterministic` above only compares this + // implementation with itself, so a field reorder or type change in + // `VerifiedPaymentMarker`/`PaymentDetails` would still pass it even + // though it silently breaks interop with the external gateway + // signer. This test instead hand-assembles the expected bytes from + // Borsh's documented encoding rules (LE integers, 4-byte-len-prefixed + // strings, 1-byte enum variant index, raw fixed-size arrays) rather + // than deriving them from `borsh::to_vec` on this same struct, so a + // reorder changes `borsh::to_vec(&vpm)` but not `expected`, failing + // the test. + let vpm = VerifiedPaymentMarker { + version: 1, + request_hash: [0u8; 32], + details: PaymentDetails::X402Direct { + txid: "tx".into(), + payer: String::new(), + pay_to: String::new(), + amount: "0".into(), + mint: String::new(), + x_payment_hash: [0u8; 32], + network: String::new(), + }, + settled_at_ms: 0, + gateway_pubkey_hex: String::new(), + }; + + let mut expected = Vec::new(); + expected.extend_from_slice(&1u32.to_le_bytes()); // version + expected.extend_from_slice(&[0u8; 32]); // request_hash + expected.push(0); // PaymentDetails variant index: X402Direct + for s in ["tx", "", "", "0", ""] { + // txid, payer, pay_to, amount, mint: length-prefixed strings + expected.extend_from_slice(&(s.len() as u32).to_le_bytes()); + expected.extend_from_slice(s.as_bytes()); + } + expected.extend_from_slice(&[0u8; 32]); // x_payment_hash + expected.extend_from_slice(&0u32.to_le_bytes()); // network (empty) + expected.extend_from_slice(&0u64.to_le_bytes()); // settled_at_ms + expected.extend_from_slice(&0u32.to_le_bytes()); // gateway_pubkey_hex (empty) + + assert_eq!(borsh::to_vec(&vpm).unwrap(), expected); + assert_eq!( + vpm.signing_digest().unwrap(), + qos_crypto::sha_256(&expected) + ); + } + + #[test] + fn x402_direct_is_borsh_variant_zero() { + // The gateway-side signer writes this discriminant by hand, so the + // variant index is wire contract. Borsh prefixes an enum with a + // 1-byte LE variant index; appending variants must leave this at 0. + let details = PaymentDetails::X402Direct { + txid: String::new(), + payer: String::new(), + pay_to: String::new(), + amount: String::new(), + mint: String::new(), + x_payment_hash: [0u8; 32], + network: String::new(), + }; + assert_eq!(borsh::to_vec(&details).unwrap().first(), Some(&0u8)); + } + + #[test] + fn request_hash_is_stable_and_sensitive_to_every_bound_field() { + let h1 = request_hash(1, "0xdeadbeef", &[], false); + let h2 = request_hash(1, "0xdeadbeef", &[], false); + let h3 = request_hash(2, "0xdeadbeef", &[], false); + let h4 = request_hash(1, "0xdeadbeee", &[], false); + let h5 = request_hash(1, "0xdeadbeef", &[1, 2, 3], false); + let h6 = request_hash(1, "0xdeadbeef", &[], true); + assert_eq!(h1, h2); + assert_ne!(h1, h3, "must be sensitive to chain"); + assert_ne!(h1, h4, "must be sensitive to unsigned_payload"); + assert_ne!(h1, h5, "must be sensitive to chain_metadata bytes"); + assert_ne!(h1, h6, "must be sensitive to include_intermediate_output"); + } + + #[test] + fn request_hash_matches_hand_encoded_preimage() { + // The two differential tests around this one compare `request_hash` + // with itself, so they pin the *presence* of the length prefixes and + // nothing beyond it: reorder the four writes, swap `to_le_bytes` for + // `to_be_bytes`, or drop the `chain` write entirely and both stay + // green while every marker minted by the external gateway signer + // stops verifying. This literal doesn't move when the implementation + // does, so it catches all three. + // + // The expectation is hand-assembled from the documented preimage + // layout rather than captured from `request_hash` itself, the same + // way `signing_digest_matches_hand_encoded_wire_bytes` avoids + // deriving its expectation from the code under test: + // + // 0100_0000 chain = 1, i32 LE + // 0a00_0000 unsigned_payload len = 10, u32 LE + // 30786465616462656566 "0xdeadbeef" + // 0300_0000 chain_metadata len = 3, u32 LE + // 010203 chain_metadata bytes + // 01 include_intermediate_output = true + // + // sha256 of that preimage: + let expected: [u8; 32] = [ + 0xf8, 0xb6, 0x4e, 0x41, 0xac, 0x19, 0xee, 0x85, 0xf7, 0x4d, 0x23, 0x3f, 0x9f, 0xf0, + 0x88, 0xdf, 0x73, 0xc1, 0x47, 0x6d, 0xcd, 0xe3, 0xce, 0xfc, 0x13, 0xd2, 0xb8, 0x5d, + 0x43, 0x4a, 0x5a, 0xc9, + ]; + assert_eq!(request_hash(1, "0xdeadbeef", &[1, 2, 3], true), expected); + } + + #[test] + fn request_hash_length_prefixes_defeat_field_boundary_collisions() { + // Without the length prefixes, the preimage is a bare concatenation + // of unsigned_payload and chain_metadata, so shifting a byte across + // the boundary yields identical bytes: "AB" + [] == "A" + [b'B']. + // A marker paid for one request would then verify against the other. + // These two calls MUST differ, or the binding is forgeable. + let shifted_right = request_hash(1, "AB", &[], false); + let shifted_left = request_hash(1, "A", b"B", false); + assert_ne!( + shifted_right, shifted_left, + "unsigned_payload/chain_metadata boundary must be unambiguous" + ); + } + + #[test] + fn signed_marker_pins_field_order_and_signature_length_prefix() { + // `SignedVerifiedPaymentMarker` (vpm, then signature) is the struct + // that actually rides in `ParseRequest.payment_marker`, not `vpm` + // alone. This pins its wire layout independently of + // `VerifiedPaymentMarker`'s own pinned-bytes test above: a field + // swap (signature before vpm), or a signer that writes + // `vpm_bytes || sig[64]` with no length prefix on `signature`, + // passes every existing `sign_with`/`verify` round-trip test but + // produces bytes the enclave cannot deserialize as intended. + let vpm = VerifiedPaymentMarker { + version: 1, + request_hash: [0u8; 32], + details: PaymentDetails::X402Direct { + txid: "tx".into(), + payer: String::new(), + pay_to: String::new(), + amount: "0".into(), + mint: String::new(), + x_payment_hash: [0u8; 32], + network: String::new(), + }, + settled_at_ms: 0, + gateway_pubkey_hex: String::new(), + }; + + // Hand-assembled from the documented Borsh layout (LE integers, a + // 1-byte enum variant index, 4-byte-length-prefixed + // strings/Vec, raw fixed-size arrays), independent of + // `borsh::to_vec` on either `vpm` or `signed`. + let mut expected_vpm_bytes = Vec::new(); + expected_vpm_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + expected_vpm_bytes.extend_from_slice(&[0u8; 32]); // request_hash + expected_vpm_bytes.push(0); // PaymentDetails variant index: X402Direct + for s in ["tx", "", "", "0", ""] { + // txid, payer, pay_to, amount, mint: length-prefixed strings + expected_vpm_bytes.extend_from_slice(&(s.len() as u32).to_le_bytes()); + expected_vpm_bytes.extend_from_slice(s.as_bytes()); + } + expected_vpm_bytes.extend_from_slice(&[0u8; 32]); // x_payment_hash + expected_vpm_bytes.extend_from_slice(&0u32.to_le_bytes()); // network (empty) + expected_vpm_bytes.extend_from_slice(&0u64.to_le_bytes()); // settled_at_ms + expected_vpm_bytes.extend_from_slice(&0u32.to_le_bytes()); // gateway_pubkey_hex (empty) + + let signature = vec![0xABu8; 64]; + let mut expected = expected_vpm_bytes; + expected.extend_from_slice(&(signature.len() as u32).to_le_bytes()); + expected.extend_from_slice(&signature); + + let signed = SignedVerifiedPaymentMarker { vpm, signature }; + + assert_eq!(borsh::to_vec(&signed).unwrap(), expected); + } +} diff --git a/src/integration/tests/parser.rs b/src/integration/tests/parser.rs index cdcdbf5bd..98b7644f7 100644 --- a/src/integration/tests/parser.rs +++ b/src/integration/tests/parser.rs @@ -96,6 +96,7 @@ async fn parser_e2e() { unsigned_payload: "unsignedpayload".to_string(), chain: Chain::Unspecified as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -137,6 +138,7 @@ async fn propagates_grpc_errors() { unsigned_payload: "no-no-that-is-not-valid-base64".to_string(), chain: Chain::Ethereum as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_error = test_args @@ -213,6 +215,7 @@ async fn parser_solana_native_transfer_e2e() { unsigned_payload: solana_tx, chain: Chain::Solana as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -406,6 +409,7 @@ async fn parser_ethereum_native_transfer_e2e() { unsigned_payload: ethereum_tx_hex.to_string(), chain: Chain::Ethereum as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -516,6 +520,7 @@ async fn parser_charset_validation_all_chains() { unsigned_payload: transaction.to_string(), chain: chain as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -574,6 +579,7 @@ async fn parser_sui_native_transfer_e2e() { unsigned_payload: sui_tx_b64.to_string(), chain: Chain::Sui as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -764,6 +770,7 @@ async fn parser_near_native_transfer_e2e() { unsigned_payload: near_transfer_hex.to_string(), chain: Chain::Near as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -838,6 +845,7 @@ async fn parser_near_metadata_selects_the_network_e2e() { unsigned_payload: near_testnet_transfer_hex.to_string(), chain: Chain::Near as i32, chain_metadata: near_chain_metadata("NEAR_TESTNET"), + payment_marker: vec![], }; let parse_response = test_args @@ -880,6 +888,7 @@ async fn parser_near_metadata_network_reaches_the_account_suffix_check_e2e() { unsigned_payload: near_mainnet_transfer_hex.to_string(), chain: Chain::Near as i32, chain_metadata: near_chain_metadata("NEAR_TESTNET"), + payment_marker: vec![], }; let parse_error = test_args @@ -914,6 +923,7 @@ async fn parser_near_intent_envelope_e2e() { unsigned_payload: intent_json.to_string(), chain: Chain::Near as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_response = test_args @@ -996,6 +1006,7 @@ async fn parser_near_rejects_input_that_is_neither_transaction_nor_intent() { unsigned_payload: "not-hex-not-base64-not-json".to_string(), chain: Chain::Near as i32, chain_metadata: None, + payment_marker: vec![], }; let parse_error = test_args diff --git a/src/parser/app/Cargo.toml b/src/parser/app/Cargo.toml index 8544ef359..9dada2cde 100644 --- a/src/parser/app/Cargo.toml +++ b/src/parser/app/Cargo.toml @@ -32,6 +32,9 @@ bincode = "1.3.3" bs58 = { version = "0.5.1", default-features = false } sha2 = { version = "0.10.8", default-features = false } +# VPM verification +thiserror = "2.0.12" + [features] # diagnostics is intentionally NOT in `default`: parser_app is the production # binary, and `cargo build --workspace --exclude parser_cli` (see Makefile) diff --git a/src/parser/app/src/lib.rs b/src/parser/app/src/lib.rs index e762304f0..ef2c2c009 100644 --- a/src/parser/app/src/lib.rs +++ b/src/parser/app/src/lib.rs @@ -21,6 +21,9 @@ pub mod chain_conversion; pub mod registry; +/// VerifiedPaymentMarker verification +pub mod payment_verify; + /// Routes for the parser service pub mod routes { /// Parse route diff --git a/src/parser/app/src/payment_verify.rs b/src/parser/app/src/payment_verify.rs new file mode 100644 index 000000000..f2a6227cc --- /dev/null +++ b/src/parser/app/src/payment_verify.rs @@ -0,0 +1,712 @@ +//! VerifiedPaymentMarker verification inside parser_app. +//! +//! Only checks the gateway's signature + binds the marker to this specific +//! request. The deeper buyer-Ed25519-on-the-Solana-tx check is deferred to +//! v3.1 (see plan). +//! +//! Policy is meant to be built once at startup from a hex-encoded pinned +//! gateway pubkey via `PaymentPolicy::from_hex`, with the binary deciding +//! where that hex value comes from (CLI arg / env). No binary does that +//! yet: every call site currently passes `Disabled`, so this module is the +//! enforcement point sitting in place, switched off. Local dev / gRPC-direct +//! callers pass `PaymentPolicy::Disabled` and VPM is not required. When +//! `Required`, the policy refuses any request whose `payment_marker` doesn't +//! carry a valid gateway-signed VPM bound to the exact request body. + +use borsh::BorshDeserialize; +use generated::google::rpc::Code; +use generated::parser::{ChainMetadata, ParseRequest}; +use host_primitives::payment_marker::{SignedVerifiedPaymentMarker, VPM_VERSION, request_hash}; +use qos_p256::sign::P256SignPublic; +use visualsign::encodings::decode_hex; + +use crate::errors::GrpcError; + +/// Borsh-encodes `chain_metadata`, or returns an empty `Vec` if absent. +/// Matches (and is reused by) the convention used for +/// `ParsedTransactionPayload::metadata_digest` in `routes::parse`. +pub(crate) fn chain_metadata_bytes( + metadata: Option<&ChainMetadata>, +) -> Result, borsh::io::Error> { + metadata + .map(borsh::to_vec) + .transpose() + .map(Option::unwrap_or_default) +} + +/// Whether `parser_app` requires (and verifies) a `VerifiedPaymentMarker` +/// on every parse call. Built by the binary from its own config via +/// `PaymentPolicy::from_hex`; there is no env-coupled constructor here (see +/// the module doc above). +pub enum PaymentPolicy { + /// No payment enforcement. Used by local-dev / direct-gRPC callers, and + /// by every call site today (no binary constructs `Required` yet). + Disabled, + /// Require a valid gateway-signed VPM in `ParseRequest.payment_marker`. + Required { + /// The gateway's P256 signing public key, pinned at TVC deploy + /// time via `GATEWAY_SIGNING_PUBKEY_HEX`. + pinned: P256SignPublic, + }, +} + +impl std::fmt::Debug for PaymentPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => write!(f, "PaymentPolicy::Disabled"), + Self::Required { pinned } => { + // Encoded on demand rather than memoized: this is log/Debug + // output only, not the hot verify() path, and deriving it + // from `pinned` (not the raw config string) keeps it the + // canonical unprefixed lower-case encoding regardless of + // whether the operator supplied a `0x`-prefixed value in + // `GATEWAY_SIGNING_PUBKEY_HEX`. The cross-check against + // `vpm.gateway_pubkey_hex` compares decoded bytes, not this + // string. + f.debug_struct("PaymentPolicy::Required") + .field("pinned_hex", &qos_hex::encode(&pinned.to_bytes())) + .finish() + } + } + } +} + +impl PaymentPolicy { + /// Build a `Required` policy from a hex-encoded P256 sign pubkey + /// (`P256SignPublic::to_bytes` SEC1 uncompressed). Binaries read their + /// own config (CLI args / env) and call this directly; there is no + /// env-coupled constructor here. + pub fn from_hex(hex_value: &str) -> Result { + let trimmed = hex_value.trim(); + let bytes = decode_hex(trimmed).map_err(|e| { + GrpcError::internal(&format!("GATEWAY_SIGNING_PUBKEY_HEX hex decode: {e:?}")) + })?; + let pinned = P256SignPublic::from_bytes(&bytes).map_err(|e| { + GrpcError::internal(&format!( + "GATEWAY_SIGNING_PUBKEY_HEX is not a valid P256 sign pubkey: {e:?}" + )) + })?; + Ok(Self::Required { pinned }) + } +} + +/// Reasons a request can be rejected for missing or invalid payment proof. +#[derive(Debug, thiserror::Error)] +pub enum PaymentVerifyError { + /// `payment_marker` was empty in `Required` mode. + #[error("payment marker is required for this endpoint")] + Missing, + /// The marker bytes weren't valid Borsh, or a decoded field didn't match + /// the schema (e.g. `gateway_pubkey_hex` that isn't valid hex). + #[error("payment marker decode error: {0}")] + Decode(String), + /// The marker was signed against an unknown VPM schema version. + #[error("payment marker version {0} is not supported")] + UnsupportedVersion(u32), + /// The marker's `request_hash` doesn't match this request (see + /// `host_primitives::payment_marker::request_hash` for the exact + /// fields covered). + #[error("payment marker does not match this request (request_hash mismatch)")] + RequestHashMismatch, + /// The marker claimed a different gateway pubkey than the pinned one. + #[error("payment marker gateway_pubkey_hex does not match pinned key")] + PinnedKeyMismatch, + /// The gateway signature on the marker didn't verify. + #[error("payment marker signature verification failed")] + BadSignature, + /// A failure unrelated to payment status (e.g. Borsh-encoding this + /// request's own `chain_metadata` to compute `request_hash`). Kept + /// distinct from the payment-conditional variants above so a caller + /// that matches on `PaymentVerifyError` for logging/metrics doesn't see + /// a deployment bug reported as a payment failure. + #[error("internal error verifying payment marker: {0}")] + Internal(String), +} + +impl From for GrpcError { + fn from(e: PaymentVerifyError) -> Self { + // `FailedPrecondition` is meant to be translated to HTTP 402 by the + // gateway. We keep parser_app HTTP-unaware; the gateway is + // responsible for mapping gRPC status codes to HTTP and + // synthesizing the canonical x402 PaymentRequired body from its + // own config (not yet wired as of this policy being `Disabled` + // everywhere). + // + // Only the payment-conditional variants get that treatment. Corrupt + // marker bytes (`Decode`) and schema skew (`UnsupportedVersion`) are + // caller or deployment bugs, not "you have not paid": under x402 + // retry semantics a 402 tells the caller to pay again for a request + // that cannot succeed however many times it retries. Those map to + // `InvalidArgument` so the gateway surfaces a 400 instead. + let code = match &e { + PaymentVerifyError::Decode(_) | PaymentVerifyError::UnsupportedVersion(_) => { + Code::InvalidArgument + } + PaymentVerifyError::Missing + | PaymentVerifyError::RequestHashMismatch + | PaymentVerifyError::PinnedKeyMismatch + | PaymentVerifyError::BadSignature => Code::FailedPrecondition, + PaymentVerifyError::Internal(_) => Code::Internal, + }; + GrpcError::new(code, &format!("{e}")) + } +} + +/// Upper bound on `payment_marker` bytes accepted before Borsh decoding. +/// +/// A real marker (fixed 32-byte arrays, a handful of short base58/hex +/// strings, a 64-byte signature) serializes to well under 1 KiB. The gRPC +/// server otherwise only caps `payment_marker` at the whole-request size +/// (`GRPC_MAX_RECV_MSG_SIZE`, 25 MiB), so without this an attacker who +/// cannot forge a signature could still make every rejected request pay +/// for decoding, and later re-serializing, up to 25 MiB of unauthenticated +/// bytes before the signature check ever runs. 8 KiB leaves headroom for +/// added fields while cutting that cost by three orders of magnitude. +const MAX_PAYMENT_MARKER_BYTES: usize = 8 * 1024; + +/// Returns `Ok(())` if the policy allows the request to proceed. +pub fn verify( + parse_request: &ParseRequest, + policy: &PaymentPolicy, +) -> Result<(), PaymentVerifyError> { + let pinned = match policy { + PaymentPolicy::Disabled => return Ok(()), + PaymentPolicy::Required { pinned } => pinned, + }; + + if parse_request.payment_marker.is_empty() { + return Err(PaymentVerifyError::Missing); + } + + if parse_request.payment_marker.len() > MAX_PAYMENT_MARKER_BYTES { + return Err(PaymentVerifyError::Decode(format!( + "payment marker is {} bytes, exceeds {MAX_PAYMENT_MARKER_BYTES} byte limit", + parse_request.payment_marker.len() + ))); + } + + let signed = SignedVerifiedPaymentMarker::try_from_slice(&parse_request.payment_marker) + .map_err(|e| PaymentVerifyError::Decode(format!("{e}")))?; + + let vpm = &signed.vpm; + + if vpm.version != VPM_VERSION { + return Err(PaymentVerifyError::UnsupportedVersion(vpm.version)); + } + + // Exhaustive on purpose, mirroring the `ParseRequest` destructure below: + // `PaymentDetails` has one variant today, but a new scheme is a new + // variant (see host_primitives::payment_marker doc), and this has no + // wildcard arm so adding one is a compile error here, forcing an + // explicit accept/reject decision for the new scheme rather than + // silently accepting it. This is separate from cross-checking a + // variant's settlement fields against forwarded X-PAYMENT bytes, which + // is deferred to v3.1. + match &vpm.details { + host_primitives::payment_marker::PaymentDetails::X402Direct { .. } => {} + } + + // Bind the VPM to this exact request: chain, unsigned_payload, + // chain_metadata, and include_intermediate_output all feed the + // enclave's own attested output, so all four must be covered. + // + // Destructured exhaustively on purpose. `ParseRequest` is a plain + // generated struct with no `#[non_exhaustive]`, so a new proto field + // breaks this line and forces a decision: hash it below, or bind it to + // `_` here with a note saying why it is out of scope. Field 4 + // (`include_intermediate_output`) was already missed once while this + // work sat on an unmerged branch. Note this only guards the verifier: + // whoever fixes the compile error also has to extend the gateway-side + // signer and its pinned-preimage test, or the two sides silently + // disagree. + let ParseRequest { + unsigned_payload, + chain, + chain_metadata, + include_intermediate_output, + // Deliberately not hashed: it carries the marker itself, and a hash + // the marker commits to cannot also cover the marker. + payment_marker: _, + } = parse_request; + + let chain_metadata_bytes = chain_metadata_bytes(chain_metadata.as_ref()) + .map_err(|e| PaymentVerifyError::Internal(format!("chain_metadata borsh encode: {e:?}")))?; + let expected = request_hash( + *chain, + unsigned_payload, + &chain_metadata_bytes, + *include_intermediate_output, + ); + if expected != vpm.request_hash { + return Err(PaymentVerifyError::RequestHashMismatch); + } + + // Cross-check the gateway pubkey claimed in the VPM against the pinned + // key. Compare decoded bytes rather than the hex strings: `decode_hex` + // accepts an optional `0x` prefix, exactly as `PaymentPolicy::from_hex` + // does for the operator-supplied value, so a signer that reads the + // `gateway_pubkey_hex` field doc literally and writes a prefixed key + // still matches instead of failing every request with a valid signature + // underneath. Both values are public keys, not secrets, so a plain + // compare is fine; the actual trust decision is the signature check + // below. + // A `gateway_pubkey_hex` that isn't even valid hex is a malformed marker, + // not an unpaid request, so it takes the `Decode` path (InvalidArgument) + // rather than `PinnedKeyMismatch` (FailedPrecondition -> 402). Telling a + // caller to pay again cannot fix a marker whose key field is corrupt. + // + // The decode error is deliberately not included: `hex::FromHexError`'s + // `Display` embeds the offending character verbatim, and this field is + // unauthenticated and attacker-controlled at this point (the signature + // has not been checked yet), so echoing it into a client-visible gRPC + // message would put attacker bytes in our error strings and logs. + let claimed = decode_hex(vpm.gateway_pubkey_hex.trim()) + .map_err(|_| PaymentVerifyError::Decode("gateway_pubkey_hex is not valid hex".into()))?; + // Valid hex of the wrong length (e.g. a truncated or padded key) falls + // through to `PinnedKeyMismatch` (FailedPrecondition -> 402) rather than + // `Decode` (InvalidArgument -> 400) above, even though both are + // malformed-marker cases. Left as-is: this compares decoded bytes + // against the pinned key regardless of length, so a length mismatch is + // just a specific case of "does not match the pinned key". Revisit if + // deploy-time key skew (a rotated gateway key with a stale pin) turns + // out to be common enough that InvalidArgument's clearer "this can + // never succeed" signal is worth splitting out here too. + if claimed[..] != pinned.to_bytes()[..] { + return Err(PaymentVerifyError::PinnedKeyMismatch); + } + + let digest = vpm.signing_digest().map_err(|e| { + PaymentVerifyError::Internal(format!("payment marker signing_digest: {e:?}")) + })?; + pinned + .verify(&digest, &signed.signature) + .map_err(|_| PaymentVerifyError::BadSignature)?; + + Ok(()) +} + +/// Shared VPM test-construction helpers. `pub(crate)` (rather than nested +/// inside `mod tests` below) so `routes::parse`'s tests can build the same +/// well-formed markers instead of re-deriving `VerifiedPaymentMarker` +/// construction from scratch. +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +pub(crate) mod test_support { + use super::{PaymentPolicy, chain_metadata_bytes}; + use generated::parser::ParseRequest; + use host_primitives::payment_marker::{ + PaymentDetails, SignedVerifiedPaymentMarker, VPM_VERSION, VerifiedPaymentMarker, + request_hash, + }; + use qos_p256::sign::P256SignPair; + + pub(crate) fn sign_with(pair: &P256SignPair, vpm: VerifiedPaymentMarker) -> Vec { + let signed = SignedVerifiedPaymentMarker { + signature: pair.sign(&vpm.signing_digest().unwrap()).unwrap(), + vpm, + }; + borsh::to_vec(&signed).unwrap() + } + + pub(crate) fn make_vpm(req: &ParseRequest, gateway_hex: &str) -> VerifiedPaymentMarker { + let chain_metadata_bytes = chain_metadata_bytes(req.chain_metadata.as_ref()).unwrap(); + VerifiedPaymentMarker { + version: VPM_VERSION, + request_hash: request_hash( + req.chain, + &req.unsigned_payload, + &chain_metadata_bytes, + req.include_intermediate_output, + ), + details: PaymentDetails::X402Direct { + txid: "txsig".into(), + payer: "Pay".into(), + pay_to: "Recv".into(), + amount: "1000".into(), + mint: "Mint".into(), + x_payment_hash: [0u8; 32], + network: "solana:test".into(), + }, + settled_at_ms: 0, + gateway_pubkey_hex: gateway_hex.to_string(), + } + } + + pub(crate) fn req_with_marker(marker: Vec) -> ParseRequest { + ParseRequest { + unsigned_payload: "0xdeadbeef".into(), + chain: 1, + chain_metadata: None, + include_intermediate_output: false, + payment_marker: marker, + } + } + + /// Generates a fresh gateway keypair and a `Required` policy pinned to it. + pub(crate) fn generate_policy() -> (P256SignPair, String, PaymentPolicy) { + let pair = P256SignPair::generate(); + let pub_hex = qos_hex::encode(&pair.public_key().to_bytes()); + let policy = PaymentPolicy::from_hex(&pub_hex).unwrap(); + (pair, pub_hex, policy) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::test_support::{generate_policy, make_vpm, req_with_marker, sign_with}; + use super::*; + use generated::parser::{ + ChainMetadata, EthereumMetadata, Idl, NearMetadata, SolanaIdlType, SolanaMetadata, + chain_metadata, + }; + use qos_p256::sign::P256SignPair; + + #[test] + fn disabled_policy_accepts_anything() { + let req = req_with_marker(vec![]); + verify(&req, &PaymentPolicy::Disabled).unwrap(); + } + + #[test] + fn required_policy_accepts_valid_marker() { + let (pair, pub_hex, policy) = generate_policy(); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, &pub_hex); + req.payment_marker = sign_with(&pair, vpm); + + verify(&req, &policy).unwrap(); + } + + #[test] + fn required_policy_rejects_missing_marker() { + let (_pair, _pub_hex, policy) = generate_policy(); + let req = req_with_marker(vec![]); + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::FailedPrecondition); + } + + #[test] + fn required_policy_rejects_oversized_marker_before_decoding() { + // A marker larger than MAX_PAYMENT_MARKER_BYTES must be rejected on + // size alone, before any Borsh decode of the attacker-controlled + // bytes is attempted. + let (_pair, _pub_hex, policy) = generate_policy(); + let oversized = vec![0u8; MAX_PAYMENT_MARKER_BYTES + 1]; + let req = req_with_marker(oversized); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::InvalidArgument); + assert!(err.message.contains("exceeds")); + } + + #[test] + fn required_policy_rejects_request_hash_mismatch() { + let (pair, pub_hex, policy) = generate_policy(); + + let req = req_with_marker(vec![]); + let mut vpm = make_vpm(&req, &pub_hex); + vpm.request_hash = [99u8; 32]; // does not match the actual request + let marker = sign_with(&pair, vpm); + let req = req_with_marker(marker); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert!(err.message.contains("request_hash")); + } + + #[test] + fn required_policy_rejects_wrong_gateway_key() { + let (_pair_a, _pub_a, policy) = generate_policy(); + let pair_b = P256SignPair::generate(); + let pub_b = qos_hex::encode(&pair_b.public_key().to_bytes()); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, &pub_b); // claims a different key + req.payment_marker = sign_with(&pair_b, vpm); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert!(err.message.contains("pinned")); + } + + #[test] + fn required_policy_rejects_replay_with_different_chain_metadata() { + // A marker paid+signed for a request with no chain_metadata must not + // verify against a request that is otherwise identical but carries + // different chain_metadata (which changes the enclave's own signed + // output). Regression test for request_hash covering all of + // ParseRequest, not just (chain, unsigned_payload). + let (pair, pub_hex, policy) = generate_policy(); + + let req_no_metadata = req_with_marker(vec![]); + let vpm = make_vpm(&req_no_metadata, &pub_hex); + let marker = sign_with(&pair, vpm); + + let mut req_with_metadata = req_with_marker(marker); + req_with_metadata.chain_metadata = Some(ChainMetadata { + metadata: Some(chain_metadata::Metadata::Ethereum(EthereumMetadata { + network_id: Some("ETHEREUM_MAINNET".to_string()), + abi_mappings: std::collections::BTreeMap::default(), + })), + }); + + let err: GrpcError = verify(&req_with_metadata, &policy).unwrap_err().into(); + assert!(err.message.contains("request_hash")); + } + + #[test] + fn required_policy_rejects_replay_with_different_intermediate_output_flag() { + // Same replay concern as above, but toggling + // include_intermediate_output instead of chain_metadata. + let (pair, pub_hex, policy) = generate_policy(); + + let req_without_flag = req_with_marker(vec![]); + let vpm = make_vpm(&req_without_flag, &pub_hex); + let marker = sign_with(&pair, vpm); + + let mut req_with_flag = req_with_marker(marker); + req_with_flag.include_intermediate_output = true; + + let err: GrpcError = verify(&req_with_flag, &policy).unwrap_err().into(); + assert!(err.message.contains("request_hash")); + } + + #[test] + fn required_policy_rejects_forged_marker_claiming_pinned_key() { + // Attacker claims the pinned gateway key in gateway_pubkey_hex (so + // the pubkey precheck passes) but actually signs with a different + // keypair. This must fail at the signature check, not pass because + // the claimed key happened to match the pinned one. + let (_pinned_pair, pinned_pub_hex, policy) = generate_policy(); + let attacker_pair = P256SignPair::generate(); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, &pinned_pub_hex); // claims the pinned key + req.payment_marker = sign_with(&attacker_pair, vpm); // signed by someone else + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::FailedPrecondition); + assert!(err.message.contains("signature")); + } + + #[test] + fn required_policy_accepts_prefixed_and_uppercase_gateway_pubkey_hex() { + // `gateway_pubkey_hex` is cross-checked by decoded bytes, not by + // string equality, so a signer that writes the key the way + // `GATEWAY_SIGNING_PUBKEY_HEX` accepts it (optional `0x`, either + // case) must still verify. Before this, such a marker failed with + // PinnedKeyMismatch despite a valid signature underneath. + let (pair, pub_hex, policy) = generate_policy(); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, &format!("0x{}", pub_hex.to_uppercase())); + req.payment_marker = sign_with(&pair, vpm); + + verify(&req, &policy).unwrap(); + } + + #[test] + fn required_policy_rejects_undecodable_gateway_pubkey_hex() { + // A corrupt key field is a malformed marker, so it must land on the + // InvalidArgument path with the other decode failures rather than on + // the 402 path that asks the caller to pay again. + let (pair, _pub_hex, policy) = generate_policy(); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, "not-hex"); + req.payment_marker = sign_with(&pair, vpm); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::InvalidArgument); + assert!(err.message.contains("gateway_pubkey_hex")); + } + + #[test] + fn corrupt_marker_bytes_map_to_invalid_argument() { + // Not payment-conditional: a truncated marker is a caller bug, and + // a 402 would tell the caller to pay again for a request that can + // never succeed. + let (_pair, _pub_hex, policy) = generate_policy(); + let req = req_with_marker(vec![0xff; 4]); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::InvalidArgument); + } + + #[test] + fn unsupported_version_maps_to_invalid_argument() { + // Schema skew between gateway and enclave is a deployment bug, not + // a missing payment. + let (pair, pub_hex, policy) = generate_policy(); + + let mut req = req_with_marker(vec![]); + let mut vpm = make_vpm(&req, &pub_hex); + vpm.version = VPM_VERSION + 1; + req.payment_marker = sign_with(&pair, vpm); + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert_eq!(err.code, Code::InvalidArgument); + } + + #[test] + fn required_policy_rejects_tampered_signature() { + let (pair, pub_hex, policy) = generate_policy(); + + let mut req = req_with_marker(vec![]); + let vpm = make_vpm(&req, &pub_hex); + let mut marker = sign_with(&pair, vpm); + // Flip the last byte (inside the signature region, the signature + // is the tail of the borsh-encoded struct). + let last = marker.len() - 1; + marker[last] ^= 0xff; + req.payment_marker = marker; + + let err: GrpcError = verify(&req, &policy).unwrap_err().into(); + assert!(err.message.contains("signature")); + } + + #[test] + fn chain_metadata_bytes_matches_hand_encoded_layout_for_ethereum_variant() { + // `chain_metadata_bytes` (borsh(ChainMetadata)) is one-third of + // `request_hash`'s preimage, alongside `unsigned_payload` and + // `chain`, both of which already have hand-encoded pinned tests in + // `host_primitives::payment_marker`. `prost`'s oneof codegen emits + // struct fields in `.proto` declaration order, not tag order (see + // `EthereumMetadata`: `network_id` is proto tag 2 but is declared, + // and thus Borsh-serialized, before `abi_mappings`, proto tag 3), + // and the enclosing oneof's Borsh variant index (Ethereum=0, + // Solana=1, Near=2, by Rust declaration order) has no relationship + // to the prost tags (1, 2, 3) either. A cosmetic-looking field or + // variant reorder in `parser.proto` is wire-compatible from + // `prost`'s point of view and produces no `make generated` diff + // failure, but silently changes this preimage and breaks every + // marker already minted against the old layout. This test + // hand-assembles the expected bytes from the documented Borsh + // encoding rules (LE integers, 1-byte `Option`/enum discriminants, + // 4-byte-length-prefixed strings, a 4-byte-length-prefixed empty + // `BTreeMap`) plus the current declaration order, rather than + // deriving them from `borsh::to_vec` on the struct under test, so a + // reorder fails this test instead of silently passing it. + let network_id = "ETHEREUM_MAINNET"; + let metadata = ChainMetadata { + metadata: Some(chain_metadata::Metadata::Ethereum(EthereumMetadata { + network_id: Some(network_id.to_string()), + abi_mappings: std::collections::BTreeMap::default(), + })), + }; + + let mut expected = Vec::new(); + expected.push(1); // ChainMetadata.metadata: Option: Some + expected.push(0); // Metadata variant index 0: Ethereum + expected.push(1); // EthereumMetadata.network_id: Option: Some + expected.extend_from_slice(&u32::try_from(network_id.len()).unwrap().to_le_bytes()); + expected.extend_from_slice(network_id.as_bytes()); + expected.extend_from_slice(&0u32.to_le_bytes()); // abi_mappings: empty BTreeMap + + assert_eq!(chain_metadata_bytes(Some(&metadata)).unwrap(), expected); + } + + #[test] + fn chain_metadata_bytes_matches_hand_encoded_layout_for_solana_variant() { + // Companion to the Ethereum test above: pins the Solana variant's + // discriminant (1) plus `SolanaMetadata`'s own declaration order + // (`network_id` tag 2, then `idl` tag 1, then `idl_mappings` tag 3), + // so swapping Solana and Near in the `.proto` oneof, or reordering + // `SolanaMetadata`'s fields, fails this test instead of silently + // changing the `request_hash` preimage. + fn encode_idl(idl: &Idl, buf: &mut Vec) { + buf.extend_from_slice(&u32::try_from(idl.value.len()).unwrap().to_le_bytes()); + buf.extend_from_slice(idl.value.as_bytes()); + match idl.idl_type { + Some(t) => { + buf.push(1); + buf.extend_from_slice(&t.to_le_bytes()); + } + None => buf.push(0), + } + match &idl.idl_version { + Some(v) => { + buf.push(1); + buf.extend_from_slice(&u32::try_from(v.len()).unwrap().to_le_bytes()); + buf.extend_from_slice(v.as_bytes()); + } + None => buf.push(0), + } + buf.push(u8::from(idl.signature.is_some())); // None here + match &idl.program_name { + Some(n) => { + buf.push(1); + buf.extend_from_slice(&u32::try_from(n.len()).unwrap().to_le_bytes()); + buf.extend_from_slice(n.as_bytes()); + } + None => buf.push(0), + } + } + + let network_id = "SOLANA_MAINNET"; + let idl = Idl { + value: "{}".to_string(), + idl_type: Some(SolanaIdlType::Anchor as i32), + idl_version: Some("0.30.0".to_string()), + signature: None, + program_name: Some("JupiterLend".to_string()), + }; + let mut idl_mappings = std::collections::BTreeMap::new(); + idl_mappings.insert( + "Prog11111111111111111111111111111111111111".to_string(), + idl.clone(), + ); + + let metadata = ChainMetadata { + metadata: Some(chain_metadata::Metadata::Solana(SolanaMetadata { + network_id: Some(network_id.to_string()), + idl: Some(idl.clone()), + idl_mappings, + })), + }; + + let mut expected = Vec::new(); + expected.push(1); // ChainMetadata.metadata: Option: Some + expected.push(1); // Metadata variant index 1: Solana + expected.push(1); // SolanaMetadata.network_id: Option: Some + expected.extend_from_slice(&u32::try_from(network_id.len()).unwrap().to_le_bytes()); + expected.extend_from_slice(network_id.as_bytes()); + expected.push(1); // SolanaMetadata.idl: Option: Some + encode_idl(&idl, &mut expected); + expected.extend_from_slice(&1u32.to_le_bytes()); // idl_mappings: 1 entry + let key = "Prog11111111111111111111111111111111111111"; + expected.extend_from_slice(&u32::try_from(key.len()).unwrap().to_le_bytes()); + expected.extend_from_slice(key.as_bytes()); + encode_idl(&idl, &mut expected); + + assert_eq!(chain_metadata_bytes(Some(&metadata)).unwrap(), expected); + } + + #[test] + fn chain_metadata_bytes_matches_hand_encoded_layout_for_near_variant() { + // Companion to the Ethereum/Solana tests above: pins the Near + // variant's discriminant (2) and `NearMetadata`'s single field, so + // a variant reorder that leaves Ethereum at index 0 but swaps + // Solana/Near still fails somewhere in this trio. + let network_id = "NEAR_MAINNET"; + let metadata = ChainMetadata { + metadata: Some(chain_metadata::Metadata::Near(NearMetadata { + network_id: Some(network_id.to_string()), + })), + }; + + let mut expected = Vec::new(); + expected.push(1); // ChainMetadata.metadata: Option: Some + expected.push(2); // Metadata variant index 2: Near + expected.push(1); // NearMetadata.network_id: Option: Some + expected.extend_from_slice(&u32::try_from(network_id.len()).unwrap().to_le_bytes()); + expected.extend_from_slice(network_id.as_bytes()); + + assert_eq!(chain_metadata_bytes(Some(&metadata)).unwrap(), expected); + } + + #[test] + fn chain_metadata_bytes_is_empty_for_absent_metadata() { + assert_eq!(chain_metadata_bytes(None).unwrap(), Vec::::new()); + } +} diff --git a/src/parser/app/src/routes/parse.rs b/src/parser/app/src/routes/parse.rs index f2023c0a1..bc28eae04 100644 --- a/src/parser/app/src/routes/parse.rs +++ b/src/parser/app/src/routes/parse.rs @@ -1,6 +1,9 @@ //! Parsing endpoint for `VisualSign` -use crate::{chain_conversion, errors::GrpcError, registry::create_registry}; +use crate::{ + chain_conversion, errors::GrpcError, payment_verify, payment_verify::PaymentPolicy, + registry::create_registry, +}; use generated::parser::Chain as ProtoChain; use generated::{ google::rpc::Code, @@ -25,7 +28,9 @@ use visualsign::vsptrait::VisualSignOptions; pub fn parse( parse_request: &ParseRequest, ephemeral_key: &P256Pair, + policy: &PaymentPolicy, ) -> Result { + payment_verify::verify(parse_request, policy)?; let registry = create_registry(); parse_with_registry(parse_request, ephemeral_key, ®istry) } @@ -89,11 +94,15 @@ pub(crate) fn parse_with_registry( // Metadata can be empty; if so, we use an empty vec for hashing to avoid having to deal with // optional types in ParsedTransactionPayload. - let metadata_bytes = if let Some(metadata) = parse_request.chain_metadata.as_ref() { - borsh::to_vec(&metadata).expect("chain_metadata implements borsh::Serialize") - } else { - vec![] - }; + let metadata_bytes = payment_verify::chain_metadata_bytes( + parse_request.chain_metadata.as_ref(), + ) + .map_err(|e| { + GrpcError::new( + Code::Internal, + &format!("chain_metadata borsh encode: {e:?}"), + ) + })?; let payload = ParsedTransactionPayload { parsed_payload: parsed_payload_str.clone(), @@ -156,7 +165,9 @@ fn signing_digest_bytes(payload: &ParsedTransactionPayload) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::payment_verify::test_support::{make_vpm, sign_with}; use generated::parser::{Abi, ChainMetadata, EthereumMetadata, chain_metadata}; + use qos_p256::sign::P256SignPair; use std::collections::BTreeMap; use visualsign::vsptrait::{ ConversionResult, Transaction, TransactionParseError, VisualSignConverter, @@ -362,6 +373,7 @@ mod tests { unsigned_payload: "stub".to_string(), chain: ProtoChain::Tron as i32, chain_metadata: None, + payment_marker: vec![], } } @@ -445,6 +457,43 @@ mod tests { assert_eq!(err.code, Code::InvalidArgument); } + /// Regression: every other test in this module exercises `parse_with_registry` + /// directly, which never calls `payment_verify::verify`. This test drives the + /// public `parse()` entry point (the one production callers actually use) with + /// `PaymentPolicy::Required`, so a regression that deletes, reorders, or + /// silently ignores the verification call inside `parse()` would fail this + /// test even though every `parse_with_registry` test still passes. The marker + /// also claims the pinned gateway key but is signed by a different keypair, + /// so this doubles as a true-forgery test at the `parse()` boundary (as + /// opposed to `payment_verify`'s own unit tests, which cover the same case + /// one layer down). + #[test] + fn parse_rejects_forged_payment_marker_via_public_entry_point() { + let pinned_pair = P256SignPair::generate(); + let attacker_pair = P256SignPair::generate(); + let pinned_pub_hex = qos_hex::encode(&pinned_pair.public_key().to_bytes()); + let policy = PaymentPolicy::from_hex(&pinned_pub_hex).unwrap(); + + let mut req = stub_request(); + let vpm = make_vpm(&req, &pinned_pub_hex); // claims the pinned key + req.payment_marker = sign_with(&attacker_pair, vpm); // signed by someone else + + let ephemeral_key = P256Pair::generate().expect("generate ephemeral key"); + let err = parse(&req, &ephemeral_key, &policy).expect_err( + "parse() must reject a payment marker forged by a different signer, even though \ + the marker's claimed gateway_pubkey_hex matches the pinned key, before ever \ + reaching the transaction converter registry", + ); + assert_eq!(err.code, Code::FailedPrecondition); + // The status code alone doesn't distinguish this forgery failure + // from `Missing`/`RequestHashMismatch`/`PinnedKeyMismatch`, all of + // which also map to `FailedPrecondition`. Assert on the message too + // so this test can't silently start passing for the wrong reason + // (e.g. if `test_support::make_vpm`'s preimage ever drifted from + // `verify()`'s). + assert!(err.message.contains("signature")); + } + fn sample_payload(intermediate_output: Vec) -> ParsedTransactionPayload { ParsedTransactionPayload { parsed_payload: "parsed".to_string(), diff --git a/src/parser/app/src/service.rs b/src/parser/app/src/service.rs index d2fb5248e..82dbc3701 100644 --- a/src/parser/app/src/service.rs +++ b/src/parser/app/src/service.rs @@ -67,15 +67,19 @@ impl Processor { let output = match input { qos_parser_request::Input::ParseRequest(parse_request) => { - match crate::routes::parse::parse(parse_request, &ephemeral_key) - .map(qos_parser_response::Output::ParseResponse) - .map_err(|e| { - qos_parser_response::Output::Status(Status { - code: e.code as i32, - message: e.message, - details: vec![], - }) - }) { + match crate::routes::parse::parse( + parse_request, + &ephemeral_key, + &crate::payment_verify::PaymentPolicy::Disabled, + ) + .map(qos_parser_response::Output::ParseResponse) + .map_err(|e| { + qos_parser_response::Output::Status(Status { + code: e.code as i32, + message: e.message, + details: vec![], + }) + }) { Ok(o) | Err(o) => o, } } diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index 8dfd6718a..f09a03c08 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -131,6 +131,10 @@ async fn parse_handler( chain, chain_metadata: wrapper.request.chain_metadata.map(ChainMetadata::from), include_intermediate_output: wrapper.request.include_intermediate_output, + // This local-dev/CI gateway never wraps a real enclave and doesn't + // forward an x402 payment marker; the production x402 gateway is + // out of this repo. + payment_marker: vec![], }); let response = match tokio::time::timeout(PARSE_TIMEOUT, grpc_client.parse(request)).await { diff --git a/src/parser/grpc-server/src/main.rs b/src/parser/grpc-server/src/main.rs index ee0e6c93f..10dcc8c0f 100644 --- a/src/parser/grpc-server/src/main.rs +++ b/src/parser/grpc-server/src/main.rs @@ -15,6 +15,7 @@ use generated::parser::{ parser_service_server::{ParserService, ParserServiceServer}, }; use generated::tonic::{self, Request, Response, Status}; +use parser_app::payment_verify::PaymentPolicy; use parser_app::routes::parse::parse; use qos_core::handles::EphemeralKeyHandle; use qos_p256::P256Pair; @@ -45,9 +46,13 @@ impl ParserService for GrpcService { request: Request, ) -> Result, Status> { // Direct function call - no sockets needed - parse(&request.into_inner(), &self.ephemeral_key) - .map(Response::new) - .map_err(|e| Status::new(tonic::Code::from(e.code as i32), e.message)) + parse( + &request.into_inner(), + &self.ephemeral_key, + &PaymentPolicy::Disabled, + ) + .map(Response::new) + .map_err(|e| Status::new(tonic::Code::from(e.code as i32), e.message)) } }