use std::env; use std::net::SocketAddr; use crate::puzzle::types::{Algorithm, Argon2idParams}; use crate::risk::LoadLadder; #[derive(Debug, Clone)] pub struct AppConfig { pub listen_addr: SocketAddr, /// Filesystem directory holding the bundled widget assets and landing page. /// Resolved relative to the process working directory unless absolute. pub static_dir: String, /// PoW algorithm for new challenges. `argon2id` (default) or `sha256`. /// The difficulty defaults below track the algorithm — each Argon2id /// hash is orders of magnitude slower than SHA-256, so argon2id's /// default 5 leading zero bits is comparable to SHA-256's default 18. pub puzzle_algorithm: Algorithm, pub default_difficulty: u32, /// Upper clamp on the final PoW difficulty after the tier bump and the /// `LOAD_LADDER` floor are applied. pub max_difficulty: u32, /// Aggregate site-load difficulty floor. When the per-site request count in /// the rate window crosses a configured threshold, the PoW difficulty is /// floored (raised for every visitor, not just high-risk ones). Empty by /// default — the floor is 0 until `LOAD_LADDER` is set. Never blocks; it /// only raises difficulty, composed with the per-request tier via `max()`. pub load_ladder: LoadLadder, pub challenge_ttl_secs: u64, /// Cadence of the background sweeper that reclaims expired challenges and /// stale rate-window entries. Coerced to at least 1s — `tokio::time::interval` /// panics on a zero period, which would silently kill the only reclaimer /// and let the in-memory maps grow unbounded. pub cleanup_interval_secs: u64, /// Global ceiling on the number of challenges held in memory at once. Once /// reached, `GET /v1/puzzle` sheds new issuance via the Block tier (429) /// regardless of per-IP score — a memory backstop against a distributed /// flood (or an IPv6 /64-spread attacker) that stays under `IP_HARD_LIMIT` /// per source. Default 1_000_000 (~200 MB of challenges); `0` disables the /// cap and skips the per-request count check entirely. pub max_active_challenges: usize, pub tier_checkbox_min: u32, pub tier_hard_pow_min: u32, pub tier_block_min: u32, /// Hard per-IP issuance cap: once an IP (IPv6: its /64 bucket) exceeds /// this many puzzle requests in the 60s rate window, further requests are /// throttled to a **max-difficulty** PoW regardless of score (not a 429). /// Risk scoring alone can't escalate a flood with clean browser headers /// (rate maxes at +45, below `TIER_BLOCK_MIN`). A hard block would also /// punish shared-IP populations (CGNAT / corporate NAT) with no recourse, /// so this throttles instead — max PoW slows an abuser while a legitimate /// user still gets through. Memory growth is bounded separately by /// `MAX_ACTIVE_CHALLENGES`. Default 500 — far above organic per-IP /// traffic. `0` disables. pub ip_hard_limit: u32, /// Path to a CIDR reputation file. If unset, the IP reputation signal contributes 0. pub ip_reputation_file: Option, /// Path to a JSON file overriding any subset of the signal weights /// (`risk::weights::SignalWeights::NAMES`). Unset → the compiled-in /// defaults. A file that fails to parse refuses to boot. pub signal_weights_file: Option, /// Verify-time score at/above which the request is shadow-failed (success /// returned, log emitted). Default 30. pub verify_shadow_min: u32, /// Verify-time score at/above which the request is hard-rejected. Default 60. pub verify_block_min: u32, /// Max failed PoW attempts a single challenge tolerates before the store /// evicts it. A wrong nonce leaves the challenge live for legitimate retry, /// so without this one challenge could absorb unlimited (with `argon2id`, /// memory-hard) verify attempts. Default 10; `0` disables the cap. pub verify_max_attempts: u32, /// When true, a verify request with no `behavior` blob at all scores like /// a flatline (+30) instead of 0. Off by default so server-to-server /// integrations aren't penalized; turn it on when every legitimate client /// is the bundled widget (which always sends the blob) — otherwise the /// behavioral layer is opt-in for attackers hitting the API directly. /// +30 alone lands in the shadow band; combine with a lower /// `VERIFY_BLOCK_MIN` to make a missing blob hard-block. pub verify_require_behavior: bool, /// Header name carrying the TLS fingerprint set by a trusted reverse proxy /// (e.g. `x-ja4`). If unset, the TLS fingerprint signal is disabled. pub tls_fingerprint_header: Option, /// Path to a file listing known-bad TLS fingerprints (one per line, `#` comments). pub tls_fingerprint_file: Option, /// CIDR allowlist of upstream proxies whose `tls_fingerprint_header` we /// trust. Comma- or whitespace-separated. Required when the TLS feature is /// enabled — without it, no peer is trusted and the signal never fires. pub trusted_proxies: Option, /// Path to the SQLite database used to persist puzzle/verify decisions for /// the validation dashboard. When unset, dashboard logging and the admin /// endpoints are both disabled. pub admin_db_path: Option, /// Bearer token required by `/v1/admin/*` and by `POST /v1/sites`. /// When unset, the dashboard endpoints are simply not mounted and /// `POST /v1/sites` returns 404 — no anonymous provisioning. pub admin_token: Option, /// Path to a SQLite database for persistent site registrations. /// When unset, sites live only in memory (lost on restart). Strongly /// recommended for any deployment beyond local dev. pub site_db_path: Option, /// **Dev/test only.** When true, `POST /v1/sites` skips the /// `ADMIN_TOKEN` bearer check so local-dev pages and Playwright e2e /// can register sites anonymously. Refused outside `cfg!(debug_assertions)` /// builds — release binaries log a warning and ignore the flag. The /// admin dashboard endpoints (`/v1/admin/*`) are NOT bypassed. pub dev_disable_admin_auth: bool, /// Optional override URL for the bundled `/static/about.html` page. /// Must be an absolute `http(s)://` URL when set; the bundled default /// is used when unset. Surfaced to the widget via the puzzle response. pub info_about_url: Option, /// Optional override URL for the bundled `/static/privacy.html` page. pub info_privacy_url: Option, /// Optional override URL for the bundled `/static/terms.html` page. pub info_terms_url: Option, /// Anonymize the client IP before it is written to the decision log /// (dashboard / `ADMIN_DB_PATH`): IPv4 is truncated to /24, IPv6 to /48. /// **Default true** — the durable log keeps only a network prefix, never /// a per-visitor address, which is what keeps the dashboard defensible /// under GDPR data-minimization. Live scoring always uses the full IP, so /// detection is unaffected. Set `ANONYMIZE_LOG_IP=false` to log full IPs /// (e.g. for abuse forensics where the operator is the data controller). pub anonymize_log_ip: bool, /// Retention window for the decision log, in hours. Rows older than this /// are pruned by a periodic sweeper (only runs when `ADMIN_DB_PATH` is /// set). **Default 72** — the same short window ALTCHA uses; together /// with `anonymize_log_ip` it keeps the dashboard's durable log /// defensible under GDPR storage-limitation. Set `LOG_RETENTION_HOURS=0` /// to disable pruning and keep rows forever (e.g. when the operator is /// the data controller and needs a longer forensic trail). pub log_retention_hours: u64, /// Opt-in training retention from the original event, in hours. /// 0 disables collection and removes existing samples; maximum 90 days. pub training_retention_hours: u64, /// Path to a MaxMind GeoLite2/GeoIP2 *Country* database (`.mmdb`). When set /// (and `ADMIN_DB_PATH` is enabled), the decision-log writer stamps each /// puzzle row with the visitor's ISO country code, looked up offline on the /// already-anonymized IP, so the dashboard can show a country breakdown. /// Unset → the `country` column stays NULL and the Countries panel is empty. /// Nothing leaves the box; a /24-truncated IP still resolves country-level. pub geoip_db_path: Option, /// Client failover: honor a widget-minted "I couldn't reach you" claim at /// `/v1/verify` while an outage is attested, so our downtime doesn't take /// every embedding form down with it. **Off by default** — honoring such a /// claim is a deliberate, bounded fail-open (see [`crate::failover`]), and /// that trade is the operator's to make. Requires `FAILOVER_STATE_PATH`. pub failover_enabled: bool, /// Where the failover heartbeat + attested outage windows are persisted. /// Unset disables failover outright: without durable state a heartbeat gap /// can't be detected across the restart that caused it, and a declared /// window would evaporate on the very restart it exists to cover. pub failover_state_path: Option, /// Cadence of the liveness heartbeat written to `FAILOVER_STATE_PATH`. /// Bounds how much of a real outage goes unattested — the window starts at /// the *last* heartbeat, so a coarse cadence under-reports the outage's /// leading edge. Coerced to ≥1s (`tokio::time::interval` panics on zero). pub failover_heartbeat_interval_secs: u64, /// Minimum heartbeat gap treated as an outage. Below this, a restart is /// assumed routine (deploy, config reload) rather than downtime worth /// opening a fail-open window for. Default 60s. pub failover_min_gap_secs: u64, /// How long after an outage window closes a failover claim is still /// honored. Covers the visitor who loaded the form *during* the outage and /// submits it minutes later, once we're already back. This is also the /// blast radius: it's exactly how long the fail-open stays open after /// recovery. Default 300s. pub failover_grace_secs: u64, /// Per-site cap on *accepted* failover claims per rolling minute. Bounds /// how much traffic an attacker who catches a genuine outage can push /// through it. Default 600; `0` disables the cap. pub failover_max_per_min: u32, } impl AppConfig { pub fn from_env() -> Self { // Parse the algorithm first: the difficulty defaults are tuned per // algorithm (SHA-256 and Argon2id differ by orders of magnitude in // per-hash cost), so an unset DEFAULT_DIFFICULTY / MAX_DIFFICULTY must // follow the selected algorithm. let puzzle_algorithm = parse_algorithm_from_env(); // Retired: CORS on the public surface is always wildcard and the // per-site `allowed_origins` list is the only origin control. Warn // rather than ignore silently — an install that relied on this to // restrict `/v1/puzzle` must now set the list on each site, or every // origin can fetch puzzles for its keys. if env::var_os("CORS_ALLOWED_ORIGINS").is_some() { tracing::warn!( "CORS_ALLOWED_ORIGINS is set but no longer read — the public surface is \ always wildcard CORS. Restrict origins per site with `allowed_origins` \ (POST /v1/sites or PUT /v1/admin/sites/{{id}}/origins)." ); } Self { listen_addr: env::var("LISTEN_ADDR") .ok() .and_then(|v| v.parse().ok()) .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 3000))), static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()), puzzle_algorithm, default_difficulty: env::var("DEFAULT_DIFFICULTY") .ok() .and_then(|v| v.parse().ok()) .unwrap_or_else(|| default_difficulty_for(puzzle_algorithm)), max_difficulty: env::var("MAX_DIFFICULTY") .ok() .and_then(|v| v.parse().ok()) .unwrap_or_else(|| max_difficulty_for(puzzle_algorithm)), load_ladder: parse_load_ladder(), challenge_ttl_secs: env::var("CHALLENGE_TTL_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(300), cleanup_interval_secs: env::var("CLEANUP_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60) .max(1), max_active_challenges: env::var("MAX_ACTIVE_CHALLENGES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1_000_000), tier_checkbox_min: env::var("TIER_CHECKBOX_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(20), tier_hard_pow_min: env::var("TIER_HARD_POW_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(40), tier_block_min: env::var("TIER_BLOCK_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(85), ip_hard_limit: env::var("IP_HARD_LIMIT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(500), ip_reputation_file: env::var("IP_REPUTATION_FILE").ok(), signal_weights_file: env::var("SIGNAL_WEIGHTS_FILE").ok(), verify_shadow_min: env::var("VERIFY_SHADOW_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(30), verify_block_min: env::var("VERIFY_BLOCK_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60), verify_max_attempts: env::var("VERIFY_MAX_ATTEMPTS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(10), verify_require_behavior: parse_truthy( env::var("VERIFY_REQUIRE_BEHAVIOR").ok().as_deref(), ), tls_fingerprint_header: env::var("TLS_FINGERPRINT_HEADER").ok(), tls_fingerprint_file: env::var("TLS_FINGERPRINT_FILE").ok(), trusted_proxies: env::var("TRUSTED_PROXIES").ok(), admin_db_path: env::var("ADMIN_DB_PATH").ok(), admin_token: env::var("ADMIN_TOKEN").ok(), site_db_path: env::var("SITE_DB_PATH").ok(), dev_disable_admin_auth: parse_truthy( env::var("DEV_DISABLE_ADMIN_AUTH").ok().as_deref(), ), info_about_url: parse_info_url("INFO_ABOUT_URL"), info_privacy_url: parse_info_url("INFO_PRIVACY_URL"), info_terms_url: parse_info_url("INFO_TERMS_URL"), // Privacy default: on unless explicitly disabled. Any unset value // anonymizes; only a recognized truthy/falsey value flips it. anonymize_log_ip: env::var("ANONYMIZE_LOG_IP") .ok() .map(|v| parse_truthy(Some(&v))) .unwrap_or(true), log_retention_hours: env::var("LOG_RETENTION_HOURS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(72), training_retention_hours: env::var("TRAINING_RETENTION_HOURS") .map(|v| { v.parse() .expect("TRAINING_RETENTION_HOURS must be an integer") }) .unwrap_or(0), geoip_db_path: env::var("GEOIP_DB_PATH").ok(), failover_enabled: parse_truthy(env::var("FAILOVER_ENABLED").ok().as_deref()), failover_state_path: env::var("FAILOVER_STATE_PATH").ok(), failover_heartbeat_interval_secs: env::var("FAILOVER_HEARTBEAT_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(15) .max(1), failover_min_gap_secs: env::var("FAILOVER_MIN_GAP_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60), failover_grace_secs: env::var("FAILOVER_GRACE_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(300), failover_max_per_min: env::var("FAILOVER_MAX_PER_MIN") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(600), } .validated() } /// Boot-time validation for settings where a single mistyped env var leaves /// the process running and apparently healthy while providing zero /// protection — the worst failure mode a CAPTCHA has, because the operator /// stops watching. Each check below guards a value that is silent when /// wrong: a difficulty of 0 passes every visitor without computing a hash, /// out-of-range Argon2 params make every verification fail forever, and an /// overflowing TTL or retention window panics a handler or a background /// sweeper with no further log line. /// /// Panics with an actionable message rather than clamping, matching the /// fail-loud stance of `parse_info_url` and `parse_load_ladder`: silently /// correcting a security-relevant value hides the misconfiguration instead /// of surfacing it. fn validated(self) -> Self { // `difficulty_for` computes `default + tier_bump`, clamped by `max`, so // the lowest difficulty any tier can produce is `min(default, max)`. // Either being 0 means `has_leading_zero_bits(_, 0)` returns true for // every hash — the PoW is disabled for everyone, with no error. if self.default_difficulty < MIN_DIFFICULTY || self.max_difficulty < MIN_DIFFICULTY { panic!( "DEFAULT_DIFFICULTY={} / MAX_DIFFICULTY={} — both must be at least {MIN_DIFFICULTY}. \ A difficulty of 0 accepts any nonce without computing a hash, so every visitor \ (including every bot) passes the proof-of-work. Note MAX_DIFFICULTY clamps \ DEFAULT_DIFFICULTY, so setting it to 0 disables the PoW even when \ DEFAULT_DIFFICULTY looks correct.", self.default_difficulty, self.max_difficulty ); } // Argon2 rejects out-of-range costs at hash time, and `compute_argon2id` // degrades that to `None` → `verify() == false`. The server would boot // cleanly, advertise the invalid params to every browser, and reject // every correct solution forever without logging a reason. if let Algorithm::Argon2id(p) = self.puzzle_algorithm && let Err(e) = argon2::Params::new(p.m_cost, p.t_cost, p.p_cost, None) { panic!( "ARGON2_M_COST={} / ARGON2_T_COST={} / ARGON2_P_COST={} is not a valid Argon2id \ parameter set: {e}. The server would start and hand these to every browser, then \ fail *every* verification with no error. Valid ranges: m_cost >= {} KiB, \ t_cost >= 1, p_cost >= 1.", p.m_cost, p.t_cost, p.p_cost, argon2::Params::MIN_M_COST, ); } // `generate()` computes `now + Duration::seconds(ttl as i64)`, which // panics on overflow — taking down every /v1/puzzle request while // /healthz still reports 200. if self.challenge_ttl_secs == 0 || self.challenge_ttl_secs > MAX_CHALLENGE_TTL_SECS { panic!( "CHALLENGE_TTL_SECS={} is out of range (expected 1..={MAX_CHALLENGE_TTL_SECS}). \ A TTL of 0 expires every challenge before the client can solve it; an oversized \ value overflows the expiry timestamp and panics every puzzle request.", self.challenge_ttl_secs ); } // `DecisionLog::prune` builds a `chrono::Duration::hours`, which panics // above ~2.56e12 hours. The retention sweeper would die on its first // tick while the process stays up, so the log grows unbounded and // storage-limitation is silently unenforced. if self.log_retention_hours > MAX_LOG_RETENTION_HOURS { panic!( "LOG_RETENTION_HOURS={} is out of range (expected 0..={MAX_LOG_RETENTION_HOURS}, \ where 0 disables pruning). An oversized value panics the retention sweeper on its \ first tick — the process stays up but the decision log is never pruned again.", self.log_retention_hours ); } assert!( self.training_retention_hours <= 2160 && (self.training_retention_hours == 0 || (self.log_retention_hours > 0 && self.training_retention_hours > self.log_retention_hours)), "TRAINING_RETENTION_HOURS must be 0 (off), or greater than LOG_RETENTION_HOURS and at most 2160; training requires decision-log pruning" ); // Failover without durable state is inert: a heartbeat gap can only be // detected by comparing against a timestamp that survived the restart, // and a declared window would be lost on the very restart it covers. // Warn rather than panic — the safe direction is "no failover", which // is exactly what happens, and refusing to boot would make an // availability feature into an availability risk. if self.failover_enabled && self.failover_state_path.is_none() { tracing::warn!( "FAILOVER_ENABLED is set but FAILOVER_STATE_PATH is not — client \ failover stays OFF. Outage attestation needs state that outlives \ a restart; without it every failover claim is refused." ); } self } /// Project the failover knobs into the guard's own config type. pub fn failover_config(&self) -> crate::failover::FailoverConfig { crate::failover::FailoverConfig { enabled: self.failover_enabled, state_path: self.failover_state_path.as_ref().map(Into::into), heartbeat_interval_secs: self.failover_heartbeat_interval_secs, min_gap_secs: self.failover_min_gap_secs, grace_secs: self.failover_grace_secs, max_per_min: self.failover_max_per_min, } } } /// Lowest difficulty that still requires real work. At 0 the PoW predicate is /// a tautology, so the challenge is free to "solve". pub const MIN_DIFFICULTY: u32 = 1; /// Upper bound on `CHALLENGE_TTL_SECS` (365 days). Far above any sane challenge /// lifetime, and far below the point where `now + ttl` overflows. const MAX_CHALLENGE_TTL_SECS: u64 = 31_536_000; /// Upper bound on `LOG_RETENTION_HOURS` (10 years). Keeps both /// `chrono::Duration::hours` and the `hours * 3600 / 24` sweep cadence well /// inside their ranges. const MAX_LOG_RETENTION_HOURS: u64 = 87_600; /// Read an `INFO_*_URL` env var. Empty/whitespace-only values are treated /// as unset. Non-empty values must be absolute (`http://` or `https://`), /// otherwise we panic — a typo'd path here would silently produce a broken /// widget link in every visitor's browser, which is exactly the kind of /// misconfiguration we want to fail loud. fn parse_info_url(name: &str) -> Option { let value = env::var(name).ok()?; let trimmed = value.trim(); if trimmed.is_empty() { return None; } if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { panic!( "{name}={value:?} must be an absolute http(s):// URL — relative paths and bare \ filenames would produce broken links in the widget. Set the full URL or unset \ the variable to use the bundled /static/ default." ); } Some(trimmed.to_string()) } /// Parse `LOAD_LADDER` (e.g. `"200:20,500:22,1000:24"`) into an aggregate /// site-load difficulty floor. Unset or empty disables the floor. A malformed /// spec panics at boot — silently dropping a misconfigured load ladder would /// leave a site running without the flood protection its operator configured, /// the same fail-loud stance as `parse_info_url`. fn parse_load_ladder() -> LoadLadder { match env::var("LOAD_LADDER") { Ok(spec) => LoadLadder::parse(&spec).unwrap_or_else(|e| { panic!( "LOAD_LADDER is invalid: {e}. Expected comma-separated threshold:difficulty \ pairs in leading zero bits, e.g. \"200:20,500:22,1000:24\"." ) }), Err(_) => LoadLadder::default(), } } fn parse_truthy(v: Option<&str>) -> bool { matches!( v.map(str::trim).map(str::to_ascii_lowercase).as_deref(), Some("1" | "true" | "yes" | "on") ) } impl Default for AppConfig { fn default() -> Self { Self { listen_addr: SocketAddr::from(([0, 0, 0, 0], 3000)), static_dir: "static".to_string(), puzzle_algorithm: Algorithm::Argon2id(Argon2idParams::default()), default_difficulty: 5, max_difficulty: 10, load_ladder: LoadLadder::default(), challenge_ttl_secs: 300, cleanup_interval_secs: 60, max_active_challenges: 1_000_000, tier_checkbox_min: 20, tier_hard_pow_min: 40, tier_block_min: 85, ip_hard_limit: 500, ip_reputation_file: None, signal_weights_file: None, verify_shadow_min: 30, verify_block_min: 60, verify_max_attempts: 10, verify_require_behavior: false, tls_fingerprint_header: None, tls_fingerprint_file: None, trusted_proxies: None, admin_db_path: None, admin_token: None, site_db_path: None, dev_disable_admin_auth: false, info_about_url: None, info_privacy_url: None, info_terms_url: None, anonymize_log_ip: true, log_retention_hours: 72, training_retention_hours: 0, geoip_db_path: None, failover_enabled: false, failover_state_path: None, failover_heartbeat_interval_secs: 15, failover_min_gap_secs: 60, failover_grace_secs: 300, failover_max_per_min: 600, } } } /// Default base difficulty (leading zero bits) when `DEFAULT_DIFFICULTY` is /// unset. SHA-256 needs ~18 bits to cost a browser a few seconds; each /// Argon2id hash is orders of magnitude slower, so the same wall-clock cost /// lands near 5 bits. fn default_difficulty_for(algorithm: Algorithm) -> u32 { match algorithm { Algorithm::Sha256 => 18, Algorithm::Argon2id(_) => 5, } } /// Upper difficulty clamp when `MAX_DIFFICULTY` is unset. Argon2id's ceiling /// is far lower so a tier bump or `LOAD_LADDER` rung can't push a memory-hard /// solve into the minutes range. fn max_difficulty_for(algorithm: Algorithm) -> u32 { match algorithm { Algorithm::Sha256 => 28, Algorithm::Argon2id(_) => 10, } } /// Parse `PUZZLE_ALGORITHM` (default `argon2id`). SHA-256 stays available via /// `PUZZLE_ALGORITHM=sha256`, but is no longer the default: it verifies fast /// yet is trivially GPU-parallelised, so it taxes honest browsers far more /// than attackers. Argon2id is memory-hard, which collapses that asymmetry. /// Unknown values fall back to argon2id with a warning printed to stderr at /// boot — the rest of the service uses tracing, but this runs before the /// subscriber is up. fn parse_algorithm_from_env() -> Algorithm { match env::var("PUZZLE_ALGORITHM").as_deref() { Ok("sha256") => Algorithm::Sha256, Ok("argon2id") | Err(_) => argon2id_from_env(), Ok(other) => { eprintln!("PUZZLE_ALGORITHM={other:?} is unknown — defaulting to argon2id"); argon2id_from_env() } } } /// Build the Argon2id algorithm variant, reading `ARGON2_M_COST` / /// `ARGON2_T_COST` / `ARGON2_P_COST` (each falling back to the tuned default). fn argon2id_from_env() -> Algorithm { let defaults = Argon2idParams::default(); Algorithm::Argon2id(Argon2idParams { m_cost: env::var("ARGON2_M_COST") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(defaults.m_cost), t_cost: env::var("ARGON2_T_COST") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(defaults.t_cost), p_cost: env::var("ARGON2_P_COST") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(defaults.p_cost), }) } #[cfg(test)] mod tests { use super::*; /// Each case below is a value that leaves the process running and healthy /// while silently providing no protection (or refusing every solve), which /// is why they abort the boot rather than being clamped. #[test] #[should_panic(expected = "must be at least 1")] fn rejects_zero_max_difficulty() { // MAX_DIFFICULTY clamps DEFAULT_DIFFICULTY, so a zero here disables the // PoW even though DEFAULT_DIFFICULTY looks correct — the subtle case. AppConfig { max_difficulty: 0, ..AppConfig::default() } .validated(); } #[test] #[should_panic(expected = "must be at least 1")] fn rejects_zero_default_difficulty() { AppConfig { default_difficulty: 0, ..AppConfig::default() } .validated(); } #[test] #[should_panic(expected = "not a valid Argon2id parameter set")] fn rejects_out_of_range_argon2_params() { AppConfig { puzzle_algorithm: Algorithm::Argon2id(Argon2idParams { m_cost: 1, t_cost: 2, p_cost: 1, }), ..AppConfig::default() } .validated(); } #[test] #[should_panic(expected = "CHALLENGE_TTL_SECS")] fn rejects_overflowing_ttl() { AppConfig { challenge_ttl_secs: u64::MAX, ..AppConfig::default() } .validated(); } #[test] #[should_panic(expected = "CHALLENGE_TTL_SECS")] fn rejects_zero_ttl() { AppConfig { challenge_ttl_secs: 0, ..AppConfig::default() } .validated(); } #[test] #[should_panic(expected = "LOG_RETENTION_HOURS")] fn rejects_overflowing_retention() { AppConfig { log_retention_hours: u64::MAX, ..AppConfig::default() } .validated(); } #[test] fn training_retention_requires_a_bounded_opt_in() { for (training, log) in [(2161, 72), (720, 0), (72, 72)] { assert!( std::panic::catch_unwind(|| AppConfig { training_retention_hours: training, log_retention_hours: log, ..AppConfig::default() } .validated()) .is_err() ); } AppConfig { training_retention_hours: 720, ..AppConfig::default() } .validated(); } #[test] fn accepts_defaults_and_documented_edge_values() { // The shipped defaults must pass, or every deployment breaks on upgrade. AppConfig::default().validated(); // 0 is the documented "disable pruning" value and must stay valid. AppConfig { log_retention_hours: 0, ..AppConfig::default() } .validated(); // A difficulty of exactly 1 is the lowest that still requires work. AppConfig { default_difficulty: 1, max_difficulty: 1, ..AppConfig::default() } .validated(); // SHA-256 deployments carry no Argon2 params to validate. AppConfig { puzzle_algorithm: Algorithm::Sha256, default_difficulty: 18, max_difficulty: 28, ..AppConfig::default() } .validated(); } }