diff --git a/.github/workflows/cargo-lock-check.yml b/.github/workflows/cargo-lock-check.yml new file mode 100644 index 0000000..903fdce --- /dev/null +++ b/.github/workflows/cargo-lock-check.yml @@ -0,0 +1,32 @@ +name: Cargo.lock up to date + +on: + push: + branches: [master, main] + pull_request: + +jobs: + lockfile: + name: Cargo.lock matches Cargo.toml + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-lockcheck-${{ hashFiles('**/Cargo.lock') }} + + # --locked fails immediately if Cargo.lock doesn't already satisfy + # every constraint in Cargo.toml, instead of silently rewriting it — + # exactly the drift this check exists to catch (a Cargo.toml version + # bump committed without regenerating the lockfile, or vice versa). + - name: Verify Cargo.lock is up to date + run: cargo check --locked --all-targets diff --git a/Cargo.lock b/Cargo.lock index b8242e2..8293d00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "hmac", "http", "jsonwebtoken", + "mime", "rand 0.8.7", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index de8c9e9..cf12c0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,32 +4,32 @@ version = "0.1.0" edition = "2021" [dependencies] -aes-gcm = "0.11.0" -argon2 = "0.5" -async-trait = "0.1" -axum = "0.8" -chrono = { version = "0.4", features = ["serde"] } -dotenvy = "0.15" -ed25519-dalek = "3.0.0" -hex = "0.4" -hmac = "0.12" -http = "1" -jsonwebtoken = "9" -mime = "0.3" -rand = "0.8" -reqwest = { version = "0.13.4", default-features = false, features = ["form", "json", "query", "rustls"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros"] } -stellar-strkey = "0.0.18" -thiserror = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -tower-http = { version = "0.6", features = ["trace", "limit", "cors"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -uuid = { version = "1", features = ["v4", "serde"] } +aes-gcm = "=0.11.0" +argon2 = "=0.5.3" +async-trait = "=0.1.91" +axum = "=0.8.9" +chrono = { version = "=0.4.45", features = ["serde"] } +dotenvy = "=0.15.7" +ed25519-dalek = "=3.0.0" +hex = "=0.4.3" +hmac = "=0.12.1" +http = "=1.5.0" +jsonwebtoken = "=9.3.1" +mime = "=0.3.17" +rand = "=0.8.7" +reqwest = { version = "=0.13.4", default-features = false, features = ["form", "json", "query", "rustls"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.10.9" +sqlx = { version = "=0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros"] } +stellar-strkey = "=0.0.18" +thiserror = "=1.0.69" +tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread"] } +tower-http = { version = "=0.6.11", features = ["trace", "limit", "cors"] } +tracing = "=0.1.44" +tracing-subscriber = { version = "=0.3.23", features = ["env-filter"] } +uuid = { version = "=1.24.0", features = ["v4", "serde"] } [dev-dependencies] -stellar-base = "0.7.0" -tower = { version = "0.5", features = ["util"] } +stellar-base = "=0.7.0" +tower = { version = "=0.5.3", features = ["util"] } diff --git a/README.md b/README.md index ff857ba..0f6f337 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,37 @@ Fill in `.env`: | `JWT_SECRET` | yes | — | Secret used to sign merchant session tokens. Generate with `openssl rand -hex 32` | | `WEBHOOK_SECRET` | yes | — | Secret used to verify inbound provider webhooks. Generate with `openssl rand -hex 32` | | `WALLET_ENCRYPTION_KEY` | yes | — | AES-256-GCM key encrypting Stellar wallet secrets at rest. Generate with `openssl rand -hex 32` (must decode to exactly 32 bytes) | +| `WALLET_ENCRYPTION_KEY_NEW` | only for `--rotate-key` | — | The key to rotate `WALLET_ENCRYPTION_KEY` to. See "Rotating `WALLET_ENCRYPTION_KEY`" below | | `STELLAR_SYSTEM_WALLET_ADDRESS` | yes | — | Reserved for a future platform settlement/sweep wallet. Validated at startup but not used by deposit detection today (see [Status](#status-real-progress-not-aspiration)) | | `STELLAR_HORIZON_URL` | no | `https://horizon-testnet.stellar.org` | Horizon endpoint to poll | | `STELLAR_POLL_INTERVAL_SECS` | no | `60` | How often the deposit-detection worker polls Horizon, per wallet | | `PAYSTACK_SECRET_KEY` | yes | — | Paystack Dashboard → Settings → API Keys & Webhooks. `sk_test_...` for dev, `sk_live_...` only once the business is verified/activated for Transfers (see `PRD.md` §9.1) | -| `CORS_ALLOWED_ORIGINS` | no | `http://localhost:3001` | Comma-separated browser origins allowed to call the API. Never mirrored back — an unlisted origin fails preflight | +| `CORS_ALLOWED_ORIGINS` | no | `http://localhost:3001` | Comma-separated browser origins allowed to call the API. Never mirrored back — an unlisted origin fails preflight. Each value is validated at startup (scheme `http`/`https`, host, no path/query, no `*` wildcard) — the app refuses to start rather than accept a malformed origin | | `COOKIE_SECURE` | no | `true` | Whether the session cookie carries `Secure`. Leave on: browsers treat `localhost` as a secure context, so the default works in dev too. Only turn it off for a non-localhost plain-HTTP setup, which you should not have | | `COOKIE_SAME_SITE` | no | `lax` | `lax` or `none`. `none` (which forces `Secure`) is only for a frontend on a different origin, and lets the session ride cross-site requests — prefer serving the frontend same-origin | +### Rotating `WALLET_ENCRYPTION_KEY` + +```bash +# 1. Generate the new key +openssl rand -hex 32 + +# 2. Re-encrypt every wallet's secret from the old key to the new one +WALLET_ENCRYPTION_KEY= \ +WALLET_ENCRYPTION_KEY_NEW= \ +DATABASE_URL= \ + cargo run -- --rotate-key + +# 3. On success, update the deployment's WALLET_ENCRYPTION_KEY to +# and restart. Discard the old key. +``` + +No downtime is required for this today: nothing in the running server decrypts a +wallet secret (withdrawals settle through Paystack, not by signing with the +wallet's own key), so there is no live reader for the rotation to race +against. See the doc comment on `src/rotate_key.rs` for what would need to +change once that's no longer true — e.g. once a sweep-wallet signer exists. + ### Quick start (Docker Postgres) ```bash diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 7913134..4ac2168 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -28,9 +28,9 @@ impl FromRequestParts for AuthUser { .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .or_else(|| cookie::from_headers(&parts.headers)) - .ok_or_else(|| (StatusCode::UNAUTHORIZED, Json(ApiError { error: "missing session cookie or bearer token".into() })))?; + .ok_or_else(|| (StatusCode::UNAUTHORIZED, Json(ApiError { error: "missing session cookie or bearer token".into(), field: None })))?; let claims = jwt::verify(&state.jwt_secret, token) - .map_err(|_| (StatusCode::UNAUTHORIZED, Json(ApiError { error: "invalid or expired token".into() })))?; + .map_err(|_| (StatusCode::UNAUTHORIZED, Json(ApiError { error: "invalid or expired token".into(), field: None })))?; Ok(AuthUser { user_id: claims.sub, merchant_id: claims.merchant_id, diff --git a/src/config.rs b/src/config.rs index ff4a757..f74ec8d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,12 +93,10 @@ impl AppConfig { .unwrap_or(60), wallet_encryption_key: SecretString::new(env("WALLET_ENCRYPTION_KEY")?), paystack_secret_key: SecretString::new(env("PAYSTACK_SECRET_KEY")?), - cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS") - .unwrap_or_else(|_| "http://localhost:3001".into()) - .split(',') - .map(|origin| origin.trim().to_string()) - .filter(|origin| !origin.is_empty()) - .collect(), + cors_allowed_origins: parse_cors_origins( + &std::env::var("CORS_ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3001".into()), + )?, cookie: CookieConfig { secure: cookie_secure, same_site: cookie_same_site, @@ -107,6 +105,63 @@ impl AppConfig { } } +/// Parses `CORS_ALLOWED_ORIGINS` into a list of validated origins, failing +/// fast with a clear message rather than letting a malformed value surface +/// later as an opaque panic from `HeaderValue` parsing in `main.rs`, or +/// silently reach the CORS layer as a value it doesn't handle the way the +/// operator expects. +fn parse_cors_origins(raw: &str) -> Result, String> { + raw.split(',') + .map(|origin| origin.trim()) + .filter(|origin| !origin.is_empty()) + .map(|origin| { + validate_origin(origin)?; + Ok(origin.to_string()) + }) + .collect() +} + +/// An "origin" is scheme + host [+ port] only — no path, query, fragment, or +/// userinfo. `http::Uri` already gives us a real URL parser without pulling +/// in a new dependency (`http` is already required by `axum`). +fn validate_origin(origin: &str) -> Result<(), String> { + if origin == "*" { + return Err( + "CORS_ALLOWED_ORIGINS: wildcard `*` is not allowed — this API sends credentials \ + (the session cookie), and browsers reject a wildcard origin on a credentialed \ + request anyway. List each allowed origin explicitly." + .into(), + ); + } + + let uri: http::Uri = origin + .parse() + .map_err(|_| format!("CORS_ALLOWED_ORIGINS: `{origin}` is not a valid URL"))?; + + let scheme = uri.scheme_str().ok_or_else(|| { + format!("CORS_ALLOWED_ORIGINS: `{origin}` must include a scheme (http:// or https://)") + })?; + if scheme != "http" && scheme != "https" { + return Err(format!( + "CORS_ALLOWED_ORIGINS: `{origin}` scheme must be http or https, got `{scheme}`" + )); + } + if uri.host().is_none() { + return Err(format!("CORS_ALLOWED_ORIGINS: `{origin}` must include a host")); + } + if !matches!(uri.path(), "" | "/") { + return Err(format!( + "CORS_ALLOWED_ORIGINS: `{origin}` must not include a path — an origin is scheme + host + port only" + )); + } + if uri.query().is_some() { + return Err(format!( + "CORS_ALLOWED_ORIGINS: `{origin}` must not include a query string" + )); + } + Ok(()) +} + fn env(name: &str) -> Result { std::env::var(name).map_err(|_| format!("{name} is required")) } diff --git a/src/lib.rs b/src/lib.rs index 928c5bf..69db143 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod error; mod middleware; mod models; pub mod payments; +pub mod rotate_key; pub mod services; mod validation; diff --git a/src/main.rs b/src/main.rs index b5d5f0a..ecde202 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,13 @@ async fn main() -> Result<(), Box> { ) .init(); + // `--rotate-key` runs the WALLET_ENCRYPTION_KEY rotation and exits — + // it never starts the HTTP server or the deposit worker. See + // src/rotate_key.rs for the full operational sequence. + if std::env::args().nth(1).as_deref() == Some("--rotate-key") { + return aframp::rotate_key::run().await; + } + let config = AppConfig::from_env()?; let state = Arc::new(build_state(&config).await?); diff --git a/src/rotate_key.rs b/src/rotate_key.rs new file mode 100644 index 0000000..39266d9 --- /dev/null +++ b/src/rotate_key.rs @@ -0,0 +1,110 @@ +//! `--rotate-key` CLI subcommand: re-encrypts every wallet's +//! `secret_key_encrypted` column from `WALLET_ENCRYPTION_KEY` (the current +//! key) to `WALLET_ENCRYPTION_KEY_NEW` (the key you're rotating to). +//! +//! # Why this is safe to run without downtime today +//! +//! No request-serving code path in this codebase calls +//! [`crate::blockchain::wallet_crypto::decrypt`] — wallet secrets are +//! encrypted at creation time and never read back by anything the running +//! server does today (withdrawals settle through Paystack, not by signing +//! with the wallet's own Stellar key). That means there is no live reader to +//! race against: this tool can walk the table and re-encrypt every row while +//! the server keeps running on the *old* key, because the old key isn't +//! being used for anything concurrent with the rotation. +//! +//! # Operational sequence +//! +//! 1. Generate a new key: `openssl rand -hex 32`. +//! 2. Set `WALLET_ENCRYPTION_KEY_NEW` to that value alongside the existing +//! `WALLET_ENCRYPTION_KEY` and `DATABASE_URL` (pointed at the target +//! database), then run the binary with `--rotate-key`. +//! 3. On success, update the deployment's `WALLET_ENCRYPTION_KEY` to the new +//! key's value and restart the server. Discard the old key. +//! +//! # If this stops being true +//! +//! The moment a live code path starts calling `decrypt` (e.g. a sweep-wallet +//! signer), this approach needs revisiting: either pause writers/readers of +//! `secret_key_encrypted` for the rotation's duration, or version-tag the +//! ciphertext (a key-id prefix) so `decrypt` can try the outgoing key during +//! a transition window instead of assuming exactly one key is ever current. + +use sqlx::postgres::PgPoolOptions; +use uuid::Uuid; + +use crate::blockchain::wallet_crypto; + +/// Runs the rotation. Returns `Err` (and leaves every already-rotated row on +/// the new key) if any row fails to decrypt with the old key or the two keys +/// are identical — a partial rotation is safe to re-run, since only rows +/// still holding old-key ciphertext will fail to re-encrypt a second time. +pub async fn run() -> Result<(), Box> { + let database_url = require_env("DATABASE_URL")?; + let old_key = wallet_crypto::parse_key(&require_env("WALLET_ENCRYPTION_KEY")?)?; + let new_key = wallet_crypto::parse_key(&require_env("WALLET_ENCRYPTION_KEY_NEW")?)?; + if old_key == new_key { + return Err("WALLET_ENCRYPTION_KEY_NEW must differ from WALLET_ENCRYPTION_KEY".into()); + } + + let db = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await?; + + let rows: Vec<(Uuid, String)> = + sqlx::query_as("SELECT id, secret_key_encrypted FROM wallets") + .fetch_all(&db) + .await?; + + let total = rows.len(); + tracing::info!(total, "starting WALLET_ENCRYPTION_KEY rotation"); + + let mut rotated = 0usize; + let mut failed = 0usize; + + for (id, encrypted) in rows { + let plaintext = match wallet_crypto::decrypt(&old_key, &encrypted) { + Ok(p) => p, + Err(err) => { + failed += 1; + tracing::error!(wallet_id = %id, error = %err, "failed to decrypt with WALLET_ENCRYPTION_KEY; row left untouched"); + continue; + } + }; + let re_encrypted = wallet_crypto::encrypt(&new_key, &plaintext)?; + + // A single UPDATE is already atomic per row: either this wallet's + // ciphertext moves to the new key or it doesn't, never a partial + // write. There's no multi-statement unit of work here that needs an + // explicit transaction. + sqlx::query("UPDATE wallets SET secret_key_encrypted = $2 WHERE id = $1") + .bind(id) + .bind(&re_encrypted) + .execute(&db) + .await?; + rotated += 1; + } + + tracing::info!(rotated, failed, total, "WALLET_ENCRYPTION_KEY rotation finished"); + + if failed > 0 { + return Err(format!( + "{failed} of {total} wallet row(s) could not be decrypted with WALLET_ENCRYPTION_KEY \ + and were left untouched — see logs above for wallet ids. Do not switch the deployment \ + to WALLET_ENCRYPTION_KEY_NEW yet; investigate those rows and re-run this command." + ) + .into()); + } + + println!( + "Rotation complete: {rotated} wallet(s) re-encrypted with WALLET_ENCRYPTION_KEY_NEW.\n\ + Next: set the deployment's WALLET_ENCRYPTION_KEY to the value you used for \ + WALLET_ENCRYPTION_KEY_NEW, restart the server, and discard the old key." + ); + Ok(()) +} + +fn require_env(name: &str) -> Result { + std::env::var(name).map_err(|_| format!("{name} is required for --rotate-key")) +}