Ctrl K
ComunidadGuías…

src/api/types.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
use std::net::IpAddr;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::dashboard::types::FeedbackVerdict;
use crate::error::CaptchaError;
use crate::puzzle::types::Algorithm;
use crate::risk::{BehaviorReport, EscalationTier, VerifyDecision};
use crate::site::types::SitePolicy;

// --- Requests ---

#[derive(Debug, Deserialize)]
pub struct GetPuzzleParams {
    pub site_key: Uuid,
    /// Id of the challenge this request replaces, sent by the widget when it
    /// refreshes shortly before expiry. Purely an optimisation for dwell
    /// accounting: the new challenge inherits the cited one's `dwell_since`,
    /// so a visitor who has had the form open for five minutes isn't scored
    /// as having arrived at the moment of the refresh.
    ///
    /// Honoured only if the cited challenge exists and belongs to the same
    /// site; anything else is ignored silently rather than erroring, so a
    /// stale, consumed or forged id degrades to a normal first issuance.
    /// Nothing here is trusted — the id proves possession of a live
    /// same-site challenge, and that's the whole of what it grants.
    #[serde(default)]
    pub refresh_of: Option<Uuid>,
}

/// Verify request. Two shapes are accepted:
///
/// 1. **Opaque token** (the widget path): a single `token` string the widget
///    wrote into the hidden form field. The form host forwards it verbatim —
///    no parsing required. When present it is the sole source of truth and the
///    explicit fields below are ignored.
/// 2. **Explicit fields** (server-to-server path): `challenge_id` plus
///    `nonce` and optional `honeypot`/`behavior`, for callers that build the
///    request themselves.
///
/// Note there is no `time_on_page_ms`: dwell time is derived server-side from
/// the challenge's issuance timestamp, so a client can't claim a longer dwell
/// than actually elapsed.
#[derive(Debug, Deserialize)]
pub struct VerifyRequest {
    /// Opaque token produced by the widget (hex-encoded JSON). Takes
    /// precedence over the explicit fields when present.
    #[serde(default)]
    pub token: Option<String>,
    /// Required when `token` is absent (and `failover` is false).
    #[serde(default)]
    pub challenge_id: Option<Uuid>,
    /// PoW nonce.
    #[serde(default)]
    pub nonce: u64,
    #[serde(default)]
    pub honeypot: Option<String>,
    /// Compact behavioural telemetry collected by the widget between mount
    /// and submit. Absent for non-widget integrations.
    #[serde(default)]
    pub behavior: Option<BehaviorReport>,
    /// Marks this as a *failover claim* rather than a solved puzzle: the
    /// widget could not reach `/v1/puzzle` at all, so there is no challenge and
    /// no nonce. See [`crate::failover`] — this flag is client-authored and
    /// proves nothing on its own; the server honors it only against its own
    /// attested outage record.
    #[serde(default)]
    pub failover: bool,
    /// Site the failover claim was minted for. Required when `failover`.
    #[serde(default)]
    pub site_key: Option<Uuid>,
    /// Client-supplied mint time (unix ms). Sanity-checked only — forgeable.
    #[serde(default)]
    pub issued_at: Option<i64>,
    /// The visitor's address as the integrator's backend saw it — the one
    /// they would rate-limit on. Only ever read from the outer request body,
    /// never from inside the opaque `token`: the token is client-authored, so
    /// a client that could name its own remote IP would name the issuing one.
    /// Optional; absent disables the check.
    #[serde(default)]
    pub remote_ip: Option<IpAddr>,
}

/// Inner payload carried by the opaque `token`. The widget hex-encodes the
/// JSON form of this so the form host treats it as an opaque blob.
///
/// One shape covers both a solved puzzle and a failover claim, discriminated
/// by `failover`, so the form host forwards a single opaque string either way
/// and needs no branching of its own.
#[derive(Debug, Deserialize)]
struct TokenPayload {
    #[serde(default)]
    challenge_id: Option<Uuid>,
    #[serde(default)]
    nonce: u64,
    #[serde(default)]
    honeypot: Option<String>,
    #[serde(default)]
    behavior: Option<BehaviorReport>,
    #[serde(default)]
    failover: bool,
    #[serde(default)]
    site_key: Option<Uuid>,
    #[serde(default)]
    issued_at: Option<i64>,
}

/// A normally-solved submission: the client held a challenge and produced a
/// nonce for it. `time_on_page_ms` is deliberately absent — it's computed
/// server-side from the challenge's `created_at`.
pub struct SolvedVerify {
    pub challenge_id: Uuid,
    pub nonce: u64,
    pub honeypot: Option<String>,
    pub behavior: Option<BehaviorReport>,
    /// See [`VerifyRequest::remote_ip`]; always the outer field.
    pub remote_ip: Option<IpAddr>,
}

