Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use tower_http::cors::{Any, CorsLayer};
/// These routes deserialize JSON from raw `Bytes`; a `Bytes` extractor alone would otherwise
/// rely on axum's implicit body limit (currently 2 MiB in this workspace's version). Making the
/// limit explicit here keeps the behavior intentional and version-stable.
const REQUEST_BODY_LIMIT: usize = 64 * 1024;
pub const REQUEST_BODY_LIMIT: usize = 64 * 1024;

/// Caller-facing wall-clock ceiling for routes that make a synchronous outbound call (Horizon).
///
Expand Down
164 changes: 164 additions & 0 deletions crates/api/tests/body_size_limit_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//! Regression test asserting request-body size limits apply uniformly across every mutating route.

mod common;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use octo_api::{build_router, AppState, REQUEST_BODY_LIMIT};
use octo_store::Store;
use octo_wallet_core::StellarNetwork;
use std::sync::Once;
use tower::ServiceExt;

static LOAD_ENV: Once = Once::new();

fn database_url() -> Option<String> {
LOAD_ENV.call_once(|| {
let _ = dotenvy::dotenv();
});
std::env::var("DATABASE_URL").ok()
}

async fn test_state() -> Option<AppState> {
let url = database_url()?;
let store = Store::connect(&url).await.expect("connect");
store.migrate().await.expect("migrate");
let master_key = [42u8; 32];
Some(AppState::new(
store,
master_key,
StellarNetwork::Testnet,
"https://horizon-testnet.stellar.org".into(),
None,
octo_email::EmailSender::new_captured(),
))
}

struct MutatingRoute {
method: &'static str,
path: &'static str,
}

const MUTATING_ROUTES: &[MutatingRoute] = &[
MutatingRoute {
method: "POST",
path: "/v1/auth/signup",
},
MutatingRoute {
method: "POST",
path: "/v1/auth/verify-email",
},
MutatingRoute {
method: "POST",
path: "/v1/auth/resend-otp",
},
MutatingRoute {
method: "POST",
path: "/v1/auth/login",
},
MutatingRoute {
method: "POST",
path: "/v1/auth/refresh",
},
MutatingRoute {
method: "PATCH",
path: "/v1/auth/me",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/addresses",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/webhooks",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/submit-signed",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/withdraw/request-otp",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/withdraw/confirm",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/gas-tank",
},
MutatingRoute {
method: "PUT",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/sponsorship",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/sponsor",
},
MutatingRoute {
method: "PUT",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/whitelist/config",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/whitelist",
},
MutatingRoute {
method: "POST",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/payment-links",
},
MutatingRoute {
method: "PUT",
path: "/v1/wallets/00000000-0000-0000-0000-000000000000/payment-links/00000000-0000-0000-0000-000000000000",
},
MutatingRoute {
method: "POST",
path: "/v1/pay/sample-link/intent",
},
MutatingRoute {
method: "POST",
path: "/v1/pay/sample-link/submit-signed",
},
];

#[tokio::test]
async fn every_mutating_route_rejects_an_oversized_body_with_a_clean_413() {
let Some(state) = test_state().await else {
return;
};
let app = build_router(state);

// Create an oversized body exceeding REQUEST_BODY_LIMIT (64 KiB).
let oversized_body = vec![b'a'; REQUEST_BODY_LIMIT + 1024];

for route in MUTATING_ROUTES {
let req = Request::builder()
.method(route.method)
.uri(route.path)
.header("content-type", "application/json")
.body(Body::from(oversized_body.clone()))
.unwrap();

let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"route {} {} must reject oversized body with 413 Payload Too Large",
route.method,
route.path
);

let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
assert!(
!bytes.is_empty(),
"route {} {} 413 response should explain itself",
route.method,
route.path
);
}
}
161 changes: 157 additions & 4 deletions crates/wallet-core/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ const HARDENED: u32 = 0x8000_0000;
/// Entropy for a 12-word BIP39 mnemonic (128 bits).
const MNEMONIC_ENTROPY_LEN: usize = 16;

/// Validate a BIP-39 recovery phrase without constructing or holding secret material.
///
/// Verifies word count, English wordlist membership, and BIP-39 checksum. Safe to call
/// with untrusted input and produces no secret-bearing output, making it suitable for
/// pre-flight client-side checks and API validation routes.
///
/// Returns `Ok(())` on valid mnemonics, or [`WalletError::InvalidMnemonic`] if the phrase
/// is syntactically invalid or fails checksum validation.
pub fn validate_seed_phrase(phrase: &str) -> Result<(), WalletError> {
// Validate mnemonic syntax, wordlist membership, and checksum.
Mnemonic::from_phrase(phrase, Language::English)
.map_err(|_| WalletError::InvalidMnemonic)?;
Ok(())
}

