From 695b3051c7c505715d4f260d847adb9d715f1d36 Mon Sep 17 00:00:00 2001 From: Darkdruce Date: Tue, 23 Jun 2026 11:05:54 +0000 Subject: [PATCH] feat: validate Stellar addresses (strkey) at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STELLAR_GATEWAY_PUBLIC and asset issuers were accepted as raw strings, so a typo in the gateway key silently produced unpayable intents and was never rejected. Add a small, dependency-free strkey validator (src/strkey.rs) that checks the `G` account version byte, the 56-character length, the base32 alphabet and the CRC16-XModem checksum. Config::from_env now validates the gateway key (when configured) and every accepted-asset issuer, so an invalid address fails fast at boot with a clear error instead of booting into a broken state. Unit tests cover valid keys and corrupted ones (bad checksum, wrong version, bad length, non-base32, secret-seed prefix). The current API exposes no user-supplied Stellar address field (destination is the gateway's own address; merchant_id is an opaque label), so validation is applied where addresses actually enter — config ingestion — and the validator is exported for future API use. closes #6 --- Cargo.lock | 18 ++++++ README.md | 5 +- src/config.rs | 86 ++++++++++++++++++++++++- src/lib.rs | 1 + src/strkey.rs | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/strkey.rs diff --git a/Cargo.lock b/Cargo.lock index f83edad..059b013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,24 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" diff --git a/README.md b/README.md index f486ddc..596cff9 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ cp .env.example .env | `DATABASE_URL` | sqlx connection string | `sqlite:stellargate.db` | | `STELLAR_NETWORK` | `testnet` or `public` | `testnet` | | `STELLAR_HORIZON_URL` | Horizon endpoint | testnet | -| `STELLAR_GATEWAY_PUBLIC` | Your gateway wallet public key | — | +| `STELLAR_GATEWAY_PUBLIC` | Your gateway wallet public key (`G...`). Validated as a Stellar strkey at startup; an invalid value aborts boot. | — | | `STELLAR_GATEWAY_SECRET` | Your gateway wallet secret key | — | -| `ACCEPTED_ASSETS` | Comma-separated assets to accept. Format: `CODE` for native (e.g. `XLM`) or `CODE:ISSUER` for non-native (e.g. `USDC:GISSUER`). Adding an asset is config-only — no code changes needed. | `XLM,USDC:` | +| `ACCEPTED_ASSETS` | Comma-separated assets to accept. Format: `CODE` for native (e.g. `XLM`) or `CODE:ISSUER` for non-native (e.g. `USDC:GISSUER`). Adding an asset is config-only — no code changes needed. Each `ISSUER` is validated as a Stellar strkey at startup. | `XLM,USDC:` | | `STELLAR_LISTENER_MODE` | `stream` (SSE + poller reconciler) or `poll` (interval only) | `stream` | | `POLL_INTERVAL_SECS` | How often the Horizon poller reconciles | `10` | | `PAYMENT_TTL_SECS` | How long a payment intent stays `pending` before it is expired (from `created_at`) | `3600` | @@ -387,6 +387,7 @@ src/ ├── config.rs # Environment configuration ├── db.rs # Database queries (SQLite) ├── money.rs # Stroops-based amount parsing/validation +├── strkey.rs # Stellar address (strkey) validation ├── horizon.rs # Horizon polling listener + payment verification ├── expiry.rs # Background sweeper that expires overdue pending intents ├── webhook.rs # HMAC-SHA256 signed webhook dispatch diff --git a/src/config.rs b/src/config.rs index 22760c3..a96446c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,7 +99,7 @@ pub struct Config { impl Config { pub fn from_env() -> Result { - Ok(Self { + let config = Self { port: parse_env("PORT", 3000), database_url: env_or("DATABASE_URL", "sqlite:stellargate.db"), network: env_or("STELLAR_NETWORK", "testnet"), @@ -130,7 +130,9 @@ impl Config { listener_mode: ListenerMode::parse( &std::env::var("STELLAR_LISTENER_MODE").unwrap_or_default(), ), - }) + }; + config.validate_addresses()?; + Ok(config) } /// True once a real gateway wallet has been configured. Until then the @@ -138,6 +140,33 @@ impl Config { pub fn gateway_configured(&self) -> bool { !self.gateway_public.is_empty() && self.gateway_public != "UNCONFIGURED" } + + /// Reject configured Stellar addresses — the gateway account and any asset + /// issuers — that are not valid strkeys, so a typo fails fast at boot rather + /// than silently producing unpayable intents. The unconfigured placeholder + /// is left alone; the poller stays idle until a real key is provided. + fn validate_addresses(&self) -> Result<()> { + if self.gateway_configured() { + crate::strkey::validate_account_id(&self.gateway_public).map_err(|e| { + anyhow::anyhow!( + "STELLAR_GATEWAY_PUBLIC ({}) is not a valid Stellar account address: {e}", + self.gateway_public + ) + })?; + } + for asset in &self.accepted_assets { + if let Some(issuer) = &asset.issuer { + crate::strkey::validate_account_id(issuer).map_err(|e| { + anyhow::anyhow!( + "issuer for asset {} ({}) is not a valid Stellar account address: {e}", + asset.code, + issuer + ) + })?; + } + } + Ok(()) + } } impl std::fmt::Debug for Config { @@ -248,4 +277,57 @@ mod tests { } ); } + + fn sample_config() -> Config { + Config { + port: 3000, + database_url: "sqlite::memory:".into(), + network: "testnet".into(), + horizon_url: "https://horizon-testnet.stellar.org".into(), + gateway_public: "UNCONFIGURED".into(), + gateway_secret: String::new(), + accepted_assets: AcceptedAsset::default_list(), + webhook_secret: String::new(), + webhook_retry_attempts: 3, + webhook_retry_delay_ms: 5000, + poll_interval_secs: 10, + payment_ttl_secs: 3600, + rate_limit_requests_per_sec: 10, + cors_allowed_origins: vec![], + listener_mode: ListenerMode::Stream, + } + } + + #[test] + fn validate_addresses_passes_for_unconfigured_gateway_and_default_issuer() { + // The placeholder gateway is skipped; the default USDC issuer is valid. + assert!(sample_config().validate_addresses().is_ok()); + } + + #[test] + fn validate_addresses_accepts_a_real_gateway_key() { + let mut cfg = sample_config(); + cfg.gateway_public = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(); + assert!(cfg.validate_addresses().is_ok()); + } + + #[test] + fn validate_addresses_rejects_a_corrupted_gateway_key() { + let mut cfg = sample_config(); + // A valid key with one character flipped — a realistic typo. + cfg.gateway_public = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLB5".into(); + let err = cfg.validate_addresses().unwrap_err().to_string(); + assert!(err.contains("STELLAR_GATEWAY_PUBLIC"), "got: {err}"); + } + + #[test] + fn validate_addresses_rejects_an_invalid_issuer() { + let mut cfg = sample_config(); + cfg.accepted_assets = vec![AcceptedAsset { + code: "USDC".into(), + issuer: Some("GNOTAREALISSUER".into()), + }]; + let err = cfg.validate_addresses().unwrap_err().to_string(); + assert!(err.contains("USDC"), "got: {err}"); + } } diff --git a/src/lib.rs b/src/lib.rs index dd64284..5b231f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod db; pub mod expiry; pub mod horizon; pub mod money; +pub mod strkey; pub mod webhook; /// Shared application state handed to every request handler and the background diff --git a/src/strkey.rs b/src/strkey.rs new file mode 100644 index 0000000..b621461 --- /dev/null +++ b/src/strkey.rs @@ -0,0 +1,175 @@ +//! Minimal Stellar strkey validation. +//! +//! A Stellar account address ("strkey") such as `GBBD47IF...` base32-encodes a +//! one-byte version, a 32-byte ed25519 public key, and a two-byte CRC16-XModem +//! checksum. Checking the prefix, length, base32 alphabet and checksum catches +//! the failure mode this guards against: a mistyped `STELLAR_GATEWAY_PUBLIC` +//! (or asset issuer) that would otherwise silently mint unpayable intents. +//! +//! This is a small, dependency-free validator. It verifies structure and the +//! checksum — enough to reject typos and corruption — but does not check that +//! the 32-byte payload is a point on the ed25519 curve. + +use std::fmt; + +/// Version byte for an ed25519 public-key (account) strkey, which renders as a +/// leading `G`. Defined as `6 << 3` by the SEP-23 strkey encoding. +const ED25519_PUBLIC_KEY_VERSION: u8 = 6 << 3; + +/// An ed25519 public-key strkey (`G...`) is always 56 characters: base32 of +/// 1 version + 32 key + 2 checksum = 35 bytes. +const ACCOUNT_STRKEY_LEN: usize = 56; +const ACCOUNT_DECODED_LEN: usize = 35; + +/// Why a string is not a valid Stellar account address. +#[derive(Debug, PartialEq, Eq)] +pub enum StrkeyError { + /// Not the expected 56-character length. + Length, + /// Contains a character outside the base32 alphabet `A-Z2-7`. + Alphabet, + /// Decoded, but the version byte is not the `G` account-address marker. + Version, + /// The trailing CRC16 checksum does not match the payload. + Checksum, +} + +impl fmt::Display for StrkeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let msg = match self { + StrkeyError::Length => "must be 56 characters", + StrkeyError::Alphabet => "contains non-base32 characters", + StrkeyError::Version => "wrong version byte (expected a 'G' account address)", + StrkeyError::Checksum => "checksum mismatch (corrupted or mistyped)", + }; + f.write_str(msg) + } +} + +impl std::error::Error for StrkeyError {} + +/// Returns `true` if `s` is a structurally valid Stellar account address +/// (`G...`): correct prefix, length, base32 alphabet and CRC16 checksum. +pub fn is_valid_account_id(s: &str) -> bool { + validate_account_id(s).is_ok() +} + +/// Validate a Stellar account address (`G...`), returning the first check that +/// failed so callers can produce a precise error message. +pub fn validate_account_id(s: &str) -> Result<(), StrkeyError> { + if s.len() != ACCOUNT_STRKEY_LEN { + return Err(StrkeyError::Length); + } + let decoded = base32_decode(s).ok_or(StrkeyError::Alphabet)?; + if decoded.len() != ACCOUNT_DECODED_LEN { + return Err(StrkeyError::Length); + } + if decoded[0] != ED25519_PUBLIC_KEY_VERSION { + return Err(StrkeyError::Version); + } + let (payload, checksum) = decoded.split_at(decoded.len() - 2); + let expected = u16::from_le_bytes([checksum[0], checksum[1]]); + if crc16_xmodem(payload) != expected { + return Err(StrkeyError::Checksum); + } + Ok(()) +} + +/// Decode an unpadded RFC 4648 base32 string (`A-Z2-7`). Returns `None` if any +/// character is outside the alphabet. +fn base32_decode(s: &str) -> Option> { + const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = Vec::with_capacity(s.len() * 5 / 8); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for c in s.bytes() { + let value = ALPHABET.iter().position(|&a| a == c)? as u32; + buffer = (buffer << 5) | value; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((buffer >> bits) as u8); + buffer &= (1 << bits) - 1; + } + } + Some(out) +} + +/// CRC16-XModem (polynomial 0x1021, initial value 0x0000) — the checksum +/// Stellar appends, little-endian, to every strkey. +fn crc16_xmodem(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &byte in data { + crc ^= (byte as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x1021 + } else { + crc << 1 + }; + } + } + crc +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real, valid Stellar account address (the default USDC issuer). + const VALID: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + #[test] + fn accepts_a_valid_account_id() { + // Also proves the base32 + CRC16 implementation against a known address. + assert_eq!(validate_account_id(VALID), Ok(())); + assert!(is_valid_account_id(VALID)); + } + + #[test] + fn rejects_corrupted_checksum() { + // Flip a character deep in the key body: length and alphabet stay valid, + // but the trailing checksum no longer matches. + let mut chars: Vec = VALID.chars().collect(); + chars[20] = if chars[20] == 'A' { 'B' } else { 'A' }; + let corrupted: String = chars.into_iter().collect(); + assert_eq!(corrupted.len(), 56); + assert_eq!(validate_account_id(&corrupted), Err(StrkeyError::Checksum)); + } + + #[test] + fn rejects_wrong_version_byte() { + // Replace the leading 'G' so the version byte no longer marks an account. + let wrong = format!("A{}", &VALID[1..]); + assert_eq!(validate_account_id(&wrong), Err(StrkeyError::Version)); + } + + #[test] + fn rejects_bad_length() { + assert_eq!(validate_account_id(""), Err(StrkeyError::Length)); + assert_eq!(validate_account_id(&VALID[..55]), Err(StrkeyError::Length)); + assert_eq!( + validate_account_id(&format!("{VALID}A")), + Err(StrkeyError::Length) + ); + } + + #[test] + fn rejects_non_base32_characters() { + // '0' is not in the strkey alphabet; keep the length at 56. + let bad = format!("{}0", &VALID[..55]); + assert_eq!(bad.len(), 56); + assert_eq!(validate_account_id(&bad), Err(StrkeyError::Alphabet)); + } + + #[test] + fn rejects_secret_seed_prefix() { + // A valid-looking secret seed (S...) is not an account address. + let seed = "SDJHRQF4GCMIIKAAAQ6IHY42X73FQFLHUULAPSKKD4DFDM7UXWWCRHBE"; + assert_eq!(seed.len(), 56); + assert!(matches!( + validate_account_id(seed), + Err(StrkeyError::Version) | Err(StrkeyError::Checksum) + )); + } +}