/// A claim that the widget could not reach this service at all.
///
/// There is no challenge and no proof of work here — by construction there
/// cannot be, since the outage is precisely that we never issued one. The
/// honeypot and behaviour blob still ride along: the widget collects them
/// locally regardless of connectivity, so they remain the only real evidence
/// available on this path and are scored before the claim is honored.
pub struct FailoverClaim {
    pub site_key: Uuid,
    pub issued_at_ms: i64,
    pub honeypot: Option<String>,
    pub behavior: Option<BehaviorReport>,
}

/// Normalised verify request. The two arms are kept distinct so the handler
/// cannot accidentally treat an unauthenticated failover claim as a solved
/// puzzle — the whole security question on that path is which branch you're in.
pub enum ResolvedVerify {
    Solved(SolvedVerify),
    Failover(FailoverClaim),
}

impl VerifyRequest {
    /// Resolve into the normalised form. Decodes the opaque token when
    /// present, otherwise falls back to the explicit fields.
    pub fn resolve(self) -> Result<ResolvedVerify, CaptchaError> {
        // Treat an empty/whitespace token as absent so a caller that sends
        // `"token": ""` alongside explicit `challenge_id`/`nonce` falls through
        // to those fields instead of failing on an unparsable empty token.
        if let Some(token) = self
            .token
            .as_deref()
            .map(str::trim)
            .filter(|t| !t.is_empty())
        {
            let bytes = hex::decode(token)
                .map_err(|_| CaptchaError::BadRequest("invalid captcha token".into()))?;
            let p: TokenPayload = serde_json::from_slice(&bytes)
                .map_err(|_| CaptchaError::BadRequest("invalid captcha token".into()))?;
            if p.failover {
                return Ok(ResolvedVerify::Failover(FailoverClaim {
                    site_key: p.site_key.ok_or_else(|| {
                        CaptchaError::BadRequest("failover token requires site_key".into())
                    })?,
                    issued_at_ms: p.issued_at.unwrap_or(0),
                    honeypot: p.honeypot,
                    behavior: p.behavior,
                }));
            }
            Ok(ResolvedVerify::Solved(SolvedVerify {
                challenge_id: p
                    .challenge_id
                    .ok_or_else(|| CaptchaError::BadRequest("invalid captcha token".into()))?,
                nonce: p.nonce,
                honeypot: p.honeypot,
                behavior: p.behavior,
                remote_ip: self.remote_ip,
            }))
        } else if self.failover {
            Ok(ResolvedVerify::Failover(FailoverClaim {
                site_key: self
                    .site_key
                    .ok_or_else(|| CaptchaError::BadRequest("failover requires site_key".into()))?,
                issued_at_ms: self.issued_at.unwrap_or(0),
                honeypot: self.honeypot,
                behavior: self.behavior,
            }))
        } else {
            let challenge_id = self.challenge_id.ok_or_else(|| {
                CaptchaError::BadRequest("challenge_id or token is required".into())
            })?;
            Ok(ResolvedVerify::Solved(SolvedVerify {
                challenge_id,
                nonce: self.nonce,
                honeypot: self.honeypot,
                behavior: self.behavior,
                remote_ip: self.remote_ip,
            }))
        }
    }
}

