diff --git a/Cargo.lock b/Cargo.lock index c35d45e..6fc46aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.5.1" @@ -601,6 +607,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -712,6 +730,20 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -746,6 +778,25 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -822,6 +873,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1000,6 +1061,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1039,6 +1101,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -1475,6 +1548,29 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1727,8 +1823,10 @@ dependencies = [ "hmac", "http-body-util", "jsonschema", + "octo-chain", "octo-crypto", "octo-email", + "octo-evm-core", "octo-ingest", "octo-resilience", "octo-store", @@ -1753,6 +1851,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "octo-chain" +version = "0.1.0" +dependencies = [ + "octo-crypto", + "octo-evm-core", + "octo-wallet-core", + "thiserror 1.0.69", +] + [[package]] name = "octo-crypto" version = "0.1.0" @@ -1780,6 +1888,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "octo-evm-core" +version = "0.1.0" +dependencies = [ + "hex", + "hmac", + "k256", + "octo-crypto", + "proptest", + "serde", + "sha2", + "sha3", + "thiserror 1.0.69", + "tiny-bip39", + "zeroize", +] + [[package]] name = "octo-ingest" version = "0.1.0" @@ -2392,6 +2517,16 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2577,6 +2712,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -2684,6 +2833,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index f8fb362..30eb0e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "crates/crypto", "crates/wallet-core", + "crates/evm-core", + "crates/chain", "crates/store", "crates/webhooks", "crates/email", @@ -32,6 +34,9 @@ slip10_ed25519 = "0.1.3" tiny-bip39 = "2.0.0" ed25519-dalek = "2.2.0" aes-gcm = "0.10.3" +# --- EVM / secp256k1 (see docs/architecture.md ADR: narrow primitives over alloy, MSRV 1.84.1) --- +k256 = "0.13" +sha3 = "0.10" zeroize = { version = "1.8.2", features = ["derive"] } hmac = "0.12.1" hkdf = "0.12.4" @@ -67,6 +72,8 @@ proptest = "1" # --- internal crates --- octo-crypto = { path = "crates/crypto" } octo-wallet-core = { path = "crates/wallet-core" } +octo-evm-core = { path = "crates/evm-core" } +octo-chain = { path = "crates/chain" } octo-store = { path = "crates/store" } octo-webhooks = { path = "crates/webhooks" } octo-email = { path = "crates/email" } diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 2f37fd0..410ff5b 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -11,6 +11,8 @@ authors.workspace = true [dependencies] octo-crypto.workspace = true octo-wallet-core.workspace = true +octo-evm-core.workspace = true +octo-chain.workspace = true octo-store.workspace = true octo-webhooks.workspace = true octo-email.workspace = true diff --git a/crates/api/src/routes/addresses.rs b/crates/api/src/routes/addresses.rs index e6d594a..9df0691 100644 --- a/crates/api/src/routes/addresses.rs +++ b/crates/api/src/routes/addresses.rs @@ -1,11 +1,15 @@ //! Address endpoints: generate a customer deposit address, list them with pagination. //! -//! Each address is returned in **both** forms — the muxed `M...` (default) and the -//! `G...` + numeric `memo_id` fallback for senders that don't support muxed (see -//! `docs/deposit-model.md`). +//! Response shape depends on the wallet's chain (see `docs/deposit-model.md`): +//! - **Stellar**: both forms — the muxed `M...` (default) and the `G...` + numeric `memo_id` +//! fallback for senders that don't support muxed. +//! - **EVM**: a single real HD-derived EOA (`address`). There is no memo fallback — EVM has +//! nowhere for a memo to go — so `memo_id`/`muxed_address`/`base_address` are omitted entirely +//! from EVM responses (not merely `null`), so clients aren't encouraged to send a memo that +//! would be silently dropped. use crate::auth::authorize_wallet; -use crate::error::{ApiResult, Envelope}; +use crate::error::{ApiError, ApiResult, Envelope}; use crate::json::parse_optional; use crate::routes::wallets::ListParams; use crate::state::AppState; @@ -13,6 +17,9 @@ use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; +use octo_chain::{ChainAdapter, DepositAddress as ChainDepositAddress, DeriveInput, EvmAdapter}; +use octo_crypto::SealedSeed; +use octo_store::{Address, Wallet}; use octo_wallet_core::encode_muxed; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -28,24 +35,68 @@ pub struct CreateAddressRequest { pub metadata: Option, } -/// An address in both deposit forms. +/// An address, in whichever shape its chain produces — see the module doc. #[derive(Debug, Serialize)] pub struct AddressView { pub id: Uuid, pub customer_ref: Option, - /// Default form handed to customers. - pub muxed_address: String, - /// Fallback for `G...`+memo senders (same id as the muxed address). - pub base_address: String, - pub memo_id: i64, + /// `"stellar"` or `"evm"`, so a client can branch without inspecting which optional fields + /// happen to be present. + pub chain_kind: String, + + /// Stellar only: the muxed `M...` address (default form). + #[serde(skip_serializing_if = "Option::is_none")] + pub muxed_address: Option, + /// Stellar only: the `G...` fallback base account. + #[serde(skip_serializing_if = "Option::is_none")] + pub base_address: Option, + /// Stellar only. **Never present on an EVM response** — EVM has no memo field, so advertising + /// one would invite a client to send a memo that lands nowhere and is silently dropped. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo_id: Option, + + /// EVM only: the EIP-55 checksummed deposit address. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + pub metadata: serde_json::Value, - /// Lifetime total (stroops) of confirmed deposits credited to this address. Historical - /// bookkeeping, not a live balance — deposits to any address land in the wallet's single - /// master account (the point of muxed addresses is that there is nothing to sweep), so this - /// will not match a per-address on-chain balance query. + /// Lifetime total (stroops) of confirmed deposits credited to this address. Always `0` for an + /// EVM address today — EVM deposit crediting is a separate, not-yet-built ingest worker — but + /// plumbed through rather than hardcoded so this doesn't need a signature change once it + /// lands. pub received_stroops: i64, } +impl AddressView { + fn stellar(address: Address, base_address: String, received_stroops: i64) -> AddressView { + AddressView { + id: address.id, + customer_ref: address.customer_ref, + chain_kind: "stellar".into(), + muxed_address: address.muxed_address, + base_address: Some(base_address), + memo_id: address.muxed_id, + address: None, + metadata: address.metadata, + received_stroops, + } + } + + fn evm(address: Address, received_stroops: i64) -> AddressView { + AddressView { + id: address.id, + customer_ref: address.customer_ref, + chain_kind: "evm".into(), + muxed_address: None, + base_address: None, + memo_id: None, + address: address.evm_address, + metadata: address.metadata, + received_stroops, + } + } +} + /// Paginated list response for addresses. #[derive(Debug, Serialize)] pub struct AddressListResponse { @@ -64,27 +115,33 @@ pub async fn create_address( // Authorize via login JWT (wallet owner) or API key (key's wallet). authorize_wallet(&headers, &state, wallet_id).await?; let req: CreateAddressRequest = parse_optional(&body)?; - - // Fetch the wallet to learn its base G... account (the muxed addresses encode it). let wallet = state.store().get_wallet(wallet_id).await?; - let base = wallet.stellar_account_g.clone(); - let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); - // allocate_address bumps the muxed-id counter atomically and derives the M... via this closure. - let address = state - .store() - .allocate_address( - wallet_id, - |id| { - // muxed_id is a positive i64 from the counter; encode needs u64. - let id_u64 = u64::try_from(id).map_err(|_| ())?; - encode_muxed(&base, id_u64).map_err(|_| ()) - }, - req.customer_ref.as_deref(), - metadata, - ) - .await?; + let view = if wallet.is_evm() { + let address = + allocate_evm_address(&state, &wallet, req.customer_ref.as_deref(), metadata).await?; + AddressView::evm(address, 0) + } else { + // allocate_address bumps the muxed-id counter atomically and derives the M... via this + // closure. Unchanged from before EVM support existed. + let base = wallet.stellar_account_g.clone(); + let address = state + .store() + .allocate_address( + wallet_id, + |id| { + // muxed_id is a positive i64 from the counter; encode needs u64. + let id_u64 = u64::try_from(id).map_err(|_| ())?; + encode_muxed(&base, id_u64).map_err(|_| ()) + }, + req.customer_ref.as_deref(), + metadata, + ) + .await?; + // A brand-new address has no deposits yet, so this is always 0 — no query needed. + AddressView::stellar(address, wallet.stellar_account_g.clone(), 0) + }; if let Some(uid) = wallet.user_id { crate::audit::record( @@ -98,20 +155,73 @@ pub async fn create_address( .await; } - // A brand-new address has no deposits yet, so this is always 0 — no query needed. - let view = AddressView { - id: address.id, - customer_ref: address.customer_ref, - muxed_address: address.muxed_address, - base_address: wallet.stellar_account_g, - memo_id: address.muxed_id, - metadata: address.metadata, - received_stroops: 0, - }; let (status, json) = Envelope::created(view); Ok((status, json)) } +/// Open the wallet's sealed HD seed and allocate the next EVM deposit address under it, via +/// [`octo_chain::EvmAdapter`]. Mirrors the decrypt pattern in `routes/sponsor.rs`: the seed is +/// opened only for the duration of the derive call and never persisted in plaintext. +async fn allocate_evm_address( + state: &AppState, + wallet: &Wallet, + customer_ref: Option<&str>, + metadata: serde_json::Value, +) -> ApiResult
{ + let (Some(ciphertext), Some(nonce), Some(salt), Some(scheme), Some(chain_id)) = ( + wallet.sealed_ciphertext.as_ref(), + wallet.sealed_nonce.as_ref(), + wallet.sealed_salt.as_ref(), + wallet.sealed_scheme, + wallet.chain_id.as_deref(), + ) else { + // wallets_evm_has_chain_id / wallets_evm_is_server_custody / wallets_server_custody_has_seed + // together guarantee an EVM wallet always has all five — reaching here means the schema + // invariant was violated some other way. + return Err(ApiError::Internal); + }; + let sealed = SealedSeed::from_parts_with_scheme(ciphertext.clone(), nonce, salt, scheme as u8) + .map_err(|_| ApiError::Internal)?; + let master_key = *state.master_key_for_scheme(scheme); + let context = evm_crypto_context(chain_id); + + let address = state + .store() + .allocate_evm_address( + wallet.id, + |index| { + EvmAdapter + .derive_deposit_address( + DeriveInput::Evm { + master_key: &master_key, + sealed: &sealed, + context: context.as_bytes(), + }, + u64::from(index), + ) + .map(|d| match d { + ChainDepositAddress::Evm(e) => e.address, + ChainDepositAddress::Stellar(_) => { + unreachable!("EvmAdapter always returns DepositAddress::Evm") + } + }) + .map_err(|_| ()) + }, + customer_ref, + metadata, + ) + .await?; + Ok(address) +} + +/// The AAD context a wallet's sealed seed is bound under. Must match exactly what provisioning +/// used, or `octo_crypto::open` rejects it — see `docs/threat-model.md` on context binding, and +/// `octo_evm_core::provision_evm_wallet`'s doc comment on why this must be chain-scoped (a seed +/// sealed for one EVM chain must not open under another). +fn evm_crypto_context(chain_id: &str) -> String { + format!("octo:{chain_id}") +} + /// `GET /v1/wallets/{id}/addresses` — list deposit addresses for a wallet, with optional /// `?limit=` and `?before=` cursor pagination. pub async fn list_addresses( @@ -122,8 +232,6 @@ pub async fn list_addresses( ) -> ApiResult>> { authorize_wallet(&headers, &state, wallet_id).await?; let wallet = state.store().get_wallet(wallet_id).await?; - // Every address view echoes the wallet's base (G...) account alongside its muxed form. - let base = wallet.stellar_account_g.clone(); let limit = crate::routes::wallets::validated_limit(q.limit)?; @@ -145,7 +253,9 @@ pub async fn list_addresses( }; // One batched query for all rows on this page instead of N — see - // Store::sum_deposits_for_addresses. + // Store::sum_deposits_for_addresses. Chain-agnostic (keyed by address_id), so it's safe to + // call for an EVM page too; it will simply find no transactions until the EVM ingest worker + // exists. let ids: Vec = items.iter().map(|a| a.id).collect(); let totals = state .store() @@ -154,16 +264,17 @@ pub async fn list_addresses( .map_err(|_| crate::error::ApiError::Internal)?; let totals: std::collections::HashMap = totals.into_iter().collect(); + let is_evm = wallet.is_evm(); + let base = wallet.stellar_account_g.clone(); let views = items .into_iter() - .map(|a| AddressView { - id: a.id, - customer_ref: a.customer_ref, - muxed_address: a.muxed_address, - base_address: base.clone(), - memo_id: a.muxed_id, - metadata: a.metadata, - received_stroops: totals.get(&a.id).copied().unwrap_or(0), + .map(|a| { + let received = totals.get(&a.id).copied().unwrap_or(0); + if is_evm { + AddressView::evm(a, received) + } else { + AddressView::stellar(a, base.clone(), received) + } }) .collect(); diff --git a/crates/api/src/routes/payment_links.rs b/crates/api/src/routes/payment_links.rs index bd039b4..910a33f 100644 --- a/crates/api/src/routes/payment_links.rs +++ b/crates/api/src/routes/payment_links.rs @@ -357,13 +357,17 @@ pub async fn get_public_payment_link( .get_address(link.address_id) .await? .ok_or(ApiError::NotFound)?; + // Payment links are a Stellar-only (USDC) feature today, so this address is always + // muxed-shaped; a None here would mean the link's address was somehow allocated on an EVM + // wallet, which nothing in this codebase does. + let deposit_address = address.muxed_address.ok_or(ApiError::Internal)?; Ok(Envelope::ok(PublicPaymentLinkView { name: link.name, description: link.description, image_url: link.image_url, redirect_url: link.redirect_url, amount_usdc_stroops: link.amount_usdc_stroops, - deposit_address: address.muxed_address, + deposit_address, asset_code: USDC_ASSET_CODE.into(), })) } @@ -434,9 +438,11 @@ pub async fn create_payment_intent( ) .await?; + // Allocated via allocate_address (the Stellar/muxed path) above, so this is always Some. + let deposit_address = address.muxed_address.ok_or(ApiError::Internal)?; let (code, json) = Envelope::created(PaymentIntentView { payment_id: payment.id, - deposit_address: address.muxed_address, + deposit_address, amount_usdc_stroops: amount, }); Ok((code, json)) @@ -605,7 +611,8 @@ pub async fn submit_payment( let OperationBody::Payment(p) = &op.body else { return false; }; - if crate::submit_validation::muxed_to_string(&p.destination) != address.muxed_address { + if Some(crate::submit_validation::muxed_to_string(&p.destination)) != address.muxed_address + { return false; } let (asset_code, asset_issuer) = crate::submit_validation::asset_parts(&p.asset); diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml new file mode 100644 index 0000000..f05cc02 --- /dev/null +++ b/crates/chain/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "octo-chain" +description = "Minimal ChainAdapter boundary for deposit-address derivation (Stellar muxed vs EVM HD). Scoped to issue #220; the full trait (capabilities, registry, CAIP-2 identity) belongs to the chain-abstraction issue." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +octo-crypto.workspace = true +octo-wallet-core.workspace = true +octo-evm-core.workspace = true +thiserror.workspace = true diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs new file mode 100644 index 0000000..8f92825 --- /dev/null +++ b/crates/chain/src/lib.rs @@ -0,0 +1,188 @@ +//! A minimal `ChainAdapter` boundary, scoped to what issue #220 (EVM deposit-address allocation) +//! needs: one trait method, `derive_deposit_address`, plus a Stellar and an EVM implementation. +//! +//! This is deliberately **not** the full abstraction described for the chain-abstraction epic +//! (capabilities queries, a `ChainRegistry`, CAIP-2 chain identity, `validate_address`, +//! `explain_failure`, object-safety for `Arc` in shared Axum state). That is a +//! separate, larger issue. Building it in full here would mean inventing answers to questions +//! (chain enumeration, RPC wiring, capability flags for chains that don't exist yet in this repo) +//! that issue owns, not this one. What's here is real and used, not a stub: [`StellarAdapter`] +//! forwards unchanged to `octo_wallet_core`, and [`EvmAdapter`] wraps the derivation this issue +//! adds in `octo-evm-core`. +#![forbid(unsafe_code)] + +use octo_crypto::{SealedSeed, MASTER_KEY_LEN}; + +/// Errors from a `ChainAdapter` operation. +#[derive(Debug, thiserror::Error)] +pub enum ChainError { + /// The sealed seed could not be opened (wrong key/context, or tampered). + #[error("seed decryption failed")] + SeedDecryption, + /// The requested derivation index/id was invalid or out of range for this chain. + #[error("invalid derivation index")] + InvalidIndex, + /// Deriving the address failed for a chain-specific reason. + #[error("address derivation failed")] + DerivationFailed, +} + +impl From for ChainError { + fn from(_: octo_wallet_core::WalletError) -> Self { + ChainError::DerivationFailed + } +} + +impl From for ChainError { + fn from(e: octo_evm_core::EvmError) -> Self { + match e { + octo_evm_core::EvmError::SeedDecryption => ChainError::SeedDecryption, + octo_evm_core::EvmError::InvalidDerivationIndex => ChainError::InvalidIndex, + _ => ChainError::DerivationFailed, + } + } +} + +/// A customer deposit address, in whichever shape its chain produces. Callers that need a +/// uniform "the string to hand the customer" can match on this; the API layer (which does need +/// chain-appropriate response shapes — see `crates/api/src/routes/addresses.rs`) matches on the +/// concrete variant so it can omit fields that don't apply (EVM has no `memo_id`). +#[derive(Debug, Clone)] +pub enum DepositAddress { + /// Stellar: muxed `M...` primary + `G...`+memo fallback. No on-chain account created, no + /// per-customer key — this is a pure encoding of `(base_account, id)`. + Stellar(octo_wallet_core::DepositAddress), + /// EVM: a real HD-derived EOA. The server holds the key material to re-derive (and, later, + /// sweep) this address — see `docs/threat-model.md`. + Evm(octo_evm_core::EvmDepositAddress), +} + +/// The chain-specific input a `ChainAdapter` needs to derive a deposit address. Stellar derives +/// from the wallet's already-public base account (no decryption needed — muxed addresses carry +/// no secret material); EVM derives from the wallet's sealed HD seed. +pub enum DeriveInput<'a> { + /// Stellar: the wallet's base `G...` account. + Stellar { base_account: &'a str }, + /// EVM: the wallet's sealed seed, opened under `master_key`/`context` only for the duration + /// of this call. + Evm { + master_key: &'a [u8; MASTER_KEY_LEN], + sealed: &'a SealedSeed, + context: &'a [u8], + }, +} + +/// The minimal chain boundary this issue needs: given the chain-appropriate input and a +/// caller-assigned id/index, produce the customer's deposit address. +pub trait ChainAdapter: Send + Sync { + /// Derive the deposit address for `id` (a muxed id for Stellar, a BIP-44 index for EVM). + /// Implementations must be **pure and deterministic**: the same input always yields the same + /// address, so the caller's stored `(input, id)` is sufficient for disaster recovery. + fn derive_deposit_address( + &self, + input: DeriveInput<'_>, + id: u64, + ) -> Result; +} + +/// Forwards to `octo_wallet_core::deposit_address`, unchanged — Stellar's muxed model needs no +/// new logic, only a place in this trait. +pub struct StellarAdapter; + +impl ChainAdapter for StellarAdapter { + fn derive_deposit_address( + &self, + input: DeriveInput<'_>, + id: u64, + ) -> Result { + let DeriveInput::Stellar { base_account } = input else { + return Err(ChainError::DerivationFailed); + }; + let addr = octo_wallet_core::deposit_address(base_account, id)?; + Ok(DepositAddress::Stellar(addr)) + } +} + +/// Wraps `octo_evm_core`'s HD derivation: opens the sealed seed and derives +/// `m/44'/60'/0'/0/{id}`. `id` must fit the BIP-44 non-hardened index space (`0..=2^31-1`). +pub struct EvmAdapter; + +impl ChainAdapter for EvmAdapter { + fn derive_deposit_address( + &self, + input: DeriveInput<'_>, + id: u64, + ) -> Result { + let DeriveInput::Evm { + master_key, + sealed, + context, + } = input + else { + return Err(ChainError::DerivationFailed); + }; + let index = u32::try_from(id).map_err(|_| ChainError::InvalidIndex)?; + let addr = octo_evm_core::deposit_address_from_sealed(master_key, sealed, context, index)?; + Ok(DepositAddress::Evm(addr)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use octo_crypto::master_key_from_slice; + + #[test] + fn stellar_adapter_matches_direct_call() { + let base = "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6"; + let via_adapter = StellarAdapter + .derive_deposit_address(DeriveInput::Stellar { base_account: base }, 42) + .unwrap(); + let direct = octo_wallet_core::deposit_address(base, 42).unwrap(); + let DepositAddress::Stellar(got) = via_adapter else { + panic!("expected Stellar variant"); + }; + assert_eq!(got, direct); + } + + #[test] + fn evm_adapter_matches_direct_call() { + let mk = master_key_from_slice(&[9u8; MASTER_KEY_LEN]).unwrap(); + let ctx = b"octo:eip155:11155111"; + let provisioned = octo_evm_core::provision_evm_wallet(&mk, ctx).unwrap(); + + let via_adapter = EvmAdapter + .derive_deposit_address( + DeriveInput::Evm { + master_key: &mk, + sealed: &provisioned.sealed, + context: ctx, + }, + 3, + ) + .unwrap(); + let direct = + octo_evm_core::deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, 3).unwrap(); + let DepositAddress::Evm(got) = via_adapter else { + panic!("expected Evm variant"); + }; + assert_eq!(got, direct); + } + + #[test] + fn evm_adapter_rejects_index_above_u32_range() { + let mk = master_key_from_slice(&[9u8; MASTER_KEY_LEN]).unwrap(); + let ctx = b"octo:eip155:11155111"; + let provisioned = octo_evm_core::provision_evm_wallet(&mk, ctx).unwrap(); + + let result = EvmAdapter.derive_deposit_address( + DeriveInput::Evm { + master_key: &mk, + sealed: &provisioned.sealed, + context: ctx, + }, + u64::from(u32::MAX) + 1, + ); + assert!(matches!(result, Err(ChainError::InvalidIndex))); + } +} diff --git a/crates/evm-core/Cargo.toml b/crates/evm-core/Cargo.toml new file mode 100644 index 0000000..13ff149 --- /dev/null +++ b/crates/evm-core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "octo-evm-core" +description = "BIP-32/BIP-44 secp256k1 derivation and EIP-55 addresses. The EVM sibling of octo-wallet-core; handles secret key material." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +octo-crypto.workspace = true +k256.workspace = true +sha3.workspace = true +sha2.workspace = true +hmac.workspace = true +tiny-bip39.workspace = true +zeroize.workspace = true +thiserror.workspace = true +serde.workspace = true +hex.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/evm-core/src/address.rs b/crates/evm-core/src/address.rs new file mode 100644 index 0000000..0913a3b --- /dev/null +++ b/crates/evm-core/src/address.rs @@ -0,0 +1,186 @@ +//! EVM address derivation (keccak256 of the uncompressed public key) and +//! [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed-case checksum encode/validate. + +use crate::error::EvmError; +use k256::elliptic_curve::sec1::ToEncodedPoint; +use k256::SecretKey; +use sha3::{Digest, Keccak256}; + +/// Derive the EIP-55 checksummed address for a 32-byte secp256k1 secret key. +pub fn address_from_secret(secret: &[u8; 32]) -> Result { + let sk = SecretKey::from_slice(secret).map_err(|_| EvmError::InvalidChildKey)?; + let uncompressed = sk.public_key().to_encoded_point(false); + // Uncompressed SEC1 point is 0x04 || X (32) || Y (32); the address hashes X||Y only. + let pubkey_bytes = uncompressed.as_bytes(); + debug_assert_eq!(pubkey_bytes.len(), 65); + let hash = Keccak256::digest(&pubkey_bytes[1..]); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&hash[12..]); + Ok(to_checksum(&addr)) +} + +/// Encode a raw 20-byte address as an EIP-55 mixed-case checksummed `0x...` string. +pub fn to_checksum(addr: &[u8; 20]) -> String { + let hex_lower = hex::encode(addr); + let hash = Keccak256::digest(hex_lower.as_bytes()); + let mut out = String::with_capacity(42); + out.push_str("0x"); + for (i, c) in hex_lower.chars().enumerate() { + if c.is_ascii_digit() { + out.push(c); + continue; + } + // Nibble i of the hash: high nibble for even i, low nibble for odd i. + let hash_byte = hash[i / 2]; + let nibble = if i % 2 == 0 { + hash_byte >> 4 + } else { + hash_byte & 0x0f + }; + if nibble >= 8 { + out.push(c.to_ascii_uppercase()); + } else { + out.push(c); + } + } + out +} + +/// Parse a `0x`-prefixed 40-hex-char address into raw bytes, without checking casing. +fn parse_hex_bytes(address: &str) -> Result<[u8; 20], EvmError> { + let hex_part = address.strip_prefix("0x").ok_or(EvmError::InvalidAddress)?; + if hex_part.len() != 40 { + return Err(EvmError::InvalidAddress); + } + let bytes = hex::decode(hex_part).map_err(|_| EvmError::InvalidAddress)?; + let mut out = [0u8; 20]; + out.copy_from_slice(&bytes); + Ok(out) +} + +/// Validate an address string per the EIP-55 rules: accepts all-lowercase, all-uppercase (of the +/// hex digits after `0x`), or a correctly-checksummed mixed case, and **rejects** an incorrect +/// mixed-case checksum. This is EIP-55's typo-detection property: a mixed-case string that isn't +/// the exact checksum encoding is far more likely a corrupted address than a stylistic choice, so +/// treating it as valid-but-different-case (as lowercasing-then-comparing would) throws away the +/// detection. +pub fn validate_address(address: &str) -> bool { + let Some(hex_part) = address.strip_prefix("0x") else { + return false; + }; + if hex_part.len() != 40 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + let is_all_lower = hex_part.chars().all(|c| !c.is_ascii_uppercase()); + let is_all_upper = hex_part.chars().all(|c| !c.is_ascii_lowercase()); + if is_all_lower || is_all_upper { + return true; + } + let Ok(bytes) = parse_hex_bytes(address) else { + return false; + }; + to_checksum(&bytes) == address +} + +/// Normalize an address to its lowercase form for storage-key comparison. Case is not +/// semantically meaningful on-chain (EIP-55 casing is a checksum, not a distinct address), so +/// lookups and uniqueness must key off this form — see `docs/deposit-model.md`. +pub fn to_lower(address: &str) -> String { + address.to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Official EIP-55 test vectors (all four from the spec's "Test Cases" section). + // https://github.com/ethereum/ercs/blob/master/ERCS/erc-55.md + const EIP55_VECTORS: [&str; 4] = [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + ]; + + #[test] + fn eip55_vectors_round_trip_and_validate() { + for &expected in &EIP55_VECTORS { + let bytes = parse_hex_bytes(expected).unwrap(); + assert_eq!(to_checksum(&bytes), expected); + assert!(validate_address(expected)); + } + } + + #[test] + fn all_lowercase_and_all_uppercase_are_valid() { + for &expected in &EIP55_VECTORS { + let lower = expected.to_ascii_lowercase(); + let upper = format!("0x{}", &expected[2..].to_ascii_uppercase()); + assert!(validate_address(&lower), "{lower} should validate"); + assert!(validate_address(&upper), "{upper} should validate"); + } + } + + #[test] + fn wrong_mixed_case_checksum_is_rejected() { + // Flip the case of exactly one alphabetic character in a valid checksummed address — + // this is precisely the typo EIP-55 exists to catch. + for &expected in &EIP55_VECTORS { + let mut chars: Vec = expected.chars().collect(); + let flip_pos = chars + .iter() + .position(|c| c.is_ascii_alphabetic()) + .expect("has a hex letter"); + chars[flip_pos] = if chars[flip_pos].is_ascii_uppercase() { + chars[flip_pos].to_ascii_lowercase() + } else { + chars[flip_pos].to_ascii_uppercase() + }; + let tampered: String = chars.into_iter().collect(); + assert!( + !validate_address(&tampered), + "{tampered} (from {expected}) must be rejected" + ); + } + } + + #[test] + fn malformed_addresses_are_rejected() { + assert!(!validate_address("not an address")); + assert!(!validate_address("0x1234")); // too short + assert!(!validate_address( + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAedFF" // too long + )); + assert!(!validate_address( + "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" // missing 0x + )); + assert!(!validate_address( + "0xZZAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" // non-hex chars + )); + } + + #[test] + fn to_lower_normalizes_case_only() { + for &expected in &EIP55_VECTORS { + let lower = expected.to_ascii_lowercase(); + assert_eq!(to_lower(expected), lower); + assert_eq!(to_lower(&lower), lower); + let upper = format!("0x{}", &expected[2..].to_ascii_uppercase()); + assert_eq!(to_lower(&upper), lower); + } + } + + // Cross-checked against eth-account (Ethereum Foundation reference implementation): the + // private key for m/44'/60'/0'/0/0 under mnemonic "test test test ... junk" (Hardhat's + // well-known test mnemonic) and its address. Kept independent of derive.rs (a hardcoded key + // here, not this crate's own derivation) so this test can't pass by mirroring the same bug. + #[test] + fn known_secret_derives_known_address() { + let bytes = hex::decode("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + .unwrap(); + let mut secret = [0u8; 32]; + secret.copy_from_slice(&bytes); + let addr = address_from_secret(&secret).unwrap(); + assert_eq!(addr, "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + } +} diff --git a/crates/evm-core/src/derive.rs b/crates/evm-core/src/derive.rs new file mode 100644 index 0000000..a7ebbdc --- /dev/null +++ b/crates/evm-core/src/derive.rs @@ -0,0 +1,345 @@ +//! BIP-32 secp256k1 key derivation, restricted to the path shape octo needs: +//! `m / 44' / 60' / 0' / {branch} / {index}`. +//! +//! Levels 0-2 (`44'`, `60'`, `0'`) are **hardened**; levels 3-4 (`branch`, `index`) are +//! **non-hardened**. That is standard BIP-44, and it is also the source of the single most +//! important security fact in this crate: +//! +//! > **With non-hardened derivation, the extended *public* key (xpub) at a given depth plus any +//! > one child *private* key is enough to recover every sibling private key at that depth.** The +//! > child private key `k_i = IL + k_par (mod n)` is a simple modular addition, and +//! > `IL = HMAC-SHA512(c_par, serP(K_par) || i)` is computable from public data alone (the parent +//! > **public** key `K_par` and chain code `c_par`). So an attacker who has the xpub for +//! > `m/44'/60'/0'/0` and recovers *one* leaked deposit-address private key can invert the +//! > addition to get `k_par`, and from there derive every other customer's deposit key on that +//! > wallet. +//! +//! Consequence: **the xpub for this branch must be treated as secret**, on par with a private +//! key, even though BIP-32 nominally calls it "public". octo never constructs or exposes an xpub +//! at all — every derivation here re-walks the path from the sealed seed — but any future code +//! that adds xpub-based (watch-only) derivation must carry this warning forward. See +//! `docs/threat-model.md`. +//! +//! We derive hardened, not from an xpub, so this crate only ever needs private-parent-key CKD +//! (BIP-32 "CKDpriv"); public-parent CKD ("CKDpub") is deliberately not implemented. + +use crate::error::EvmError; +use hmac::{Hmac, Mac}; +use k256::elliptic_curve::ff::PrimeField; +use k256::elliptic_curve::sec1::ToEncodedPoint; +use k256::{FieldBytes, Scalar, SecretKey}; +use sha2::Sha512; +use zeroize::Zeroizing; + +type HmacSha512 = Hmac; + +/// BIP-44 purpose. +const PURPOSE: u32 = 44; +/// Ethereum's SLIP-44 coin type. +const EVM_COIN_TYPE: u32 = 60; +/// Hardened-derivation offset (bit 31 set). +const HARDENED: u32 = 0x8000_0000; +/// Upper bound (inclusive) of the non-hardened index space: `2^31 - 1`. +pub const MAX_NON_HARDENED_INDEX: u32 = HARDENED - 1; + +/// The BIP-44 "external" (deposit-facing) branch: `m/44'/60'/0'/0/i`. This is the only branch +/// customer deposit addresses are ever allocated from. +pub const DEPOSIT_BRANCH: u32 = 0; +/// The BIP-44 "internal" (change) branch: `m/44'/60'/0'/1/i`. Never handed out as a customer +/// deposit address; used only for the wallet's own identity address (index 0). +pub const IDENTITY_BRANCH: u32 = 1; + +/// A raw BIP-32 extended private key: a 32-byte secret scalar plus its 32-byte chain code. +/// Zeroized on drop. +struct ExtendedKey { + secret: Zeroizing<[u8; 32]>, + chain_code: Zeroizing<[u8; 32]>, +} + +/// A BIP39 seed, held only long enough to derive from and then dropped/zeroized. +pub struct EvmSeed(Zeroizing>); + +impl EvmSeed { + /// Wrap raw BIP39 seed bytes (e.g. decrypted from a [`octo_crypto::SealedSeed`]). + /// + /// The same 64-byte seed that produces a wallet's Stellar SEP-0005 keys can be reused here: + /// the coin-type level of the path (`60'` vs `148'`) keeps the two derivation trees disjoint. + pub fn from_bytes(bytes: Vec) -> EvmSeed { + EvmSeed(Zeroizing::new(bytes)) + } + + /// Reconstruct from an existing BIP39 mnemonic phrase (recovery / re-import). + pub fn from_phrase(phrase: &str) -> Result { + let mnemonic = bip39::Mnemonic::from_phrase(phrase, bip39::Language::English) + .map_err(|_| EvmError::InvalidMnemonic)?; + let seed = bip39::Seed::new(&mnemonic, ""); + Ok(EvmSeed(Zeroizing::new(seed.as_bytes().to_vec()))) + } + + /// Generate a fresh 12-word mnemonic and its seed. + pub fn generate() -> (Zeroizing, EvmSeed) { + let mnemonic = bip39::Mnemonic::new(bip39::MnemonicType::Words12, bip39::Language::English); + let phrase = Zeroizing::new(mnemonic.phrase().to_string()); + let seed = bip39::Seed::new(&mnemonic, ""); + (phrase, EvmSeed(Zeroizing::new(seed.as_bytes().to_vec()))) + } + + /// Borrow the raw seed bytes (crate-private: callers derive, they don't read). + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// BIP-32 master key: `HMAC-SHA512(key = "Bitcoin seed", data = seed)`. The key name is a + /// BIP-32 convention shared by every coin derived under it, not a Bitcoin-specific value. + fn master_key(&self) -> Result { + let mut mac = + HmacSha512::new_from_slice(b"Bitcoin seed").map_err(|_| EvmError::InvalidChildKey)?; + mac.update(&self.0); + split_i(&mac.finalize().into_bytes()) + } + + /// Derive the secret key at `m/44'/60'/0'/{branch}/{index}`. + /// + /// `branch` and `index` are non-hardened (`DEPOSIT_BRANCH`/`IDENTITY_BRANCH` select `branch`); + /// `44'`, `60'`, `0'` above them are hardened. See this module's doc comment for why that + /// split matters. + pub(crate) fn derive_secret( + &self, + branch: u32, + index: u32, + ) -> Result, EvmError> { + if index > MAX_NON_HARDENED_INDEX { + return Err(EvmError::InvalidDerivationIndex); + } + let m = self.master_key()?; + let purpose = ckd_priv_hardened(&m, PURPOSE)?; + let coin_type = ckd_priv_hardened(&purpose, EVM_COIN_TYPE)?; + let account = ckd_priv_hardened(&coin_type, 0)?; + let chain = ckd_priv_normal(&account, branch)?; + let leaf = ckd_priv_normal(&chain, index)?; + Ok(leaf.secret) + } +} + +/// Split a 64-byte HMAC-SHA512 output `I` into `(IL, IR)` = (secret, chain code). +fn split_i(i: &[u8]) -> Result { + if i.len() != 64 { + return Err(EvmError::InvalidChildKey); + } + let mut secret = [0u8; 32]; + let mut chain_code = [0u8; 32]; + secret.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + // IL must be a valid, nonzero scalar below the curve order — SecretKey::from_slice enforces + // that; reject rather than silently reduce, per BIP-32 §"Private parent key -> private child key". + SecretKey::from_slice(&secret).map_err(|_| EvmError::InvalidChildKey)?; + Ok(ExtendedKey { + secret: Zeroizing::new(secret), + chain_code: Zeroizing::new(chain_code), + }) +} + +/// CKDpriv, hardened case: `i' = i + 2^31`. `data = 0x00 || ser256(k_par) || ser32(i')`. +fn ckd_priv_hardened(parent: &ExtendedKey, index: u32) -> Result { + ckd_priv(parent, index | HARDENED, true) +} + +/// CKDpriv, non-hardened case: `data = serP(point(k_par)) || ser32(i)`. +fn ckd_priv_normal(parent: &ExtendedKey, index: u32) -> Result { + if index > MAX_NON_HARDENED_INDEX { + return Err(EvmError::InvalidDerivationIndex); + } + ckd_priv(parent, index, false) +} + +fn ckd_priv(parent: &ExtendedKey, index: u32, hardened: bool) -> Result { + let parent_secret = + SecretKey::from_slice(parent.secret.as_ref()).map_err(|_| EvmError::InvalidChildKey)?; + + let mut data = Vec::with_capacity(37); + if hardened { + data.push(0x00); + data.extend_from_slice(parent.secret.as_ref()); + } else { + let point = parent_secret.public_key().to_encoded_point(true); + data.extend_from_slice(point.as_bytes()); + } + data.extend_from_slice(&index.to_be_bytes()); + + let mut mac = HmacSha512::new_from_slice(parent.chain_code.as_ref()) + .map_err(|_| EvmError::InvalidChildKey)?; + mac.update(&data); + let i = mac.finalize().into_bytes(); + + let il = &i[..32]; + let ir = &i[32..]; + + let il_scalar: Scalar = Option::from(Scalar::from_repr(*FieldBytes::from_slice(il))) + .ok_or(EvmError::InvalidChildKey)?; + let parent_scalar = parent_secret.to_nonzero_scalar(); + let child_scalar = il_scalar + parent_scalar.as_ref(); + + let child_bytes: FieldBytes = child_scalar.into(); + // A zero sum (or, equivalently, a scalar SecretKey::from_slice rejects) is the "invalid key" + // case BIP-32 calls out as vanishingly rare (~2^-127). We surface it rather than silently + // deriving a different index — see EvmError::InvalidChildKey's doc comment. + SecretKey::from_slice(child_bytes.as_slice()).map_err(|_| EvmError::InvalidChildKey)?; + + let mut secret = [0u8; 32]; + secret.copy_from_slice(&child_bytes); + let mut chain_code = [0u8; 32]; + chain_code.copy_from_slice(ir); + + Ok(ExtendedKey { + secret: Zeroizing::new(secret), + chain_code: Zeroizing::new(chain_code), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // BIP-32 Test Vector 1, seed = 000102030405060708090a0b0c0d0e0f. + // https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vectors + // + // The BIP only publishes base58check-encoded xprv/xpub strings; the raw hex below is that + // xprv's 32-byte key field (bytes 46..78 of the base58check payload, after the version/ + // depth/fingerprint/child-number/chaincode header and the leading 0x00 key-type byte), + // decoded locally and cross-checked against the seed's well-known public xprv/xpub strings + // for "Chain m" and "Chain m/0H". + #[test] + fn bip32_vector1_master_and_hardened_child() { + let seed_bytes = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); + let seed = EvmSeed::from_bytes(seed_bytes); + let m = seed.master_key().unwrap(); + // xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi + assert_eq!( + hex::encode(m.secret.as_ref()), + "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35" + ); + assert_eq!( + hex::encode(m.chain_code.as_ref()), + "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508" + ); + let child = ckd_priv_hardened(&m, 0).unwrap(); + // xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7 + assert_eq!( + hex::encode(child.secret.as_ref()), + "edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea" + ); + assert_eq!( + hex::encode(child.chain_code.as_ref()), + "47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141" + ); + } + + // Known mnemonic -> m/44'/60'/0'/0/0 address, cross-checked against an independent + // implementation (ethers.js `HDNodeWallet.fromPhrase`). + const TEST_MNEMONIC: &str = "test test test test test test test test test test test junk"; + // ethers.js / hardhat's well-known "test test ... junk" mnemonic; account 0 is the + // widely-published 0xf39F... address used by Hardhat's default test network. + const EXPECTED_ADDR_0: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + #[test] + fn known_mnemonic_derives_expected_address() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let secret = seed.derive_secret(DEPOSIT_BRANCH, 0).unwrap(); + let addr = crate::address::address_from_secret(&secret).unwrap(); + assert_eq!(addr, EXPECTED_ADDR_0[..42]); + } + + // More indices from the same mnemonic, cross-checked against eth-account + // (`Account.from_mnemonic(mnemonic, account_path=f"m/44'/60'/0'/0/{i}")`) — these are also + // Hardhat's well-published default accounts #1, #2 and #9. + #[test] + fn known_mnemonic_derives_expected_addresses_at_other_indices() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let cases = [ + (1u32, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), + (2, "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + (9, "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"), + ]; + for (index, expected) in cases { + let secret = seed.derive_secret(DEPOSIT_BRANCH, index).unwrap(); + let addr = crate::address::address_from_secret(&secret).unwrap(); + assert_eq!(addr, expected, "mismatch at index {index}"); + } + } + + // Identity branch (m/44'/60'/0'/1/0), same cross-check method. + #[test] + fn identity_branch_matches_independent_implementation() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let secret = seed.derive_secret(IDENTITY_BRANCH, 0).unwrap(); + let addr = crate::address::address_from_secret(&secret).unwrap(); + assert_eq!(addr, "0x4b39F7b0624b9dB86AD293686bc38B903142dbBc"); + } + + #[test] + fn derivation_is_deterministic() { + let a = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let b = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let sa = a.derive_secret(DEPOSIT_BRANCH, 5).unwrap(); + let sb = b.derive_secret(DEPOSIT_BRANCH, 5).unwrap(); + assert_eq!(*sa, *sb); + } + + #[test] + fn distinct_indices_yield_distinct_keys() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let a = seed.derive_secret(DEPOSIT_BRANCH, 0).unwrap(); + let b = seed.derive_secret(DEPOSIT_BRANCH, 1).unwrap(); + assert_ne!(*a, *b); + } + + #[test] + fn deposit_and_identity_branches_diverge() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + let a = seed.derive_secret(DEPOSIT_BRANCH, 0).unwrap(); + let b = seed.derive_secret(IDENTITY_BRANCH, 0).unwrap(); + assert_ne!(*a, *b); + } + + #[test] + fn index_above_max_non_hardened_is_rejected() { + let seed = EvmSeed::from_phrase(TEST_MNEMONIC).unwrap(); + assert!(matches!( + seed.derive_secret(DEPOSIT_BRANCH, MAX_NON_HARDENED_INDEX + 1), + Err(EvmError::InvalidDerivationIndex) + )); + assert!(seed + .derive_secret(DEPOSIT_BRANCH, MAX_NON_HARDENED_INDEX) + .is_ok()); + } + + proptest::proptest! { + #[test] + fn derivation_is_deterministic_for_any_index( + entropy in proptest::prelude::any::<[u8; 16]>(), + index in 0u32..=MAX_NON_HARDENED_INDEX + ) { + let mnemonic = bip39::Mnemonic::from_entropy(&entropy, bip39::Language::English).unwrap(); + let seed_bytes = bip39::Seed::new(&mnemonic, "").as_bytes().to_vec(); + let a = EvmSeed::from_bytes(seed_bytes.clone()); + let b = EvmSeed::from_bytes(seed_bytes); + let sa = a.derive_secret(DEPOSIT_BRANCH, index).unwrap(); + let sb = b.derive_secret(DEPOSIT_BRANCH, index).unwrap(); + proptest::prop_assert_eq!(*sa, *sb); + } + + #[test] + fn distinct_indices_never_collide( + entropy in proptest::prelude::any::<[u8; 16]>(), + index_a in 0u32..=MAX_NON_HARDENED_INDEX, + index_b in 0u32..=MAX_NON_HARDENED_INDEX, + ) { + proptest::prop_assume!(index_a != index_b); + let mnemonic = bip39::Mnemonic::from_entropy(&entropy, bip39::Language::English).unwrap(); + let seed = EvmSeed::from_bytes(bip39::Seed::new(&mnemonic, "").as_bytes().to_vec()); + let sa = seed.derive_secret(DEPOSIT_BRANCH, index_a).unwrap(); + let sb = seed.derive_secret(DEPOSIT_BRANCH, index_b).unwrap(); + proptest::prop_assert_ne!(*sa, *sb); + } + } +} diff --git a/crates/evm-core/src/error.rs b/crates/evm-core/src/error.rs new file mode 100644 index 0000000..250a6b1 --- /dev/null +++ b/crates/evm-core/src/error.rs @@ -0,0 +1,41 @@ +//! Error type for evm-core. +//! +//! Like [`octo_wallet_core`](https://docs.rs/octo-wallet-core)'s `WalletError`, variants describe +//! the *kind* of failure without echoing key material. + +use thiserror::Error; + +/// Errors returned by evm-core operations. +#[derive(Debug, Error)] +pub enum EvmError { + /// The supplied BIP39 mnemonic phrase was invalid. + #[error("invalid mnemonic phrase")] + InvalidMnemonic, + + /// A non-hardened BIP-44 index was outside `0..=2^31-1`. + #[error("derivation index out of range")] + InvalidDerivationIndex, + + /// The vanishingly rare BIP-32 case where a derived child key is invalid (the arithmetic + /// produced zero, or a value outside the curve order). Per spec, the caller should treat this + /// index as unusable; we do not silently substitute a different index because that would + /// break the "stored index reproduces this address" recovery guarantee. + #[error("child key derivation produced an invalid key")] + InvalidChildKey, + + /// An address string was not a well-formed `0x`-prefixed 20-byte hex address, or a + /// mixed-case address failed its EIP-55 checksum (a likely typo). + #[error("invalid EVM address")] + InvalidAddress, + + /// Decrypting the sealed seed failed (wrong key/context or tampered record). + #[error("seed decryption failed")] + SeedDecryption, +} + +impl From for EvmError { + fn from(_: octo_crypto::CryptoError) -> Self { + // Collapse all crypto failures to a single coarse variant — do not leak which. + EvmError::SeedDecryption + } +} diff --git a/crates/evm-core/src/lib.rs b/crates/evm-core/src/lib.rs new file mode 100644 index 0000000..3c590d5 --- /dev/null +++ b/crates/evm-core/src/lib.rs @@ -0,0 +1,193 @@ +//! BIP-32/BIP-44 secp256k1 derivation and EIP-55 addresses for EVM chains — the EVM sibling of +//! [`octo_wallet_core`], which stays Stellar-only. This is the only crate that handles EVM secret +//! key material; decrypted seeds and derived keys are zeroized after use. +//! +//! ## Why EVM needs this crate at all +//! +//! Stellar customer deposit addresses cost nothing: a muxed address is the base account's public +//! key plus a 64-bit id, so allocating one is pure arithmetic (see `docs/deposit-model.md`). EVM +//! has no muxed-account equivalent — every customer needs a real, distinct, HD-derived externally +//! owned account (EOA) at `m/44'/60'/0'/0/{index}`. That means: +//! +//! - The server must hold key material capable of *deriving* (and later, spending from) every +//! deposit address — a real departure from Stellar's non-custodial posture, narrowly permitted +//! by AD-4. See `docs/threat-model.md`. +//! - **Non-hardened derivation is a package deal**: it's what makes `m/44'/60'/0'/0/{index}` cheap +//! to re-derive on demand (no watch-only xpub infrastructure needed), but it also means the +//! xpub for that branch plus any one leaked child key recovers every sibling key. See +//! [`derive`]'s module doc for the exact mechanism. +//! +//! ## Modules +//! - [`derive`] — BIP-32 HD derivation restricted to `m/44'/60'/0'/{branch}/{index}`. +//! - [`address`] — keccak256 pubkey → address, EIP-55 checksum encode/validate. +//! +//! No signing/relay support here — that belongs to the outbound-transfer and sweep-engine issues. +//! This crate only derives. +#![forbid(unsafe_code)] +// Secret-handling crate: a panic could surface key material in a backtrace, and lossy/sign +// conversions on amounts are bugs. Deny them (tests may unwrap/panic freely). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + +pub mod address; +pub mod derive; +mod error; + +pub use derive::{EvmSeed, DEPOSIT_BRANCH, IDENTITY_BRANCH, MAX_NON_HARDENED_INDEX}; +pub use error::EvmError; + +use octo_crypto::{seal, SealedSeed, MASTER_KEY_LEN}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +/// A customer deposit address on an EVM chain: the EIP-55 checksummed form (for display) plus +/// the BIP-44 index it was derived at (stored so the address is re-derivable from the seed alone +/// — see [`derive`]'s module doc on why the index, not just the address, must be persisted). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvmDepositAddress { + /// EIP-55 checksummed form, e.g. `0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed`. + pub address: String, + /// The BIP-44 non-hardened index this address was derived at (`0..=2^31-1`). + pub derivation_index: u32, +} + +/// Derive the deposit address for `index` on the customer-facing branch +/// (`m/44'/60'/0'/0/{index}`). +pub fn derive_deposit_address(seed: &EvmSeed, index: u32) -> Result { + let secret = seed.derive_secret(DEPOSIT_BRANCH, index)?; + let address = address::address_from_secret(&secret)?; + Ok(EvmDepositAddress { + address, + derivation_index: index, + }) +} + +/// Open a sealed seed and derive the deposit address for `index`, without ever persisting the +/// decrypted seed. Mirrors [`octo_wallet_core::signer::account_id_from_sealed`]'s +/// open-derive-drop pattern: the same stored `(sealed_ciphertext, sealed_nonce, sealed_salt, +/// derivation_index)` always reproduces the same address, which is the disaster-recovery +/// guarantee this crate exists to provide. +pub fn deposit_address_from_sealed( + master_key: &[u8; MASTER_KEY_LEN], + sealed: &SealedSeed, + context: &[u8], + index: u32, +) -> Result { + let plaintext = octo_crypto::open(master_key, sealed, context)?; + let seed = EvmSeed::from_bytes(plaintext.to_vec()); + derive_deposit_address(&seed, index) +} + +/// The result of provisioning a new EVM HD wallet: its own identity address (derived on the +/// BIP-44 *change* branch, `m/44'/60'/0'/1/0` — never handed out as a customer deposit address), +/// the sealed seed to persist, and the one-time recovery mnemonic. +pub struct ProvisionedEvmWallet { + /// The wallet's own address, `m/44'/60'/0'/1/0`. Used only to satisfy the `wallets` table's + /// existing unique-identity column; it is not, and must never become, a deposit address. + pub identity_address: String, + /// The AES-256-GCM-sealed seed to store at rest. + pub sealed: SealedSeed, + /// The BIP39 mnemonic — the backup secret. Show once, never persist in plaintext. + pub mnemonic: Zeroizing, +} + +/// Generate a brand-new EVM HD wallet, sealed under `context` (e.g. `b"octo:eip155:1"` — must be +/// distinct per chain so a seed sealed for one EVM chain cannot be opened under another, the same +/// AAD-binding property `octo_wallet_core::signer::StellarNetwork::crypto_context` relies on). +pub fn provision_evm_wallet( + master_key: &[u8; MASTER_KEY_LEN], + context: &[u8], +) -> Result { + let (mnemonic, seed) = EvmSeed::generate(); + let identity_secret = seed.derive_secret(IDENTITY_BRANCH, 0)?; + let identity_address = address::address_from_secret(&identity_secret)?; + let sealed = seal(master_key, seed_bytes_for_sealing(&seed), context)?; + Ok(ProvisionedEvmWallet { + identity_address, + sealed, + mnemonic, + }) +} + +/// Re-provision from an existing mnemonic (recovery / import). +pub fn import_evm_wallet( + master_key: &[u8; MASTER_KEY_LEN], + context: &[u8], + mnemonic: &str, +) -> Result { + let seed = EvmSeed::from_phrase(mnemonic)?; + let identity_secret = seed.derive_secret(IDENTITY_BRANCH, 0)?; + let identity_address = address::address_from_secret(&identity_secret)?; + let sealed = seal(master_key, seed_bytes_for_sealing(&seed), context)?; + Ok(ProvisionedEvmWallet { + identity_address, + sealed, + mnemonic: Zeroizing::new(mnemonic.to_string()), + }) +} + +/// Borrow the raw seed bytes for sealing. Kept to one call site so it's obvious the only place +/// seed bytes leave [`EvmSeed`] in plaintext is immediately into [`seal`]. +fn seed_bytes_for_sealing(seed: &EvmSeed) -> &[u8] { + seed.as_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + use octo_crypto::master_key_from_slice; + + fn master_key() -> [u8; MASTER_KEY_LEN] { + master_key_from_slice(&[7u8; MASTER_KEY_LEN]).unwrap() + } + + #[test] + fn provision_then_derive_is_deterministic_from_sealed_form() { + let mk = master_key(); + let ctx = b"octo:eip155:11155111"; + let provisioned = provision_evm_wallet(&mk, ctx).unwrap(); + + let a = deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, 0).unwrap(); + let b = deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, 0).unwrap(); + assert_eq!(a, b); + + // Re-importing from the recovery mnemonic must reproduce the exact same addresses — + // this is the disaster-recovery guarantee. + let reimported = import_evm_wallet(&mk, ctx, &provisioned.mnemonic).unwrap(); + assert_eq!(reimported.identity_address, provisioned.identity_address); + let c = deposit_address_from_sealed(&mk, &reimported.sealed, ctx, 0).unwrap(); + assert_eq!(a, c); + } + + #[test] + fn identity_address_is_never_a_deposit_address() { + let mk = master_key(); + let ctx = b"octo:eip155:11155111"; + let provisioned = provision_evm_wallet(&mk, ctx).unwrap(); + for i in 0..20u32 { + let deposit = deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, i).unwrap(); + assert_ne!(deposit.address, provisioned.identity_address); + } + } + + #[test] + fn sealed_for_one_chain_context_does_not_open_under_another() { + let mk = master_key(); + let provisioned = provision_evm_wallet(&mk, b"octo:eip155:1").unwrap(); + assert!(matches!( + deposit_address_from_sealed(&mk, &provisioned.sealed, b"octo:eip155:11155111", 0), + Err(EvmError::SeedDecryption) + )); + } + + #[test] + fn distinct_indexes_give_distinct_addresses() { + let mk = master_key(); + let ctx = b"octo:eip155:11155111"; + let provisioned = provision_evm_wallet(&mk, ctx).unwrap(); + let a = deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, 0).unwrap(); + let b = deposit_address_from_sealed(&mk, &provisioned.sealed, ctx, 1).unwrap(); + assert_ne!(a.address, b.address); + } +} diff --git a/crates/ingest/tests/adversarial_replay_tests.rs b/crates/ingest/tests/adversarial_replay_tests.rs index 4dfdb65..9b6b88d 100644 --- a/crates/ingest/tests/adversarial_replay_tests.rs +++ b/crates/ingest/tests/adversarial_replay_tests.rs @@ -127,7 +127,7 @@ async fn build_pool(store: &Store, wallet_id: Uuid, n: usize) -> Vec PaymentRecord { diff --git a/crates/store/migrations/0021_evm_deposit_addresses.sql b/crates/store/migrations/0021_evm_deposit_addresses.sql new file mode 100644 index 0000000..ef980fc --- /dev/null +++ b/crates/store/migrations/0021_evm_deposit_addresses.sql @@ -0,0 +1,81 @@ +-- EVM per-customer deposit addresses via HD derivation (see docs/deposit-model.md). +-- +-- Stellar muxed addresses cost nothing on-chain: a customer address is just (base account, id), +-- so `next_muxed_id` + `addresses.muxed_id`/`muxed_address` was the whole model. EVM has no +-- muxed-account equivalent — each customer needs a real, distinct, HD-derived EOA at +-- m/44'/60'/0'/0/{index}. This migration generalises `wallets`/`addresses` just enough to support +-- that second allocation strategy, side by side with the unchanged Stellar one. +-- +-- This is intentionally NOT the full multi-chain schema (a `chains` registry table, chain-scoped +-- transaction dedup, generalised network/network naming) — that is a separate, larger migration. +-- Scope here is exactly what EVM deposit-address allocation needs. + +-- --------------------------------------------------------------------------- +-- wallets: which chain kind a wallet belongs to, and the EVM allocation counter. +-- --------------------------------------------------------------------------- + +ALTER TABLE wallets ADD COLUMN chain_kind TEXT NOT NULL DEFAULT 'stellar' + CHECK (chain_kind IN ('stellar', 'evm')); + +-- CAIP-2 chain id (e.g. 'eip155:11155111'). NULL for Stellar wallets (identified by `network` +-- alone); required for EVM wallets so a future adapter/RPC lookup has something to key on, and so +-- the sealed seed's AAD context (bound to this id — see octo-evm-core) is recoverable from the row. +ALTER TABLE wallets ADD COLUMN chain_id TEXT; +ALTER TABLE wallets ADD CONSTRAINT wallets_evm_has_chain_id + CHECK (chain_kind <> 'evm' OR chain_id IS NOT NULL); + +-- Deriving (and, later, sweeping) an EVM deposit address requires the sealed HD seed to live on +-- this row, so an EVM wallet must be server-custody. Combined with the pre-existing +-- wallets_server_custody_has_seed CHECK, this guarantees every EVM wallet carries a sealed seed. +ALTER TABLE wallets ADD CONSTRAINT wallets_evm_is_server_custody + CHECK (chain_kind <> 'evm' OR custody = 'server'); + +-- Next non-hardened BIP-44 index to hand out at m/44'/60'/0'/0/{index}. The column is BIGINT so +-- overflow is caught long before the 2^31-1 non-hardened ceiling (enforced below on +-- addresses.derivation_index) could ever wrap it. +ALTER TABLE wallets ADD COLUMN next_derivation_index BIGINT NOT NULL DEFAULT 0 + CHECK (next_derivation_index >= 0); + +-- --------------------------------------------------------------------------- +-- addresses: relax the muxed-only shape and add the EVM shape. +-- --------------------------------------------------------------------------- + +-- EVM rows have neither a muxed id nor a muxed address. +ALTER TABLE addresses ALTER COLUMN muxed_id DROP NOT NULL; +ALTER TABLE addresses ALTER COLUMN muxed_address DROP NOT NULL; + +-- BIP-44 non-hardened index, 0..=2^31-1 (2147483647). +ALTER TABLE addresses ADD COLUMN derivation_index BIGINT + CHECK (derivation_index IS NULL OR derivation_index BETWEEN 0 AND 2147483647); + +-- EIP-55 checksummed form, stored for display. +ALTER TABLE addresses ADD COLUMN evm_address TEXT; + +-- Lowercased form, generated so it can never drift from evm_address. Lookups and the uniqueness +-- constraint key off THIS column, not evm_address, so a client sending an all-lowercase or +-- all-uppercase address still matches the checksummed row (case is a checksum, not part of the +-- address's identity) — see docs/deposit-model.md. +ALTER TABLE addresses ADD COLUMN evm_address_lower TEXT + GENERATED ALWAYS AS (lower(evm_address)) STORED; + +-- A row is either fully Stellar-shaped or fully EVM-shaped, never a mix and never neither. +ALTER TABLE addresses ADD CONSTRAINT addresses_chain_shape CHECK ( + (muxed_id IS NOT NULL AND muxed_address IS NOT NULL + AND evm_address IS NULL AND derivation_index IS NULL) + OR + (evm_address IS NOT NULL AND derivation_index IS NOT NULL + AND muxed_id IS NULL AND muxed_address IS NULL) +); + +-- Case-insensitive global uniqueness (NULLs — i.e. Stellar rows — are distinct from each other in +-- a standard Postgres UNIQUE index, so this constrains only EVM rows). +ALTER TABLE addresses ADD CONSTRAINT uq_addresses_evm_address_lower UNIQUE (evm_address_lower); + +-- Per-wallet index uniqueness is redundant with the row-lock in Store::allocate_evm_address (two +-- concurrent allocations can never observe the same next_derivation_index), but is cheap +-- defense-in-depth against a future caller that bypasses that path. +ALTER TABLE addresses ADD CONSTRAINT uq_addresses_wallet_derivation_index + UNIQUE (wallet_id, derivation_index); + +CREATE INDEX idx_addresses_evm_address_lower ON addresses(evm_address_lower) + WHERE evm_address_lower IS NOT NULL; diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index ffba872..77fe692 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -29,6 +29,12 @@ pub enum StoreError { /// An OTP was wrong, expired, already used, over the attempt limit, or tx-hash mismatched. #[error("invalid or expired code")] InvalidOtp, + + /// An EVM wallet's `next_derivation_index` exceeded BIP-44's non-hardened index space + /// (`2^31 - 1`). Allocating a further address for this wallet requires a new account branch, + /// which is out of scope for automatic handling. + #[error("derivation index space exhausted")] + DerivationIndexExhausted, } impl StoreError { diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 97f981a..2b76c7e 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -50,6 +50,30 @@ pub struct NewWallet<'a> { pub description: Option<&'a str>, } +/// Parameters for creating an EVM HD wallet (see `migrations/0021_evm_deposit_addresses.sql`). +/// +/// Always server-custody: deriving (and later sweeping) customer deposit addresses requires the +/// sealed HD seed to live on this row — see `docs/threat-model.md` for the exception this is to +/// octo's normal non-custodial posture. +pub struct NewEvmWallet<'a> { + pub network: &'a str, + /// CAIP-2 chain id, e.g. `"eip155:11155111"`. + pub chain_id: &'a str, + /// The wallet's own identity address (`m/44'/60'/0'/1/0`) — reuses the `stellar_account_g` + /// column's existing NOT NULL + UNIQUE constraints as a chain-agnostic "wallet identity" + /// slot. Never a customer deposit address (those come only from the `0` branch). A proper + /// rename belongs to a full multi-chain schema migration; see `docs/deposit-model.md`. + pub identity_address: &'a str, + pub sealed_ciphertext: &'a [u8], + pub sealed_nonce: &'a [u8], + pub sealed_salt: &'a [u8], + /// Scheme version tag for the sealed seed. Use `octo_crypto::SCHEME_V1`. + pub sealed_scheme: i16, + pub label: Option<&'a str>, + pub user_id: Option, + pub description: Option<&'a str>, +} + /// Parameters for creating a non-custodial (client-custody) wallet: the client generated the /// keypair and sends only the public account plus an opaque password-encrypted backup blob the /// server cannot decrypt. @@ -372,6 +396,33 @@ impl Store { .map_err(StoreError::from_sqlx_conflict) } + /// Create a server-custody EVM HD wallet. See [`NewEvmWallet`] for why this is always + /// server-custody. + pub async fn create_evm_wallet(&self, new: NewEvmWallet<'_>) -> Result { + sqlx::query_as::<_, Wallet>( + r#" + INSERT INTO wallets + (network, stellar_account_g, chain_kind, chain_id, sealed_ciphertext, + sealed_nonce, sealed_salt, sealed_scheme, label, user_id, description, custody) + VALUES ($1, $2, 'evm', $3, $4, $5, $6, $7, $8, $9, $10, 'server') + RETURNING * + "#, + ) + .bind(new.network) + .bind(new.identity_address) + .bind(new.chain_id) + .bind(new.sealed_ciphertext) + .bind(new.sealed_nonce) + .bind(new.sealed_salt) + .bind(new.sealed_scheme) + .bind(new.label) + .bind(new.user_id) + .bind(new.description) + .fetch_one(&self.pool) + .await + .map_err(StoreError::from_sqlx_conflict) + } + /// Attach a gas-tank fee account to a client-custody wallet: stores the tank's sealed seed /// and public account. The tank only ever holds fee float — never customer funds. /// @@ -702,6 +753,73 @@ impl Store { Ok(address) } + /// Atomically allocate the next BIP-44 derivation index for an EVM `wallet_id` and insert the + /// address row. + /// + /// Mirrors [`allocate_address`](Self::allocate_address) exactly — same transaction + row-lock + /// pattern, bumping `next_derivation_index` instead of `next_muxed_id` — so two concurrent + /// callers always get distinct indexes and never collide. `evm_address_for` receives the + /// index and returns the EIP-55 checksummed address (e.g. via `octo_evm_core`); the + /// lowercased lookup form is derived by the database, not passed in, so it can never drift. + pub async fn allocate_evm_address( + &self, + wallet_id: Uuid, + evm_address_for: impl FnOnce(u32) -> Result, + customer_ref: Option<&str>, + metadata: serde_json::Value, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // Lock the wallet row and read+bump the counter. + let next_index: i64 = sqlx::query_scalar( + "SELECT next_derivation_index FROM wallets WHERE id = $1 FOR UPDATE", + ) + .bind(wallet_id) + .fetch_optional(&mut *tx) + .await? + .ok_or(StoreError::NotFound)?; + + // BIP-44's non-hardened index level is bounded at 2^31 - 1; the schema CHECK on + // addresses.derivation_index enforces the same bound, but we surface a specific error + // here rather than letting an out-of-range wallet fail obscurely on the INSERT. (Store + // deliberately does not depend on octo-evm-core — same separation as the Stellar path, + // which never depends on octo-wallet-core — so this bound is restated, not imported.) + const MAX_NON_HARDENED_INDEX: u32 = 0x7fff_ffff; + let index_u32 = u32::try_from(next_index) + .ok() + .filter(|&i| i <= MAX_NON_HARDENED_INDEX) + .ok_or(StoreError::DerivationIndexExhausted)?; + + sqlx::query( + "UPDATE wallets SET next_derivation_index = next_derivation_index + 1, \ + updated_at = now() WHERE id = $1", + ) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let evm_address = evm_address_for(index_u32).map_err(|_| StoreError::NotFound)?; + + let address = sqlx::query_as::<_, Address>( + r#" + INSERT INTO addresses (wallet_id, derivation_index, evm_address, customer_ref, metadata) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(wallet_id) + .bind(next_index) + .bind(&evm_address) + .bind(customer_ref) + .bind(metadata) + .fetch_one(&mut *tx) + .await + .map_err(StoreError::from_sqlx_conflict)?; + + tx.commit().await?; + Ok(address) + } + /// List addresses for a wallet (most recent first), with optional cursor-based pagination. pub async fn list_addresses( &self, @@ -780,6 +898,22 @@ impl Store { Ok(row) } + /// Find the address for a given EVM address, matching case-insensitively (the caller may + /// send any casing — lowercase, uppercase, or EIP-55 checksummed — and all resolve to the + /// same row via the `evm_address_lower` generated column). + pub async fn address_by_evm_address( + &self, + address: &str, + ) -> Result, StoreError> { + let row = sqlx::query_as::<_, Address>( + "SELECT * FROM addresses WHERE evm_address_lower = lower($1)", + ) + .bind(address) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + // --- transactions (deposits) ------------------------------------------ /// Idempotently record a confirmed deposit. diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index 8f8f8a5..fc08ea8 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -35,6 +35,15 @@ pub struct Wallet { pub custody: String, pub encrypted_backup: Option, pub gas_tank_account_g: Option, + /// `"stellar"` or `"evm"` (see `migrations/0021_evm_deposit_addresses.sql`). Determines which + /// half of `Store::allocate_address` / `allocate_evm_address` this wallet uses. + pub chain_kind: String, + /// CAIP-2 chain id (e.g. `"eip155:11155111"`). `None` for Stellar wallets; always `Some` for + /// `chain_kind == "evm"` (enforced by `wallets_evm_has_chain_id`). + pub chain_id: Option, + /// Next non-hardened BIP-44 index to hand out at `m/44'/60'/0'/0/{index}`. The EVM analogue + /// of `next_muxed_id`; meaningless for Stellar wallets. + pub next_derivation_index: i64, pub created_at: DateTime, pub updated_at: DateTime, } @@ -44,15 +53,35 @@ impl Wallet { pub fn is_client_custody(&self) -> bool { self.custody == "client" } + + /// True for an EVM (HD-derived-EOA) wallet, false for Stellar (muxed-address) wallets. + pub fn is_evm(&self) -> bool { + self.chain_kind == "evm" + } } /// A per-customer deposit address (off-chain row). +/// +/// Exactly one of the two shapes below is populated, never both and never neither — enforced by +/// the `addresses_chain_shape` CHECK constraint: +/// - Stellar: `muxed_id` + `muxed_address` (see `docs/deposit-model.md`'s muxed model). +/// - EVM: `derivation_index` + `evm_address` (+ generated `evm_address_lower`) — a real HD-derived +/// EOA (see `docs/deposit-model.md`'s EVM model). #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct Address { pub id: Uuid, pub wallet_id: Uuid, - pub muxed_id: i64, - pub muxed_address: String, + pub muxed_id: Option, + pub muxed_address: Option, + /// BIP-44 non-hardened index (`0..=2^31-1`) this address was derived at. `None` for Stellar + /// rows. Stored (not just the address) so the address is re-derivable from seed + index alone + /// for disaster recovery. + pub derivation_index: Option, + /// EIP-55 checksummed form, for display. `None` for Stellar rows. + pub evm_address: Option, + /// Lowercased form of `evm_address` — a generated column, so it can never drift. Lookups key + /// off this, not `evm_address`, so a client sending any casing still resolves to this row. + pub evm_address_lower: Option, pub customer_ref: Option, pub metadata: serde_json::Value, pub created_at: DateTime, diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 9b047f6..7dc422a 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -8,7 +8,8 @@ //! green). If a URL is found but the DB is unreachable, the test fails loudly with the reason. use octo_store::{ - NewDeposit, NewPaymentLink, NewSponsoredTx, NewWallet, NewWithdrawal, Store, StoreError, + NewDeposit, NewEvmWallet, NewPaymentLink, NewSponsoredTx, NewWallet, NewWithdrawal, Store, + StoreError, }; use std::sync::Once; use uuid::Uuid; @@ -96,8 +97,8 @@ async fn allocate_address_increments_atomically() { .await .expect("alloc b"); - assert_eq!(a.muxed_id, 1); - assert_eq!(b.muxed_id, 2); + assert_eq!(a.muxed_id, Some(1)); + assert_eq!(b.muxed_id, Some(2)); assert_ne!(a.muxed_address, b.muxed_address); let list = store @@ -107,6 +108,195 @@ async fn allocate_address_increments_atomically() { assert_eq!(list.len(), 2); } +// --- EVM deposit addresses (issue #220) ------------------------------------------------------ +// +// These tests deliberately do NOT depend on octo-evm-core (store never depends on wallet-core +// either — see fresh_wallet's fake muxed encoding above), so the derive closure just returns a +// fake-but-validly-shaped `0x...` string. Real BIP-44/EIP-55 correctness is covered in +// crates/evm-core's own test suite (BIP-32 spec vectors, EIP-55 spec vectors, and cross-checks +// against an independent implementation); what belongs here is the STORE's contract: atomic +// index allocation and case-insensitive lookup. + +/// Create a throwaway EVM wallet with a unique identity address (so tests don't collide). +async fn fresh_evm_wallet(store: &Store) -> Uuid { + let identity = format!("0x{:040x}", Uuid::new_v4().as_u128()); + let w = store + .create_evm_wallet(NewEvmWallet { + network: "testnet", + chain_id: "eip155:11155111", + identity_address: &identity, + sealed_ciphertext: b"ciphertext", + sealed_nonce: b"nonce12bytes", + sealed_salt: b"saltsaltsaltsalt", + sealed_scheme: 1, + label: Some("test-evm"), + user_id: None, + description: None, + }) + .await + .expect("create evm wallet"); + w.id +} + +/// A fake-but-validly-shaped EIP-55-style address for a given index, unique per (wallet, index) +/// via a random prefix — good enough for exercising store-level allocation/lookup, not real +/// derivation. +fn fake_evm_address(salt: u128, index: u32) -> String { + format!("0x{salt:032x}{index:08x}") +} + +#[tokio::test] +async fn allocate_evm_address_increments_atomically() { + let Some(store) = store().await else { return }; + let wallet_id = fresh_evm_wallet(&store).await; + let salt = Uuid::new_v4().as_u128(); + + let a = store + .allocate_evm_address( + wallet_id, + |index| Ok(fake_evm_address(salt, index)), + Some("user-a"), + serde_json::json!({}), + ) + .await + .expect("alloc a"); + let b = store + .allocate_evm_address( + wallet_id, + |index| Ok(fake_evm_address(salt, index)), + Some("user-b"), + serde_json::json!({}), + ) + .await + .expect("alloc b"); + + assert_eq!(a.derivation_index, Some(0)); + assert_eq!(b.derivation_index, Some(1)); + assert_ne!(a.evm_address, b.evm_address); + // The EVM shape, not the Stellar shape. + assert_eq!(a.muxed_id, None); + assert_eq!(a.muxed_address, None); + + let list = store + .list_addresses(wallet_id, 100, None) + .await + .expect("list"); + assert_eq!(list.len(), 2); +} + +/// N concurrent allocations on ONE evm wallet must yield N distinct, gap-free indexes and N +/// distinct addresses — the atomicity guarantee `allocate_address` already gives Stellar, carried +/// over to the EVM sibling. Modeled on the row-lock pattern exercised by +/// `crates/ingest/tests/supervisor_concurrency_tests.rs`. +#[tokio::test] +async fn allocate_evm_address_concurrent_allocations_are_gap_free_and_unique() { + let Some(store) = store().await else { return }; + let wallet_id = fresh_evm_wallet(&store).await; + let salt = Uuid::new_v4().as_u128(); + + const N: usize = 25; + let mut handles = Vec::with_capacity(N); + for _ in 0..N { + let store = store.clone(); + handles.push(tokio::spawn(async move { + store + .allocate_evm_address( + wallet_id, + move |index| Ok(fake_evm_address(salt, index)), + None, + serde_json::json!({}), + ) + .await + .expect("concurrent alloc") + })); + } + + let mut indexes: Vec = Vec::with_capacity(N); + let mut addresses = std::collections::HashSet::with_capacity(N); + for h in handles { + let addr = h.await.expect("task join"); + indexes.push(addr.derivation_index.expect("evm address")); + addresses.insert(addr.evm_address.expect("evm address")); + } + + indexes.sort_unstable(); + let expected: Vec = (0..N as i64).collect(); + assert_eq!( + indexes, expected, + "expected exactly 0..{N} with no gaps or duplicates" + ); + assert_eq!(addresses.len(), N, "expected N distinct addresses"); +} + +/// Looking up a deposit address by its lowercase, uppercase, and original-cased forms all +/// resolve to the same row (the `evm_address_lower` generated column backs this). +#[tokio::test] +async fn evm_address_lookup_is_case_insensitive() { + let Some(store) = store().await else { return }; + let wallet_id = fresh_evm_wallet(&store).await; + let salt = Uuid::new_v4().as_u128(); + + let allocated = store + .allocate_evm_address( + wallet_id, + |index| Ok(fake_evm_address(salt, index)), + None, + serde_json::json!({}), + ) + .await + .expect("alloc"); + let original = allocated.evm_address.clone().expect("evm address"); + + let by_lower = store + .address_by_evm_address(&original.to_ascii_lowercase()) + .await + .expect("lookup lower") + .expect("found by lowercase"); + let by_upper = store + .address_by_evm_address(&original.to_ascii_uppercase()) + .await + .expect("lookup upper") + .expect("found by uppercase"); + let by_original = store + .address_by_evm_address(&original) + .await + .expect("lookup original") + .expect("found by original casing"); + + assert_eq!(by_lower.id, allocated.id); + assert_eq!(by_upper.id, allocated.id); + assert_eq!(by_original.id, allocated.id); +} + +/// A Stellar wallet's allocation behaviour is completely unchanged by any of the above: it still +/// gets muxed_id/muxed_address, never touches the EVM columns, and defaults to chain_kind = +/// "stellar". +#[tokio::test] +async fn stellar_wallet_allocation_is_unaffected_by_evm_support() { + let Some(store) = store().await else { return }; + let wallet_id = fresh_wallet(&store).await; + let wallet = store.get_wallet(wallet_id).await.expect("get wallet"); + assert_eq!(wallet.chain_kind, "stellar"); + assert_eq!(wallet.chain_id, None); + assert!(!wallet.is_evm()); + + let wid = wallet_id.simple(); + let addr = store + .allocate_address( + wallet_id, + |id| Ok(format!("M{wid}-{id}")), + Some("user-a"), + serde_json::json!({}), + ) + .await + .expect("alloc"); + + assert_eq!(addr.muxed_id, Some(1)); + assert!(addr.muxed_address.is_some()); + assert_eq!(addr.derivation_index, None); + assert_eq!(addr.evm_address, None); +} + #[tokio::test] async fn record_deposit_is_idempotent() { let Some(store) = store().await else { return }; @@ -850,13 +1040,13 @@ async fn migrate_applies_exactly_the_expected_version_set() { .expect("query _sqlx_migrations"); versions.sort_unstable(); - // One version per file under crates/store/migrations/, 0001_init.sql .. 0020. + // One version per file under crates/store/migrations/, 0001_init.sql .. 0021. // Guards against silent version collisions — sqlx keys migrations by version, so a repeated // number means only one of the colliding pair actually ran. assert_eq!( versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], - "expected exactly the twenty known migrations to be recorded as applied" + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], + "expected exactly the twenty-one known migrations to be recorded as applied" ); } diff --git a/docs/api.md b/docs/api.md index b18cace..92791c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,9 +65,15 @@ decrypt. Consequently: ## Addresses - `POST /v1/wallets/{id}/addresses` — generate a dedicated customer address. - Returns `muxed_address` (`M...`) **and** the `{ base_address, memo_id }` fallback. - `GET /v1/wallets/{id}/addresses` — list addresses (paginated). +Response shape depends on the wallet's chain (`chain_kind` is always present so a client can +branch without inspecting which optional fields exist — see `docs/deposit-model.md`): + +- **Stellar**: `muxed_address` (`M...`) **and** the `{ base_address, memo_id }` fallback. +- **EVM**: a single `address` (EIP-55 checksummed). `memo_id` is **omitted entirely**, not + `null` — EVM has nowhere for a memo to go, so there is nothing to send one to. + ## Gas sponsorship Lets you pay your users' Stellar fees. The **gas tank** is a separate, server-held account that diff --git a/docs/deposit-model.md b/docs/deposit-model.md index 7fa4677..e2d3bae 100644 --- a/docs/deposit-model.md +++ b/docs/deposit-model.md @@ -1,4 +1,4 @@ -# Deposit model: muxed-primary + memo fallback +# Deposit model: Stellar muxed-primary + EVM HD-derived ## The idea @@ -44,3 +44,113 @@ The `ingest` crate matches an incoming payment to a customer by: Both map to the same customer row. No data-model difference — we store the base account and the `u64` id once. + +--- + +# EVM model: HD-derived per-customer EOAs + +## Why muxed doesn't carry over + +A muxed address is mathematically `base account + id`, decoded by whoever controls the base +account. EVM chains have no equivalent encoding: an ERC-20 `Transfer` event names exactly one +`to` address, and there is no id field anywhere in the transfer to decode. **The only way to get a +customer-identifying destination on EVM is to give each customer a real, distinct on-chain +address.** + +So octo derives one externally-owned account (EOA) per customer, using [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)/[BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) +hierarchical deterministic (HD) derivation at: + +``` +m / 44' / 60' / 0' / 0 / {index} + purpose coin acct chg customer + type (0=deposit) +``` + +`60` is Ethereum's SLIP-44 coin type (the same derivation applies on any `eip155:*` chain — Base, +Arbitrum, etc. all use coin type 60 too). `index` is a per-wallet counter — the EVM analogue of +`next_muxed_id` — bumped atomically under the same row-lock pattern as the Stellar path (see +[`Store::allocate_evm_address`](../crates/store/src/lib.rs)). + +``` +Customer A → m/44'/60'/0'/0/0 → 0xAaAa... ┐ +Customer B → m/44'/60'/0'/0/1 → 0xBbBb... ├─► N distinct on-chain EOAs, one per customer +Customer C → m/44'/60'/0'/0/2 → 0xCcCc... ┘ +deposit to 0xBbBb... → the Transfer's `to` topic IS the customer identity, no decoding needed +``` + +## Economic differences from the muxed model + +| | Stellar (muxed) | EVM (HD-derived) | +|---|---|---| +| On-chain accounts created | 1 (the master), ever | N (customer-visible EOAs) — though allocation itself creates no on-chain transaction, since the address exists cryptographically the moment it's derived | +| Per-customer reserve | None | None to *allocate* — but see sweep, below | +| Funds land at | The single master account | Each customer's own address | +| Getting funds usable | Nothing further needed | Must be **swept** to a treasury (tracked separately — sweeping needs a design decision of its own, see below) | +| Gas for the sweep | N/A | The deposit address holds no native token, so the sweeper must either pre-fund it with gas or use a smart-contract forwarder pattern | +| Server-held keys | None (fully non-custodial) | The seed that derives every deposit key — a real custody exception, see the Security section below and `docs/threat-model.md` | + +**Sweep-mechanism decision (recorded here per the issue that introduced EVM deposit addresses):** +two designs were considered for moving swept funds off deposit addresses: + +- **HD EOAs + relayer-funded sweep** (what this repo implements): simplest, needs no contract + deployment, but the deposit address needs native-token gas before it can send anything, and the + server holds a real spending key for every deposit address. +- **CREATE2 forwarder contracts**: the deposit address is a *counterfactual* contract address + (known before deployment), and sweeping can be pull-based (a relayer calls the contract, which + forwards its balance) rather than needing the deposit address itself to hold gas. Trade-off: more + gas cost per sweep (contract deployment + call, vs. a plain transfer) and real contract-code + attack surface (see the "not a smart-contract system" caveat in `docs/threat-model.md`, which + this would revoke). + +Both are legitimate; this repo takes the HD-EOA path because it needs no contract deployment or +audit to ship deposit-address allocation, and because the sweep engine itself (which key funds +gas, when sweeps trigger, how sweep failures are retried) is separate, larger, follow-on work. +**Sweeping is not implemented by the deposit-address-allocation work this section documents** — +until it lands, EVM deposit funds sit at the customer's own address, unswept. + +## Security: the xpub is a secret + +This is the one property of BIP-44 that most needs to be understood correctly, so it is restated +here as well as in `docs/threat-model.md`: + +> The path above is **hardened** through `m/44'/60'/0'` and **non-hardened** for `/0/{index}`. +> Non-hardened derivation means the extended *public* key (xpub) at `m/44'/60'/0'/0` plus any +> **one** leaked child *private* key is enough to reconstruct **every sibling private key** at +> that depth — the child key is `k_i = IL + k_par (mod n)`, a reversible modular addition, and +> `IL` is computable from the xpub alone. Practically: if the xpub for this branch is ever exposed +> (a log line, an API response, a webhook payload, a debugging session) and a single customer's +> deposit key is later compromised by any means, **every customer's deposit key on that wallet is +> compromised**, not just the one that leaked. + +octo never constructs or exposes an xpub — every derivation walks the full path from the sealed +seed on demand (see [`octo_evm_core::deposit_address_from_sealed`](../crates/evm-core/src/lib.rs)) +— but this is a structural property of the choice to use non-hardened derivation for the +deposit-facing branch, not an implementation detail that could be "fixed" without changing the +derivation scheme. Any future code that adds xpub-based (watch-only) derivation must carry this +warning forward. + +## Recoverability + +Only the **index** is guaranteed to reproduce the address — not the address string alone. Given +the wallet's seed (recovered from its BIP39 mnemonic, out-of-band) and a stored `derivation_index`, +`octo_evm_core::derive_deposit_address` always reproduces the exact same address. This is why +`addresses.derivation_index` is a real column, not a derived/cached value: it is the only durable +record of *how* to re-derive a given customer's key for disaster recovery. + +## Case handling + +An EVM address's mixed-case form ([EIP-55](https://eips.ethereum.org/EIPS/eip-55)) is a +**checksum**, not part of the address's identity — `0xf39F...` and `0xf39f...` (all-lowercase) are +the same address. octo stores the checksummed form for display but indexes and looks up on the +lowercase form (a generated column, `evm_address_lower`), so a client that sends any casing — +lowercase, uppercase, or checksummed — still resolves to the same row. See +`migrations/0021_evm_deposit_addresses.sql`. + +## API shape + +`POST /v1/wallets/{id}/addresses` and `GET /v1/wallets/{id}/addresses` return a chain-appropriate +shape: Stellar responses carry `muxed_address` / `base_address` / `memo_id`; EVM responses carry +`address` instead and **omit `memo_id` entirely** (not `null`) — there is nowhere for a memo to go +on an EVM chain, so the field isn't there to invite a client to send one. Both shapes carry +`chain_kind` so a client can branch without inspecting which optional fields are present. See +[`crates/api/src/routes/addresses.rs`](../crates/api/src/routes/addresses.rs). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f868daf..6af07eb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -555,6 +555,42 @@ components: type: string balance_stroops: type: integer + AddressView: + type: object + description: >- + Response shape depends on chain_kind (see docs/deposit-model.md). Stellar responses carry + muxed_address/base_address/memo_id; EVM responses carry address instead and OMIT + memo_id entirely (not null) — EVM has nowhere for a memo to go. + required: + - id + - chain_kind + - metadata + - received_stroops + properties: + id: + type: string + format: uuid + customer_ref: + type: string + chain_kind: + type: string + enum: [stellar, evm] + muxed_address: + type: string + description: Stellar only. + base_address: + type: string + description: Stellar only. + memo_id: + type: integer + description: Stellar only. Never present on an EVM response. + address: + type: string + description: EVM only. EIP-55 checksummed deposit address. + metadata: + type: object + received_stroops: + type: integer CreateAddressResponse: type: object required: @@ -567,24 +603,7 @@ components: message: type: string data: - type: object - required: - - id - - muxed_address - - base_address - - memo_id - properties: - id: - type: string - format: uuid - customer_ref: - type: string - muxed_address: - type: string - base_address: - type: string - memo_id: - type: integer + $ref: '#/components/schemas/AddressView' ListAddressesResponse: type: object required: @@ -599,24 +618,7 @@ components: data: type: array items: - type: object - required: - - id - - muxed_address - - base_address - - memo_id - properties: - id: - type: string - format: uuid - customer_ref: - type: string - muxed_address: - type: string - base_address: - type: string - memo_id: - type: integer + $ref: '#/components/schemas/AddressView' WithdrawResponse: type: object required: diff --git a/docs/threat-model.md b/docs/threat-model.md index 5e249b5..2e38f75 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -16,6 +16,17 @@ tank**: a separate, server-held account carrying *fee float only*, used to sign envelopes for gas sponsorship. That is the sole plaintext key material on the server, and its worst-case exposure is the gas budget, never customer balances. +**A second, new exception exists for EVM wallets.** Stellar's muxed-address model needs no +per-customer on-chain account, so it needs no per-customer key. EVM has no such mechanism — each +customer gets a real, HD-derived EOA (see `docs/deposit-model.md`), and the server **does** hold +key material (the wallet's sealed BIP-39 seed) capable of deriving every one of those addresses' +private keys, narrowly permitted by AD-4 in `docs/ethereum-expansion-issues.md`. This is scoped to +*EVM deposit addresses only* — Stellar user wallets remain fully non-custodial, unaffected. See +the new row in section A below and `docs/deposit-model.md`'s "Security: the xpub is a secret" +section for the mechanism. **Sweeping funds out of those addresses (moving the risk from "the +server can derive the key" to "the server actively spends from it") is separate, not-yet-built +work** — see the sweep-mechanism note in `docs/deposit-model.md`. + octo is **not** a smart-contract system. Therefore the famous web3 exploit classes that assume on-chain contracts **do not apply**: @@ -43,8 +54,10 @@ responsible for the largest share of real-world crypto theft. | **User seed stolen from the server** | **Structurally impossible: the server never has it.** Keys are generated client-side; only the public account is transmitted. A total compromise of the API, DB and backups yields no user key | cutover | | User key backup stolen from DB dump / backup | `encrypted_backup` is ciphertext the **client** produced under the user's password (PBKDF2→AES-256-GCM in the browser). octo stores it opaquely and holds no password or key to open it | cutover | | Gas-tank seed stolen from DB dump / backup | Seed stored **AES-256-GCM encrypted**, random nonce+salt; master key from KMS/secret-manager, never in the DB or repo. **Loss is bounded by the gas budget — no customer funds** | 3, 5 | -| Seed/keys leaked via logs, crash dumps, swap | Secrets live only in `wallet-core`; `Zeroizing` wrappers wipe seed & derived keys on drop; `Debug` never prints secret bytes; `unwrap`/`panic` denied by clippy there | 3, 4 | -| Master key compromise | KMS-held key; zero-downtime rotation via `sealed_scheme` + `bin/migrate-keys`; defense-in-depth so DB-only compromise is insufficient. Only gas tanks are affected | 3, rotation | +| **EVM deposit-wallet seed stolen from DB dump / backup** | Same at-rest protection as the gas tank (AES-256-GCM, `octo-crypto`, chain-scoped AAD context so a seed sealed for one `eip155:*` chain can't be opened under another). **Unlike the gas tank, loss here exposes every current and future customer deposit key on that wallet** — the seed derives the entire `m/44'/60'/0'/0/*` branch. This is the AD-4 custody exception new for EVM; see `docs/deposit-model.md` | #220 | +| **EVM xpub exposure + one leaked child key ⇒ every sibling key** | Non-hardened derivation (`m/44'/60'/0'/0/{index}`) means the extended *public* key at that depth plus any one leaked deposit private key lets an attacker reconstruct every sibling deposit key on the wallet — the child key is a reversible modular addition, `IL` computable from the xpub alone. **Defense: octo never constructs or exposes an xpub anywhere** (API response, log, webhook) — every derivation re-walks the full path from the sealed seed on demand (`octo_evm_core::deposit_address_from_sealed`). Treat this as a standing constraint on any future code, not a one-time check | #220 | +| Seed/keys leaked via logs, crash dumps, swap | Secrets live only in `wallet-core` / `evm-core`; `Zeroizing` wrappers wipe seed & derived keys on drop; `Debug` never prints secret bytes; `unwrap`/`panic` denied by clippy in both | 3, 4, #220 | +| Master key compromise | KMS-held key; zero-downtime rotation via `sealed_scheme` + `bin/migrate-keys`; defense-in-depth so DB-only compromise is insufficient. Affects gas tanks and EVM deposit-wallet seeds — never Stellar user wallets, which carry no server-side key at all | 3, rotation | | Weak randomness in key/nonce generation | Use `OsRng` (CSPRNG) only; never `rand::thread_rng` seeded predictably for key material; test that two seals of same plaintext differ | 3, 4 | | Derived key reused across contexts | One derivation path per account; keys are ephemeral and zeroized | 4 | | **User loses password *and* recovery phrase** | **Accepted, unavoidable consequence of non-custody:** octo cannot reset or recover the wallet. This is a deliberate trade — the same property that makes a server breach harmless makes recovery impossible without the user's own secrets. Must be stated plainly in any product UI | product | @@ -132,5 +145,17 @@ responsible for the largest share of real-world crypto theft. gone; octo cannot help. This must be said plainly in product UI, not buried. - **Gas tank remains a hot key** (online, encrypted + zeroized + op-allowlisted). Exposure is bounded by the gas budget; MPC/HSM for this key is a later phase. +- **EVM deposit-wallet seeds are hot keys with wallet-wide blast radius.** Unlike the gas tank + (bounded by the fee budget), a compromised EVM wallet seed exposes every customer deposit key + derived under it, current and future. This is the accepted cost of HD-EOA deposit addresses + (the alternative, CREATE2 forwarders, trades this for contract-deployment risk instead — see + `docs/deposit-model.md`). Bounding this further (MPC/HSM, hardened-only derivation with + per-customer xpub distribution, or a smaller per-wallet customer cap) is future work, not part of + deposit-address allocation itself. +- **EVM deposit funds are unswept by design, for now.** Deposit-address allocation (this issue) + does not implement sweeping; funds sit at the customer's own address until a separate sweep + engine exists. That engine inherits everything above plus a live-spending key, not just a + derivation key — treat it as a materially bigger threat surface when it's built, not an + incremental extension of this one. > If you add a feature, add its row(s) above and the test that proves the defense.