Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d19c79e
docs(rust): start audit assessment, decide on finding #38
lgalabru May 26, 2026
240b77d
fix(rust/mpp): harden find_sol_transfer (audit #32)
lgalabru May 26, 2026
64204b2
fix(rust/mpp): harden find_spl_transfer (audit #29)
lgalabru May 26, 2026
588f52c
fix(rust/mpp): resolve token program at boot, not by guess (audit #28)
lgalabru May 26, 2026
4bc975d
fix(rust/mpp): gate unknown Token-2022 mints in client (audit #26)
lgalabru May 26, 2026
3ef88d8
fix(rust/mpp): tighten priority-fee cap in fee-sponsored mode (audit …
lgalabru May 26, 2026
86fbadc
fix(rust/mpp): require >=32-byte HMAC secret key (audit #24)
lgalabru May 26, 2026
c50cf41
fix(rust/mpp): only create split ATA when flagged (audit #20)
lgalabru May 26, 2026
4386cb7
fix(rust/mpp): validate ChargeRequest before HMAC (audit #19)
lgalabru May 26, 2026
8e3de93
fix(rust/mpp): reject primary recipient in ATA-create split (audit #38)
lgalabru May 27, 2026
0538abe
fix(rust/mpp): client policy gates on charge build (audit #10)
lgalabru May 27, 2026
c255735
fix(rust/mpp): recover from confirmation-poll timeout (audit #3)
lgalabru May 27, 2026
5501aaa
refactor(rust/mpp)!: delete verify_credential, force explicit expecte…
lgalabru May 27, 2026
98d3ea0
fix(rust/mpp): exhaustive expected-charge comparison (audit #1)
lgalabru May 27, 2026
ce5888f
fix(rust/mpp): bound decimals and use checked arithmetic in parse_uni…
lgalabru May 27, 2026
1f1b62e
fix(rust/mpp): checked split-amount sum + centralize MAX_SPLITS (audi…
lgalabru May 27, 2026
8d28027
fix(rust/mpp): checked divisor in diagnose_balances (audit #8)
lgalabru May 27, 2026
8dabd34
fix(rust/mpp): use resolved tokenProgram in diagnose_balances (audit …
lgalabru May 27, 2026
56caf55
fix(rust/mpp): cap challenge request parameter at MAX_TOKEN_LEN (audi…
lgalabru May 27, 2026
9c80f79
fix(rust/mpp): require decimals on SPL charges, skip diagnostic when …
lgalabru May 27, 2026
fadd606
fix(rust/mpp): require fee_payer_signer when fee_payer=true (audit #16)
lgalabru May 27, 2026
90f5d57
fix(rust/mpp): derive default realm from recipient (audit #15)
lgalabru May 27, 2026
20525ed
fix(rust/mpp): network allowlist + mainnet canonicalization (audit #37)
lgalabru May 27, 2026
492cd13
fix(rust/mpp): full split validation at challenge issuance (audit #21)
lgalabru May 27, 2026
ccf04c9
docs(rust): mark audit #33 rejected — SOL transfer path not in produc…
lgalabru May 28, 2026
8e9c881
fix(rust/mpp): bind verify's request to the credential (audit #22)
lgalabru May 28, 2026
b1525b7
fix(rust/mpp): client method/intent gate on credential builder (audit…
lgalabru May 28, 2026
0c020e2
fix(rust/mpp): make push-mode acceptance opt-in (audit #5)
lgalabru May 28, 2026
d52e610
fix(rust/mpp): input-strictness pass (audit #44, #45, #27, #14, #34)
lgalabru May 28, 2026
cb80702
fix(rust/mpp): integration tests now populate expected methodDetails …
lgalabru May 29, 2026
115165b
fix(interop): pad MPP_INTEROP_SECRET_KEY to ≥32 bytes (audit #24 fall…
lgalabru May 29, 2026
a68bc35
style(rust/mpp): apply cargo fmt --all (CI green)
lgalabru May 29, 2026
d89f39a
fix(rust/mpp): refuse to boot interop server on invalid splits (audit…
lgalabru May 29, 2026
9f7c3f1
wip
lgalabru May 29, 2026
49381e4
Merge remote-tracking branch 'origin/main' into fix/rust-audit
lgalabru Jun 15, 2026
3570a3f
fix(rust/x402): parse spec-nested sign-in-with-x challenge
lgalabru Jun 15, 2026
f3aa971
chore(rust/mpp): rustfmt merge result + drop duplicate clippy allow
lgalabru Jun 15, 2026
6106823
chore(deps): bump ws override to ^8.21.0 to clear GHSA-96hv-2xvq-fx4p
lgalabru Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions harness/src/intents/charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ export const chargeScenarios: readonly HarnessScenario[] = [
// can drive a 9-split request through the env-only path, so this
// is typescript-client only. Splits are intentionally tiny so the
// sum stays well under amount.
//
// Rust audit #21 promoted this from a runtime-reject to a
// refuse-to-boot at server startup. The harness has no notion of
// "expected startup failure" yet, so rust is excluded from this
// scenario via serverIds — re-include it when the harness gains a
// way to assert on adapter-exit-before-readiness.
id: "charge-splits-too-many",
intent: "charge",
network: "localnet",
Expand All @@ -333,6 +339,7 @@ export const chargeScenarios: readonly HarnessScenario[] = [
resourcePath: "/protected/splits-too-many",
settlementHeader: "x-fixture-settlement",
clientIds: ["typescript"],
serverIds: ["typescript", "php", "ruby", "go", "python", "lua"],
splits: [
{ recipientKey: "platform", amount: "1" },
{ recipientKey: "platform", amount: "1" },
Expand Down
8 changes: 6 additions & 2 deletions harness/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,10 @@ beforeAll(async () => {
MPP_HARNESS_NETWORK: baseScenario.network,
MPP_HARNESS_MINT: baseScenario.asset,
MPP_HARNESS_PRICE: baseScenario.price,
MPP_HARNESS_SECRET_KEY: "mpp-harness-secret-key",
// Rust audit #24 requires ≥32-byte HMAC secrets (NIST SP 800-107 for
// HMAC-SHA256). Padded with `-pad` to clear the threshold without
// changing the test's intent.
MPP_HARNESS_SECRET_KEY: "mpp-harness-secret-key-with-32b-pad",
MPP_HARNESS_PAY_TO: payTo.publicKey,
MPP_HARNESS_CLIENT_SECRET_KEY: JSON.stringify(Array.from(client.secretKey)),
MPP_HARNESS_FEE_PAYER_SECRET_KEY: JSON.stringify(
Expand Down Expand Up @@ -593,7 +596,8 @@ describe("mpp harness", () => {
const envA = environmentForScenario(harnessEnv, scenario);
const envB = {
...environmentForScenario(harnessEnv, scenario),
MPP_HARNESS_SECRET_KEY: "mpp-harness-secret-key-server-b",
// Rust audit #24: 32-byte minimum.
MPP_HARNESS_SECRET_KEY: "mpp-harness-secret-key-server-b-pad",
};
const a = await startServer(serverA, envA);
runningServers.push(a);
Expand Down
763 changes: 763 additions & 0 deletions rust/AUDIT-ASSESSMENT.md

Large diffs are not rendered by default.

23 changes: 18 additions & 5 deletions rust/crates/mpp/examples/payment_link_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,27 @@ use std::collections::HashMap;
use std::sync::Arc;

const ROUTE_PRICE: &str = "0.01";
const ROUTE_DESCRIPTION: &str = "Open a fortune cookie";

/// Build the route's expected charge request. Threading this into
/// `verify_credential_with_expected` is what protects against cross-route
/// credential replay — without it, a credential issued for a cheaper route
/// (or different recipient/currency) on the same server would be accepted.
///
/// Important: the options here MUST match the options used when the route
/// issues its user-facing challenge. Audit #1 compares every
/// payment-constraining field (including description), so a mismatch here
/// would reject every honest credential.
fn expected_request_for_route(mpp: &Mpp) -> Option<ChargeRequest> {
mpp.charge(ROUTE_PRICE)
.ok()
.and_then(|challenge| challenge.request.decode().ok())
mpp.charge_with_options(
ROUTE_PRICE,
solana_mpp::server::ChargeOptions {
description: Some(ROUTE_DESCRIPTION),
..Default::default()
},
)
.ok()
.and_then(|challenge| challenge.request.decode().ok())
}

const CSP: &str = "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src *; worker-src 'self'";
Expand Down Expand Up @@ -85,12 +97,13 @@ async fn fortune(
.into_response();
}

// Generate challenge.
// Generate challenge. Options here must match `expected_request_for_route`
// exactly — audit #1 compares every payment-constraining field.
let challenge = mpp
.charge_with_options(
ROUTE_PRICE,
solana_mpp::server::ChargeOptions {
description: Some("Open a fortune cookie"),
description: Some(ROUTE_DESCRIPTION),
..Default::default()
},
)
Expand Down
12 changes: 11 additions & 1 deletion rust/crates/mpp/src/bin/harness_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ use solana_mpp::{
const DEFAULT_RESOURCE_PATH: &str = "/protected";
const HEALTH_PATH: &str = "/health";
const DEFAULT_PRICE: &str = "0.001";
const DEFAULT_SECRET_KEY: &str = "mpp-harness-secret-key";
// Audit #24: ≥32 bytes for HMAC-SHA256 keys. Pad to keep the harness
// default usable when no MPP_HARNESS_SECRET_KEY is set in the env.
const DEFAULT_SECRET_KEY: &str = "mpp-harness-secret-key-with-32b-pad";
const DEFAULT_SETTLEMENT_HEADER: &str = "x-fixture-settlement";
const DEFAULT_TOKEN_DECIMALS: u8 = 6;

Expand Down Expand Up @@ -144,6 +146,11 @@ fn read_state() -> Result<HarnessState, Box<dyn std::error::Error + Send + Sync>
_ => DEFAULT_TOKEN_DECIMALS,
};
let splits = read_splits()?;
// Refuse to boot with invalid splits (audit #21). The harness
// misconfig scenario depends on this — every server SDK should
// reject the misconfig consistently, and refusing to start is the
// earliest possible signal.
solana_mpp::protocol::solana::validate_splits(&splits)?;

Ok(HarnessState {
mpp: Mpp::new(Config {
Expand All @@ -158,6 +165,9 @@ fn read_state() -> Result<HarnessState, Box<dyn std::error::Error + Send + Sync>
fee_payer_signer: if push_mode { None } else { Some(fee_payer) },
store: None,
html: false,
// Interop tests exercise push mode end-to-end; the gate is
// opt-in (audit #5) so we set it explicitly here.
accept_push_mode: push_mode,
})?,
price,
push_mode,
Expand Down
4 changes: 3 additions & 1 deletion rust/crates/mpp/src/client/authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,15 @@ mod tests {
currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(),
decimals: 6,
network: "mainnet".into(),
challenge_binding_secret: Some("test-secret".into()),
// ≥32 bytes to satisfy the audit #24 secret-length check at Mpp::new.
challenge_binding_secret: Some("test-secret-key-for-authenticate-32b-pad".into()),
..Default::default()
})
.expect("mpp")
.charge_challenge(&crate::ChargeRequest {
amount: "1000".into(),
currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(),
recipient: Some(signer.pubkey().to_string()),
..Default::default()
})
.expect("charge challenge");
Expand Down
Loading
Loading