/// Body for `POST /v1/admin/outages`. Either `duration_secs` (a window ending
/// now) or an explicit `start`/`end` pair.
#[derive(Debug, Deserialize)]
pub struct DeclareOutageRequest {
    #[serde(default)]
    pub duration_secs: Option<u64>,
    #[serde(default)]
    pub start: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(default)]
    pub end: Option<chrono::DateTime<chrono::Utc>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct OutagesResponse {
    /// False when `FAILOVER_ENABLED` is off or no state path is configured —
    /// in which case every failover claim is refused regardless of windows.
    pub enabled: bool,
    pub grace_secs: u64,
    /// Windows that can still cover a claim (open, or within their grace tail).
    pub windows: Vec<crate::failover::OutageWindow>,
    pub accepted_total: u64,
    pub refused_total: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateSiteRequest {
    pub name: String,
    /// Optional browser-origin allowlist (`http(s)://host[:port]` entries).
    /// Empty/omitted = allow any origin. Validated + normalized in the handler.
    #[serde(default)]
    pub allowed_origins: Vec<String>,
    /// Optional per-site scoring overrides. Omitted = inherit every env
    /// default. Validated against the running config in the handler.
    #[serde(default)]
    pub policy: SitePolicy,
}

// --- Responses ---

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct InfoUrls {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub about: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub privacy: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub terms: Option<String>,
}

impl InfoUrls {
    pub fn is_empty(&self) -> bool {
        self.about.is_none() && self.privacy.is_none() && self.terms.is_none()
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct PuzzleResponse {
    pub challenge_id: Uuid,
    pub algorithm: Algorithm,
    pub prefix: String,
    pub difficulty: u32,
    pub expires_at: String,
    /// Challenge lifetime in seconds, measured from issuance. The widget
    /// schedules its pre-expiry refresh from this rather than comparing
    /// `expires_at` against the client clock, which may be skewed.
    pub expires_in_secs: u64,
    pub tier: EscalationTier,
    /// Operator-overridden URLs for the about/privacy/terms pages, when
    /// `INFO_*_URL` env vars are set. Per-field optional — widget falls
    /// back to bundled `/static/*.html` for unset fields.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub info_urls: Option<InfoUrls>,
}

/// Body returned alongside a 429 in the `Block` tier so the widget can
/// still surface operator-overridden info URLs to a user it just blocked.
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockedResponse {
    pub error: String,
    pub tier: EscalationTier,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub info_urls: Option<InfoUrls>,
}

/// The verify-time verdict as a band, so an integrator can step up — email
/// confirmation, a review queue, tighter downstream limits — on a submission
/// the service accepted but did not like. Before this field a `ShadowFail`
/// was invisible outside the operator's WARN log, so nobody downstream could
/// act on it. `success` stays the enforcement decision; `risk` is the reason
/// behind it, which is why `high` can arrive with `success: true` (the site
/// is in monitor mode) and why an invalid proof of work carries no band at
/// all — a wrong nonce is a failed proof, not a judgement about the visitor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskBand {
    Low,
    Elevated,
    High,
}

impl From<VerifyDecision> for RiskBand {
    fn from(d: VerifyDecision) -> Self {
        match d {
            VerifyDecision::Pass => RiskBand::Low,
            VerifyDecision::ShadowFail => RiskBand::Elevated,
            VerifyDecision::Block => RiskBand::High,
        }
    }
}

/// `POST /v1/feedback`: an integrator telling the service what a submission
/// it already decided turned out to be — the one source of ground truth the
/// scorer has. See `CONFIGURATION.md` → *Feedback and training samples*.
#[derive(Debug, Deserialize)]
pub struct FeedbackRequest {
    pub challenge_id: Uuid,
    pub verdict: FeedbackVerdict,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct VerifyResponse {
    pub success: bool,
    /// True when `success` was granted on the failover path — i.e. this
    /// visitor never solved a puzzle, because the service was attestably
    /// unreachable when they loaded the form. Integrators who care about the
    /// difference (accept-but-flag, extra review, tighter downstream limits)
    /// should branch on this; those who don't can keep reading `success`
    /// alone and get availability by default.
    #[serde(default)]
    pub failover: bool,
    /// See [`RiskBand`]. Absent only when there was no risk verdict to
    /// report (an invalid proof of work).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub risk: Option<RiskBand>,
    /// The challenge this verdict is about — the handle for `POST
    /// /v1/feedback`, so an integrator can label the decision later without
    /// decoding the token themselves. Absent on the failover path, which has
    /// no challenge.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub challenge_id: Option<Uuid>,
    /// Names of the verify-time signals that contributed to `risk` (e.g.
    /// `honeypot`, `time_on_page`, `behavior`, `remote_ip`). The names are a
    /// stable contract; the weights behind them are not, so none are exposed.
    /// Absent when nothing fired, on the failover path and on an invalid
    /// proof of work.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub signals: Vec<String>,
}

impl VerifyResponse {
    /// The ordinary path: `failover` is false for every solved-puzzle verdict.
    pub fn solved(
        challenge_id: Uuid,
        success: bool,
        decision: VerifyDecision,
        signals: Vec<String>,
    ) -> Self {
        Self {
            success,
            failover: false,
            risk: Some(decision.into()),
            challenge_id: Some(challenge_id),
            signals,
        }
    }

    /// A wrong nonce: refused before any scoring ran, so there is no band.
    pub fn pow_invalid(challenge_id: Uuid) -> Self {
        Self {
            success: false,
            failover: false,
            risk: None,
            challenge_id: Some(challenge_id),
            signals: Vec::new(),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSiteResponse {
    pub site_key: Uuid,
    pub secret_key: String,
    /// Echoes back the normalized origin allowlist (empty = any origin).
    pub allowed_origins: Vec<String>,
    /// Echoes back the accepted policy. Serializes to `{}` when the site
    /// inherits everything, so the caller can see exactly what was stored.
    #[serde(default)]
    pub policy: SitePolicy,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A `VerifyRequest` with everything unset, so each test only states the
    /// fields it actually cares about.
    fn req() -> VerifyRequest {
        VerifyRequest {
            token: None,
            challenge_id: None,
            nonce: 0,
            honeypot: None,
            behavior: None,
            failover: false,
            site_key: None,
            issued_at: None,
            remote_ip: None,
        }
    }

    #[test]
    fn remote_ip_is_read_from_the_body_never_from_the_token() {
        let cid = Uuid::new_v4();
        let payload = serde_json::json!({
            "challenge_id": cid,
            "nonce": 7,
            "remote_ip": "203.0.113.9",
        });
        let token = hex::encode(serde_json::to_vec(&payload).unwrap());

        let solved = expect_solved(
            VerifyRequest {
                token: Some(token.clone()),
                ..req()
            }
            .resolve()
            .unwrap(),
        );
        assert_eq!(
            solved.remote_ip, None,
            "a token cannot name its own remote IP"
        );

        let outer: IpAddr = "198.51.100.4".parse().unwrap();
        let solved = expect_solved(
            VerifyRequest {
                token: Some(token),
                remote_ip: Some(outer),
                ..req()
            }
            .resolve()
            .unwrap(),
        );
        assert_eq!(solved.remote_ip, Some(outer));
    }

    fn expect_solved(r: ResolvedVerify) -> SolvedVerify {
        match r {
            ResolvedVerify::Solved(s) => s,
            ResolvedVerify::Failover(_) => panic!("expected a solved verify"),
        }
    }

    fn expect_failover(r: ResolvedVerify) -> FailoverClaim {
        match r {
            ResolvedVerify::Failover(f) => f,
            ResolvedVerify::Solved(_) => panic!("expected a failover claim"),
        }
    }

    #[test]
    fn resolve_empty_token_falls_through_to_explicit_fields() {
        // `"token": ""` must not shadow the explicit challenge_id/nonce.
        let cid = Uuid::new_v4();
        let resolved = VerifyRequest {
            token: Some(String::new()),
            challenge_id: Some(cid),
            nonce: 42,
            ..req()
        }
        .resolve()
        .expect("empty token falls through");
        let solved = expect_solved(resolved);
        assert_eq!(solved.challenge_id, cid);
        assert_eq!(solved.nonce, 42);
    }

    #[test]
    fn resolve_whitespace_token_falls_through() {
        let cid = Uuid::new_v4();
        let resolved = VerifyRequest {
            token: Some("   ".into()),
            challenge_id: Some(cid),
            nonce: 7,
            ..req()
        }
        .resolve()
        .unwrap();
        assert_eq!(expect_solved(resolved).challenge_id, cid);
    }

    #[test]
    fn resolve_missing_token_and_challenge_id_is_bad_request() {
        assert!(matches!(req().resolve(), Err(CaptchaError::BadRequest(_))));
    }

    #[test]
    fn failover_token_resolves_to_a_claim_not_a_solve() {
        let site = Uuid::new_v4();
        let payload = serde_json::json!({
            "failover": true,
            "site_key": site,
            "issued_at": 1_700_000_000_000i64,
        });
        let token = hex::encode(serde_json::to_vec(&payload).unwrap());
        let claim = expect_failover(
            VerifyRequest {
                token: Some(token),
                ..req()
            }
            .resolve()
            .unwrap(),
        );
        assert_eq!(claim.site_key, site);
        assert_eq!(claim.issued_at_ms, 1_700_000_000_000);
    }

    #[test]
    fn failover_token_without_site_key_is_rejected() {
        let payload = serde_json::json!({ "failover": true, "issued_at": 1i64 });
        let token = hex::encode(serde_json::to_vec(&payload).unwrap());
        assert!(matches!(
            VerifyRequest {
                token: Some(token),
                ..req()
            }
            .resolve(),
            Err(CaptchaError::BadRequest(_))
        ));
    }

    #[test]
    fn non_failover_token_without_challenge_id_is_rejected() {
        // `challenge_id` became Option to make room for the failover arm; a
        // solved token that omits it must still be a hard error rather than
        // silently resolving to a nil UUID.
        let payload = serde_json::json!({ "nonce": 5u64 });
        let token = hex::encode(serde_json::to_vec(&payload).unwrap());
        assert!(matches!(
            VerifyRequest {
                token: Some(token),
                ..req()
            }
            .resolve(),
            Err(CaptchaError::BadRequest(_))
        ));
    }

    #[test]
    fn explicit_failover_fields_resolve_to_a_claim() {
        let site = Uuid::new_v4();
        let claim = expect_failover(
            VerifyRequest {
                failover: true,
                site_key: Some(site),
                issued_at: Some(42),
                ..req()
            }
            .resolve()
            .unwrap(),
        );
        assert_eq!(claim.site_key, site);
        assert_eq!(claim.issued_at_ms, 42);
    }
}