/// A BIP39 seed (the 64-byte output of mnemonic + passphrase), zeroized on drop.
pub struct WalletSeed(Zeroizing<Vec<u8>>);

Expand Down Expand Up @@ -63,6 +78,8 @@ impl WalletSeed {
/// Validates both that each word belongs to the BIP-39 wordlist and that the phrase's
/// built-in checksum bits verify. Wordlist membership alone is not sufficient validation.
pub fn from_phrase(phrase: &str) -> Result<WalletSeed, WalletError> {
// Enforce wordlist and checksum validation before constructing secret material.
validate_seed_phrase(phrase)?;
let mnemonic = Mnemonic::from_phrase(phrase, Language::English).map_err(|e| match e {
bip39::ErrorKind::InvalidChecksum => WalletError::InvalidChecksum,
_ => WalletError::InvalidMnemonic,
Expand All @@ -83,15 +100,52 @@ impl WalletSeed {

/// Derive the 32-byte ed25519 secret key for Stellar account `index` (`m/44'/148'/index'`).
///
/// Per SEP-0005 and SLIP-0010 / BIP-32, hardened derivation adds `0x8000_0000` (2^31) to the
/// index. An index at or above 2^31 (`index >= 0x8000_0000`) is invalid and would wrap or
/// collide with lower indices; it is explicitly rejected with [`WalletError::InvalidDerivationPath`].
/// # Derivation Path & Invariants
///
/// Derives according to Stellar's [SEP-0005](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0005.md)
/// specification using SLIP-0010 ed25519 master-key derivation:
///
/// - **Path**: `m/44'/148'/index'`, where `44'` is BIP-44 purpose, `148'` is Stellar's
/// SLIP-0044 coin type, and `index'` is the account index.
/// - **All-Hardened Derivation**: Every level in SEP-0005 is strictly hardened (`index | HARDENED`).
/// Unlike EVM's BIP-44 path (`m/44'/60'/0'/0/index`, referenced for contrast in
/// `docs/ethereum-expansion-issues.md`), which permits unhardened derivation at the change and
/// address levels, ed25519 does not safely support unhardened public derivation without
/// compromising key security (leaking an extended public key alongside a single child private
/// key would allow recovering the parent secret key and all sibling keys). Full hardening
/// guarantees that compromise of any derived key cannot compromise parent or sibling keys.
/// - **Valid Index Range**: Hardened indices must fall within `0..2^31` (`0..0x8000_0000`). Values
/// at or above the 2^31 ceiling cannot be hardened without overflowing the 31-bit index space.
/// Per SEP-0005 and SLIP-0010 / BIP-32, hardened derivation adds `0x8000_0000` (2^31) to the
/// index. An index at or above 2^31 (`index >= 0x8000_0000`) is invalid and would wrap or
/// collide with lower indices; it is explicitly rejected with [`WalletError::InvalidDerivationPath`].
///
/// # Architecture & Deposit Model
///
/// In Octo's deposit architecture (see `docs/deposit-model.md`), this derivation underpins the
/// muxed-address model: account index 0 is derived as the single master base account (`G...`).
/// Customer funds are multiplexed via 64-bit IDs encoded into SEP-0023 muxed addresses (`M...`),
/// allowing off-chain per-user address allocation with zero on-chain account reserves and no sweeps.
/// Arbitrary index derivation remains available if dedicated on-chain accounts are required.
///
/// The returned secret is wrapped in [`Zeroizing`] and zeroized on drop. Feed it to
/// [`crate::signer`] to construct a keypair.
///
/// Returned zeroized; feed it to [`crate::signer`] to build a keypair.
/// # Example
///
/// ```rust
/// use octo_wallet_core::WalletSeed;
///
/// let mnemonic = "illness spike retreat truth genius clock brain pass fit cave bargain toe";
/// let seed = WalletSeed::from_phrase(mnemonic).unwrap();
/// let secret = seed.derive_ed25519_secret(0).unwrap();
/// assert_eq!(secret.len(), 32);
/// ```
pub fn derive_ed25519_secret(&self, index: u32) -> Result<Zeroizing<[u8; 32]>, WalletError> {
if index >= HARDENED {
return Err(WalletError::InvalidDerivationPath);
}
// Derive ed25519 key at hardened path m/44'/148'/index'.
let path = [
BIP44_PURPOSE | HARDENED,
STELLAR_COIN_TYPE | HARDENED,
Expand Down Expand Up @@ -185,6 +239,105 @@ mod tests {
));
}

// Sourced checksum-invalid and syntactically invalid test vectors.
//
// Sources:
// 1. Trezor python-mnemonic test suite (tests/test_mnemonic.py:test_failed_checksum).
// 2. BIP-39 specification official vectors (bitcoin/bips/bip-0039.mediawiki &
// trezor/python-mnemonic/vectors.json), mutating the final checksum word to an alternative
// wordlist entry ("almost valid" vectors: correct word count and wordlist membership, wrong checksum).
// 3. Grossly invalid vectors (length mismatches, non-wordlist tokens, empty input).
const SOURCED_CHECKSUM_INVALID_VECTORS: &[&str] = &[
// Trezor python-mnemonic tests/test_mnemonic.py test_failed_checksum
"bless cloud wheel regular tiny venue bird web grief security dignity zoo",
// BIP-39 spec vector 0 (12-word all-zero entropy), mutated checksum word (about -> abandon)
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
// BIP-39 spec vector 0 (12-word all-zero entropy), mutated checksum word (about -> zoo)
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zoo",
// BIP-39 spec vector 1 (12-word all-0x7F entropy), mutated checksum word (yellow -> legal)
"legal winner thank year wave sausage worth useful legal winner thank legal",
// BIP-39 spec vector 3 (12-word all-0xFF entropy), mutated checksum word (wrong -> zoo)
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo",
// 15-word phrase, mutated checksum word
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
// BIP-39 spec vector 4 (18-word all-zero entropy), mutated checksum word (agent -> abandon)
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
// BIP-39 spec vector 7 (24-word all-zero entropy), mutated checksum word (art -> abandon)
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
// BIP-39 spec vector 8 (24-word all-0x7F entropy), mutated checksum word (title -> yellow)
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth yellow",
// BIP-39 spec vector 9 (24-word all-0xFF entropy), mutated checksum word (vote -> zoo)
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo",
// Grossly invalid: non-wordlist tokens
"not a real mnemonic phrase at all",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon notaword",
// Grossly invalid: incorrect word counts (11, 13, 25 words)
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
// Grossly invalid: empty and corrupt strings
"",
" ",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon 1234",
];

#[test]
fn from_phrase_rejects_every_sourced_checksum_invalid_vector() {
for &vector in SOURCED_CHECKSUM_INVALID_VECTORS {
let res = WalletSeed::from_phrase(vector);
assert!(
matches!(res, Err(WalletError::InvalidMnemonic)),
"from_phrase must reject checksum-invalid vector {vector:?}, got {res:?}"
);
}
}

#[test]
fn validate_seed_phrase_accepts_every_from_phrase_accepted_vector() {
let (gen_phrase, _) = WalletSeed::generate();
let valid_vectors = [
VECTOR_MNEMONIC,
gen_phrase.as_str(),
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"legal winner thank year wave sausage worth useful legal winner thank yellow",
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
];
for vector in valid_vectors {
assert!(
validate_seed_phrase(vector).is_ok(),
"validate_seed_phrase rejected valid vector {vector:?}"
);
assert!(
WalletSeed::from_phrase(vector).is_ok(),
"from_phrase rejected valid vector {vector:?}"
);
}
}

#[test]
fn validate_seed_phrase_rejects_every_from_phrase_rejected_vector() {
for &vector in SOURCED_CHECKSUM_INVALID_VECTORS {
let val_res = validate_seed_phrase(vector);
let seed_res = WalletSeed::from_phrase(vector);
assert!(
matches!(val_res, Err(WalletError::InvalidMnemonic)),
"validate_seed_phrase must reject {vector:?}"
);
assert!(
matches!(seed_res, Err(WalletError::InvalidMnemonic)),
"from_phrase must reject {vector:?}"
);
}
}

#[test]
fn validate_seed_phrase_never_returns_secret_material_even_on_success() {
// Assert at type level that validate_seed_phrase produces unit () and holds no secret state.
let result: Result<(), WalletError> = validate_seed_phrase(VECTOR_MNEMONIC);
assert_eq!(result.unwrap(), ());
assert_eq!(std::mem::size_of::<()>(), 0);
}

proptest! {
#[test]
fn derivation_is_deterministic_for_any_index(
Expand Down
2 changes: 1 addition & 1 deletion crates/wallet-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub use address::{
verify_account_signature, DecodedMuxed, DepositAddress,
};
pub use asset::is_valid_asset_code;
pub use derive::WalletSeed;
pub use derive::{validate_seed_phrase, WalletSeed};
pub use error::WalletError;
pub use provision::{import_wallet, provision_wallet, ProvisionedWallet};
pub use signer::{
Expand Down
Loading