Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/cargo-lock-check.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 27 additions & 27 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<current-key> \
WALLET_ENCRYPTION_KEY_NEW=<new-key> \
DATABASE_URL=<target-database> \
cargo run -- --rotate-key

# 3. On success, update the deployment's WALLET_ENCRYPTION_KEY to <new-key>
# 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
Expand Down
4 changes: 2 additions & 2 deletions src/auth/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ impl FromRequestParts<AppState> 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,
Expand Down
67 changes: 61 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Vec<String>, 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<String, String> {
std::env::var(name).map_err(|_| format!("{name} is required"))
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod error;
mod middleware;
mod models;
pub mod payments;
pub mod rotate_key;
pub mod services;
mod validation;

Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.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?);

Expand Down
110 changes: 110 additions & 0 deletions src/rotate_key.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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<String, String> {
std::env::var(name).map_err(|_| format!("{name} is required for --rotate-key"))
}