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
7 changes: 7 additions & 0 deletions crates/api/src/routes/sponsor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use octo_crypto::SealedSeed;
use octo_wallet_core::{
compute_inner_tx_hash, inner_sequence_number, sign_fee_bump, sign_fee_bump_with_account_id, FeeBumpRequest,
};
use stellar_base::transaction::MIN_BASE_FEE;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

Expand Down Expand Up @@ -52,6 +53,12 @@ pub async fn sponsor(
.max_base_fee_stroops
.filter(|f| *f > 0)
.ok_or_else(|| ApiError::BadRequest("max_base_fee_stroops must be > 0".into()))?;
if max_fee < MIN_BASE_FEE.to_i64() {
return Err(ApiError::BadRequest(format!(
"max_base_fee_stroops must be at least {}",
MIN_BASE_FEE.to_i64()
)));
}

let wallet = state.store().get_wallet(wallet_id).await?;
if wallet.is_archived() {
Expand Down
12 changes: 12 additions & 0 deletions crates/wallet-core/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ mod tests {
));
}

#[test]
fn to_base_account_rejects_a_muxed_address_with_a_corrupted_character() {
let muxed = encode_muxed(BASE, 42).unwrap();
let replacement = if muxed.as_bytes()[20] == b'X' { 'Y' } else { 'X' };
let corrupted = format!("{}{}{}", &muxed[..20], replacement, &muxed[21..]);

assert!(matches!(
to_base_account(&corrupted),
Err(WalletError::InvalidAddress)
));
}

// -----------------------------------------------------------------------
// Strkey-level corruption tests
//
Expand Down
42 changes: 35 additions & 7 deletions crates/wallet-core/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const BIP44_PURPOSE: u32 = 44;
const HARDENED: u32 = 0x8000_0000;
/// Entropy for a 12-word BIP39 mnemonic (128 bits).
const MNEMONIC_ENTROPY_LEN: usize = 16;
/// BIP39 seed output length, independent of mnemonic entropy length.
const BIP39_SEED_LEN: usize = 64;

/// Validate a BIP-39 recovery phrase without constructing or holding secret material.
///
Expand Down Expand Up @@ -88,9 +90,13 @@ impl WalletSeed {
Ok(WalletSeed(Zeroizing::new(seed.as_bytes().to_vec())))
}

/// Construct directly from raw seed bytes (e.g. after decrypting a sealed seed).
pub fn from_bytes(bytes: Vec<u8>) -> WalletSeed {
WalletSeed(Zeroizing::new(bytes))
/// Construct from the 64-byte BIP39 seed output (e.g. after decrypting a sealed seed).
pub fn from_bytes(bytes: Vec<u8>) -> Result<WalletSeed, WalletError> {
let bytes = Zeroizing::new(bytes);
if bytes.len() != BIP39_SEED_LEN {
return Err(WalletError::InvalidSeedLength);
}
Ok(WalletSeed(bytes))
}

/// Borrow the raw seed bytes (kept private to the crate; callers derive, they don't read).
Expand Down Expand Up @@ -231,6 +237,26 @@ mod tests {
assert!(WalletSeed::from_phrase(VECTOR_MNEMONIC).is_ok());
}

#[test]
fn from_bytes_accepts_only_the_64_byte_bip39_seed_length() {
assert!(matches!(
WalletSeed::from_bytes(Vec::new()),
Err(WalletError::InvalidSeedLength)
));
assert!(matches!(
WalletSeed::from_bytes(vec![0; BIP39_SEED_LEN - 1]),
Err(WalletError::InvalidSeedLength)
));
assert!(matches!(
WalletSeed::from_bytes(vec![0; BIP39_SEED_LEN + 1]),
Err(WalletError::InvalidSeedLength)
));

let phrase_seed = WalletSeed::from_phrase(VECTOR_MNEMONIC).unwrap();
assert_eq!(phrase_seed.as_bytes().len(), BIP39_SEED_LEN);
assert!(WalletSeed::from_bytes(vec![0; BIP39_SEED_LEN]).is_ok());
}

#[test]
fn from_phrase_rejects_a_word_not_in_the_wordlist() {
assert!(matches!(
Expand Down Expand Up @@ -347,8 +373,8 @@ mod tests {
let mnemonic =
bip39::Mnemonic::from_entropy(&entropy, bip39::Language::English).unwrap();
let seed_bytes = bip39::Seed::new(&mnemonic, "").as_bytes().to_vec();
let seed_a = WalletSeed::from_bytes(seed_bytes.clone());
let seed_b = WalletSeed::from_bytes(seed_bytes);
let seed_a = WalletSeed::from_bytes(seed_bytes.clone()).unwrap();
let seed_b = WalletSeed::from_bytes(seed_bytes).unwrap();
let secret_a = seed_a.derive_ed25519_secret(index).unwrap();
let secret_b = seed_b.derive_ed25519_secret(index).unwrap();
prop_assert_eq!(*secret_a, *secret_b);
Expand All @@ -363,8 +389,10 @@ mod tests {
prop_assume!(index_a != index_b);
let mnemonic =
bip39::Mnemonic::from_entropy(&entropy, bip39::Language::English).unwrap();
let seed =
WalletSeed::from_bytes(bip39::Seed::new(&mnemonic, "").as_bytes().to_vec());
let seed = WalletSeed::from_bytes(
bip39::Seed::new(&mnemonic, "").as_bytes().to_vec(),
)
.unwrap();
let secret_a = seed.derive_ed25519_secret(index_a).unwrap();
let secret_b = seed.derive_ed25519_secret(index_b).unwrap();
prop_assert_ne!(*secret_a, *secret_b);
Expand Down
5 changes: 5 additions & 0 deletions crates/wallet-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub enum WalletError {
#[error("invalid mnemonic checksum")]
InvalidChecksum,

/// Raw seed bytes did not have the 64-byte BIP-39 seed length.
#[error("invalid seed length")]
InvalidSeedLength,

/// A derivation path component or index was invalid.
#[error("invalid derivation path")]
InvalidDerivationPath,
Expand Down Expand Up @@ -83,6 +87,7 @@ mod tests {
let secret = "illness spike retreat truth genius clock brain pass fit cave bargain toe";
let errors = [
WalletError::InvalidMnemonic,
WalletError::InvalidSeedLength,
WalletError::InvalidDerivationPath,
WalletError::KeyDerivation,
WalletError::MnemonicAccountMismatch,
Expand Down
Loading