Skip to main content

axond_fuzz_seam/
fuzz_seam.rs

1//! A private seam that lets the out-of-tree fuzz project drive the untrusted
2//! parsers this binary runs on its request and boot paths.
3//!
4//! Axond ships as a binary, so its modules have no library target a fuzz
5//! harness could link against. This file is a second target over the very same
6//! module sources (`[lib]` in `Cargo.toml`), compiled only when the
7//! `fuzzing` feature is on: a default build, the published `.crate`, and every
8//! consumer see an empty library, so nothing here widens the published API. The
9//! feature is not part of the compatibility contract and must not be enabled by
10//! anything but [`fuzz/`](https://github.com/Litvue/axond/tree/main/fuzz).
11//!
12//! Every entry point below returns a typed, owned outcome rather than an
13//! internal type, for two reasons: the fuzz project asserts on the *shape* of a
14//! rejection (a recoverable error, never a panic or an abort), and the internal
15//! types stay free to change without a fuzz-side edit.
16//!
17//! No entry point performs I/O, reads the environment, or touches a real
18//! secret: the verifier material below is committed synthetic test material.
19#![cfg(fuzzing)]
20// Only the handful of items the seams below reach are live in this target; the
21// rest of the crate is compiled for its `crate::` paths. A re-export whose only
22// consumer is `main.rs` is unused here for the same reason, since this target
23// compiles the modules without the binary that drives them.
24#![allow(dead_code, unused_imports)]
25
26// Keep this list identical to `main.rs`. `tests/fuzz_seam.rs` fails if it drifts.
27mod admin;
28mod admission;
29mod aliases;
30mod availability;
31mod backends;
32mod budget;
33mod config;
34mod convergence;
35mod credentials;
36mod desired_state;
37mod error;
38mod key_material;
39mod mint;
40mod ops;
41mod policy;
42mod principals;
43mod rate_limit;
44mod redis_support;
45mod reload;
46mod revocation;
47mod routes;
48mod shutdown;
49mod state;
50mod status;
51mod streaming;
52// The layer re-export this module makes for `main.rs` has no consumer here.
53#[allow(unused_imports)]
54mod telemetry;
55mod usage;
56
57use std::collections::HashMap;
58use std::sync::OnceLock;
59use std::time::Duration;
60
61use crate::config::Config;
62use crate::mint::{MintAlgorithm, MintRequest};
63use crate::principals::{
64    Presented, PrincipalStore, PrincipalStoreError, TokenVerificationError, TokenVerifier,
65};
66
67/// How a parser refused an input: the variant carries the class of failure, the
68/// string carries the operator-facing message the process would have logged.
69///
70/// Presence of a variant is the fuzz assertion — a refusal is a value, so it
71/// cannot have unwound, aborted, or exited.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Rejection {
74    /// The input was not loadable at all (TOML syntax, wrong types, unknown keys).
75    Load(String),
76    /// The input parsed but failed a whole-graph invariant.
77    Invalid(String),
78    /// The input was refused as a bad request, the way a caller would see it.
79    BadRequest(String),
80    /// Authentication failed. The payload is the stable error code.
81    Unauthenticated(&'static str),
82    /// Authentication succeeded and authorization failed. Stable error code.
83    Unauthorized(&'static str),
84    /// A store the check needs was unavailable. Unreachable through this seam,
85    /// which is stateless, and mapped rather than asserted away.
86    Unavailable,
87}
88
89/// What a config the fuzzer produced turned into, without exposing [`Config`].
90///
91/// The mode is part of the shape because it selects which invariants the
92/// validator applied: stateless mode owns its resources in TOML, stateful mode
93/// forbids every one of those sections (ADR 0027).
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct ConfigShape {
96    pub stateful: bool,
97    pub namespaces: usize,
98    pub providers: usize,
99    pub models: usize,
100    pub credentials: usize,
101    pub gateway_keys: usize,
102    pub verifiers: usize,
103}
104
105/// Parse and validate untrusted configuration text, the way `axond` does at
106/// boot and on reload.
107///
108/// # Errors
109///
110/// [`Rejection::Load`] for text the loader refuses, [`Rejection::Invalid`] for
111/// a config that loads but fails validation.
112pub fn config_from_toml_str(input: &str) -> Result<ConfigShape, Rejection> {
113    match Config::from_toml_str(input) {
114        Ok(config) => Ok(ConfigShape {
115            stateful: config.mode == config::Mode::Stateful,
116            namespaces: config.namespace.len(),
117            providers: config.provider.len(),
118            models: config.model.len(),
119            credentials: config.credential.len(),
120            gateway_keys: config.gateway_key.len(),
121            verifiers: config.gateway_verifier.len(),
122        }),
123        Err(config::ConfigError::Load(message)) => Err(Rejection::Load(message)),
124        Err(config::ConfigError::Invalid(message)) => Err(Rejection::Invalid(message)),
125    }
126}
127
128/// Parse the `namespaces` filter out of an untrusted `GET
129/// /v1/credentials/status` query string, percent-decoding included.
130///
131/// `None` means the caller sent no `namespaces` parameter; `Some` carries the
132/// decoded value, which may be empty.
133///
134/// # Errors
135///
136/// [`Rejection::BadRequest`] for a duplicate parameter or an undecodable value.
137pub fn credentials_query_namespaces(raw_query: Option<&str>) -> Result<Option<String>, Rejection> {
138    routes::fuzz_parse_credential_query(raw_query)
139        .map_err(|error| Rejection::BadRequest(error.to_string()))
140}
141
142/// What a token that verified carried, without exposing `InboundKey`.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct VerifiedToken {
145    pub namespace: String,
146    pub subject: String,
147    pub capabilities: usize,
148    pub scoped_aliases: bool,
149    pub max_request_microdollars: Option<u64>,
150}
151
152/// Verify an untrusted `axt1.` credential against the seam's synthetic
153/// verifiers: one HS256 signer, one EdDSA signer, two namespaces, and an
154/// issuance epoch, so signature, algorithm, audience, lifetime, namespace,
155/// scope, and epoch checks are all reachable.
156///
157/// # Errors
158///
159/// [`Rejection::Unauthenticated`] or [`Rejection::Unauthorized`], carrying the
160/// same stable code the gateway would answer with.
161pub fn verify_token(credential: &str) -> Result<Option<VerifiedToken>, Rejection> {
162    let presented = Presented { credential };
163    let resolved = futures::executor::block_on(verifier().resolve(&presented));
164    match resolved {
165        Ok(None) => Ok(None),
166        Ok(Some(key)) => Ok(Some(VerifiedToken {
167            namespace: key.namespace,
168            subject: key.subject,
169            capabilities: key.scope.map_or(0, |scope| scope.len()),
170            scoped_aliases: key.alias_scope.is_some(),
171            max_request_microdollars: key.max_request_microdollars,
172        })),
173        Err(PrincipalStoreError::Unauthorized(error)) => {
174            Err(Rejection::Unauthenticated(code(&error)))
175        }
176        Err(PrincipalStoreError::Forbidden(error)) => Err(Rejection::Unauthorized(code(&error))),
177        Err(PrincipalStoreError::Unavailable) => Err(Rejection::Unavailable),
178    }
179}
180
181/// Mint an `axt1.` credential with the seam's synthetic HS256 signer so a
182/// fuzzer can reach the claim checks that sit past signature verification.
183///
184/// `scope` is written into the claim **verbatim**: a name the capability
185/// vocabulary does not define has to reach the verifier, because discarding it
186/// here would leave the verifier's own handling of an unknown capability
187/// unfuzzed.
188///
189/// Returns `None` when the requested claims cannot be encoded at all, which is
190/// a rejection of the fuzzer's request rather than a finding.
191pub fn mint_hs256_token(
192    namespace: &str,
193    subject: &str,
194    audience: &str,
195    ttl_seconds: u64,
196    issued_at: Option<u64>,
197    scope: Option<Vec<String>>,
198    aliases: Option<Vec<String>>,
199) -> Option<String> {
200    mint::fuzz_mint_token_with_raw_scope(
201        MintRequest {
202            kid: HS256_KID,
203            algorithm: MintAlgorithm::Hs256,
204            key_material: HS256_MATERIAL,
205            namespace,
206            subject,
207            audience,
208            ttl: Duration::from_secs(ttl_seconds),
209            aliases,
210            max_request_microdollars: None,
211            // Ignored by the raw-scope mint, which takes the claim below.
212            scope: None,
213        },
214        issued_at,
215        scope,
216    )
217    .ok()
218    .map(|minted| minted.token)
219}
220
221/// Re-sign a committed token seed with its timestamps *translated* onto the
222/// current run, so the claim check the seed is named for is the one it reaches.
223///
224/// A committed `axt1.` token expires the moment the date passes its `exp`, after
225/// which every seed collapses onto the expiry check and the checks behind it —
226/// scope, aliases, subject, `jti`, namespace, issuance epoch — go unexercised.
227/// Translating rather than replacing the timestamps is what preserves each
228/// seed's intent: the offset that moves `iat` onto now is applied to `exp` too,
229/// so `exp - iat` is unchanged and a seed built to sit past the lifetime ceiling
230/// still does, while one built with `exp` before `iat` still is.
231///
232/// The header is carried over verbatim and the payload is *not* verified first —
233/// that is the point, since the interesting seeds are the ones a verifier would
234/// refuse. Returns `None` when the seed is not a signable `axt1.` JWS with a
235/// numeric `iat`, which is most of the corpus and not a finding.
236pub fn resign_seed_onto_this_run(token: &str) -> Option<String> {
237    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
238
239    let mut segments = token.strip_prefix("axt1.")?.split('.');
240    let header: jsonwebtoken::Header =
241        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments.next()?).ok()?).ok()?;
242    let mut claims: serde_json::Map<String, serde_json::Value> =
243        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments.next()?).ok()?).ok()?;
244    let iat = claims.get("iat")?.as_u64()?;
245    let now = std::time::SystemTime::now()
246        .duration_since(std::time::UNIX_EPOCH)
247        .ok()?
248        .as_secs();
249    // Signed, and in a wider type: an `exp` deliberately placed *before* its
250    // `iat` has to stay before it, and a saturating unsigned subtraction would
251    // quietly move it onto `iat` instead.
252    let offset = i128::from(now) - i128::from(iat);
253    let shift = |value: &mut serde_json::Value| {
254        if let Some(seconds) = value.as_u64() {
255            let shifted = (i128::from(seconds) + offset).clamp(0, i128::from(u64::MAX));
256            *value = serde_json::Value::from(u64::try_from(shifted).unwrap_or(0));
257        }
258    };
259    for claim in ["iat", "exp", "nbf"] {
260        if let Some(value) = claims.get_mut(claim) {
261            shift(value);
262        }
263    }
264    let kid = header.kid.clone().unwrap_or_else(|| HS256_KID.to_owned());
265    mint::fuzz_sign_claims(
266        &header,
267        &serde_json::Value::Object(claims),
268        MintAlgorithm::Hs256,
269        HS256_MATERIAL,
270        &kid,
271    )
272    .ok()
273}
274
275/// The audience the seam's verifiers accept, so a fuzzer can aim at the
276/// audience check from either side.
277pub const AUDIENCE: &str = "fuzz.axond.invalid";
278
279/// The namespaces the seam's config defines. `denied` is deliberately outside
280/// the HS256 signer's permitted set.
281pub const NAMESPACES: [&str; 2] = ["fuzz", "denied"];
282
283/// How many capabilities the scope vocabulary defines, which bounds what any
284/// token can present however its `scope` claim is shaped.
285pub const CAPABILITY_COUNT: usize = principals::Capability::ALL.len();
286
287/// The longest lifetime the seam's verifiers accept, so a fuzzer can mint a
288/// token that straddles the issuance epoch without tripping the lifetime check
289/// on the way there.
290pub const MAX_TTL_SECONDS: u64 = 900;
291
292/// The issuance epoch the seam declares for [`NAMESPACES`]`[0]`, as unix
293/// seconds: a token this namespace's signer produced *before* this instant is
294/// refused with `token_issued_before_epoch`.
295///
296/// It is anchored to the run rather than committed, because the check sits
297/// behind the lifetime check — a fixed past epoch is unreachable, since any
298/// token old enough to precede it is either expired or over its TTL. Resolved
299/// once per process, so a replay stays internally consistent.
300pub fn epoch_min_iat() -> u64 {
301    static MIN_IAT: OnceLock<u64> = OnceLock::new();
302    *MIN_IAT.get_or_init(|| {
303        std::time::SystemTime::now()
304            .duration_since(std::time::UNIX_EPOCH)
305            .map_or(0, |since| since.as_secs())
306            .saturating_sub(EPOCH_LOOKBACK_SECONDS)
307    })
308}
309
310/// How far behind the start of a run the issuance epoch sits. Long enough that
311/// a token minted before it still has a live `exp` inside [`MAX_TTL_SECONDS`],
312/// short enough that an ordinary minted token lands after it.
313const EPOCH_LOOKBACK_SECONDS: u64 = 300;
314
315/// The `kid` of the seam's HS256 signer.
316pub const HS256_KID: &str = "fuzz-hs256";
317
318/// The `kid` of the seam's EdDSA verifier, whose private half does not exist.
319pub const EDDSA_KID: &str = "fuzz-eddsa";
320
321/// Synthetic HS256 material. Not a secret: it is committed, published in the
322/// fuzz corpus, and accepted by nothing but this seam.
323const HS256_MATERIAL: &str = "axond-fuzz-hs256-material-not-a-secret";
324
325/// A synthetic 32-byte Ed25519 public key. There is no matching private key in
326/// this repository, so every EdDSA signature the fuzzer produces is invalid by
327/// construction — which is the point: it keeps the signature-failure path hot.
328const EDDSA_PUBLIC_BASE64: &str = "ZnV6ei1heG9uZC1lZDI1NTE5LXB1YmxpYy1rZXktMzI=";
329
330const CONFIG: &str = r#"
331[[namespace]]
332id = "fuzz"
333default = true
334
335[[namespace]]
336id = "denied"
337
338[[gateway_key]]
339env = "AXOND_FUZZ_STATIC_KEY"
340namespace = "fuzz"
341
342[gateway_token]
343audience = "fuzz.axond.invalid"
344
345[[gateway_verifier]]
346kid = "fuzz-hs256"
347alg = "HS256"
348env = "AXOND_FUZZ_HS256"
349namespaces = ["fuzz"]
350max_ttl = "15m"
351
352[[gateway_verifier]]
353kid = "fuzz-eddsa"
354alg = "EdDSA"
355env = "AXOND_FUZZ_EDDSA"
356namespaces = ["fuzz", "denied"]
357max_ttl = "15m"
358
359[[gateway_token_epoch]]
360namespace = "fuzz"
361min_iat = {MIN_IAT}
362"#;
363
364fn verifier() -> &'static TokenVerifier {
365    static VERIFIER: OnceLock<TokenVerifier> = OnceLock::new();
366    VERIFIER.get_or_init(|| {
367        // The epoch is the one value that cannot be committed: see
368        // [`epoch_min_iat`].
369        let text = CONFIG.replace("{MIN_IAT}", &epoch_min_iat().to_string());
370        let config = Config::from_toml_str(&text).expect("the seam's own config is valid");
371        let env = HashMap::from([
372            (
373                "AXOND_FUZZ_STATIC_KEY".to_owned(),
374                "axond-fuzz-static-key-not-a-secret".to_owned(),
375            ),
376            ("AXOND_FUZZ_HS256".to_owned(), HS256_MATERIAL.to_owned()),
377            (
378                "AXOND_FUZZ_EDDSA".to_owned(),
379                EDDSA_PUBLIC_BASE64.to_owned(),
380            ),
381        ]);
382        TokenVerifier::build(&config, &env)
383            .expect("the seam's own verifiers build")
384            .expect("the seam configures verifiers")
385    })
386}
387
388fn code(error: &TokenVerificationError) -> &'static str {
389    error.code()
390}