Skip to main content

termivar_scanner/payload_strategies/
api_authorization_context_pair.rs

1//! Second built-in payload strategy: `api.authorization.context-pair@1`.
2//!
3//! This strategy derives a matched control/candidate pair that varies only the
4//! authorization context of an otherwise identical request, so a strategy-aware
5//! capability executor can measure how the same resource's visibility differs
6//! between an anonymous and an authorized principal.
7//!
8//! The control leg is deliberately empty: an empty artifact instructs the HTTP
9//! executor to omit the `authorization` header entirely, representing the
10//! anonymous context. The candidate leg is the seed verbatim — the complete
11//! authorization header value the host wants to test (for example
12//! `Bearer <token>`). Derivation is a pure function of `(role, seed)` — no
13//! clocks, randomness, knowledge state, or transport.
14//!
15//! The strategy only accepts header-safe seed bytes and never introduces control
16//! characters, so a derived candidate is always a valid header value and cannot
17//! perform header injection or request splitting at the derivation boundary.
18
19use crate::payload_strategy::{
20    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
21    PayloadStrategyRef, PayloadVariantRole,
22};
23
24/// Stable identity of this strategy, without its revision.
25pub const API_AUTHORIZATION_CONTEXT_PAIR_ID: &str = "api.authorization.context-pair";
26
27/// Deterministic implementation revision materialized by this module.
28pub const API_AUTHORIZATION_CONTEXT_PAIR_REVISION: u32 = 1;
29
30/// Request header this revision varies. The candidate leg applies the derived
31/// artifact as this header's value; the control leg omits it.
32pub const API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME: &str = "authorization";
33
34/// Second built-in [`PayloadStrategy`]: an anonymous/authorized context pair.
35#[derive(Debug, Clone)]
36pub struct ApiAuthorizationContextPairStrategy {
37    reference: PayloadStrategyRef,
38}
39
40impl ApiAuthorizationContextPairStrategy {
41    /// Creates the strategy bound to its stable reference and revision.
42    ///
43    /// The reference identity is a compile-time constant, so construction only
44    /// fails if the shared [`PayloadStrategyRef`] validation contract changes;
45    /// that is treated as an unrecoverable programmer error.
46    pub fn new() -> Self {
47        let reference = PayloadStrategyRef::new(
48            API_AUTHORIZATION_CONTEXT_PAIR_ID,
49            API_AUTHORIZATION_CONTEXT_PAIR_REVISION,
50        )
51        .expect("api.authorization.context-pair@1 is a valid strategy reference");
52        Self { reference }
53    }
54
55    /// Returns whether `bytes` are a safe, non-empty HTTP header value.
56    ///
57    /// Accepts a single interior run of visible ASCII and spaces, rejecting
58    /// empty values, leading or trailing spaces, control characters (including
59    /// CR and LF), and any non-ASCII byte. The candidate credential must satisfy
60    /// this before it can be applied verbatim as a header value.
61    fn is_safe_header_value(bytes: &[u8]) -> bool {
62        if bytes.is_empty() {
63            return false;
64        }
65        if bytes.first() == Some(&b' ') || bytes.last() == Some(&b' ') {
66            return false;
67        }
68        bytes
69            .iter()
70            .all(|&byte| byte == b' ' || (0x21..=0x7e).contains(&byte))
71    }
72}
73
74impl Default for ApiAuthorizationContextPairStrategy {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl PayloadStrategy for ApiAuthorizationContextPairStrategy {
81    fn strategy_ref(&self) -> &PayloadStrategyRef {
82        &self.reference
83    }
84
85    fn derive_one(
86        &self,
87        role: PayloadVariantRole,
88        seed: &PayloadSeed,
89        limits: PayloadStrategyLimits,
90    ) -> Result<PayloadArtifact, PayloadStrategyError> {
91        // Both legs require a valid candidate credential up front so the pair is
92        // rejected deterministically regardless of which leg is derived first.
93        if !Self::is_safe_header_value(seed.as_bytes()) {
94            return Err(PayloadStrategyError::DerivationFailed);
95        }
96
97        let bytes = match role {
98            // Anonymous context: an empty artifact omits the header downstream.
99            PayloadVariantRole::Control => Vec::new(),
100            // Authorized context: the seed is the complete header value.
101            PayloadVariantRole::Candidate => seed.as_bytes().to_vec(),
102        };
103
104        PayloadArtifact::new(self.reference.clone(), role, bytes, limits)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use std::{sync::Arc, thread};
111
112    use super::*;
113    use crate::payload_strategy::PayloadStrategyRegistry;
114
115    fn limits() -> PayloadStrategyLimits {
116        PayloadStrategyLimits::default()
117    }
118
119    fn seed(value: &[u8]) -> PayloadSeed {
120        PayloadSeed::new(value.to_vec(), limits()).unwrap()
121    }
122
123    #[test]
124    fn reference_is_stable_and_versioned() {
125        let strategy = ApiAuthorizationContextPairStrategy::new();
126        assert_eq!(
127            strategy.strategy_ref().id(),
128            API_AUTHORIZATION_CONTEXT_PAIR_ID
129        );
130        assert_eq!(
131            strategy.strategy_ref().revision(),
132            API_AUTHORIZATION_CONTEXT_PAIR_REVISION
133        );
134        assert_eq!(
135            strategy.strategy_ref().to_string(),
136            "api.authorization.context-pair@1"
137        );
138    }
139
140    #[test]
141    fn control_is_anonymous_and_candidate_is_the_credential() {
142        let strategy = ApiAuthorizationContextPairStrategy::new();
143
144        let control = strategy
145            .derive_one(
146                PayloadVariantRole::Control,
147                &seed(b"Bearer token"),
148                limits(),
149            )
150            .unwrap();
151        assert!(
152            control.as_bytes().is_empty(),
153            "control leg must be an empty (anonymous) artifact"
154        );
155
156        let candidate = strategy
157            .derive_one(
158                PayloadVariantRole::Candidate,
159                &seed(b"Bearer token"),
160                limits(),
161            )
162            .unwrap();
163        assert_eq!(candidate.as_bytes(), b"Bearer token");
164        assert_ne!(control.receipt().sha256(), candidate.receipt().sha256());
165    }
166
167    #[test]
168    fn control_is_seed_independent_but_still_requires_a_valid_seed() {
169        let strategy = ApiAuthorizationContextPairStrategy::new();
170        let first = strategy
171            .derive_one(PayloadVariantRole::Control, &seed(b"Bearer one"), limits())
172            .unwrap();
173        let second = strategy
174            .derive_one(PayloadVariantRole::Control, &seed(b"Basic two"), limits())
175            .unwrap();
176        assert!(first.as_bytes().is_empty());
177        assert_eq!(first.as_bytes(), second.as_bytes());
178    }
179
180    #[test]
181    fn same_input_is_deterministic_across_repeats() {
182        let strategy = ApiAuthorizationContextPairStrategy::new();
183        let first = strategy
184            .derive_one(PayloadVariantRole::Candidate, &seed(b"Bearer t"), limits())
185            .unwrap();
186        let second = strategy
187            .derive_one(PayloadVariantRole::Candidate, &seed(b"Bearer t"), limits())
188            .unwrap();
189        assert_eq!(first, second);
190        assert_eq!(first.receipt(), second.receipt());
191    }
192
193    #[test]
194    fn derivation_is_deterministic_under_concurrency() {
195        let strategy = Arc::new(ApiAuthorizationContextPairStrategy::new());
196        let expected = strategy
197            .derive_one(
198                PayloadVariantRole::Candidate,
199                &seed(b"Bearer probe"),
200                limits(),
201            )
202            .unwrap();
203
204        let handles: Vec<_> = (0..8)
205            .map(|_| {
206                let strategy = Arc::clone(&strategy);
207                thread::spawn(move || {
208                    strategy
209                        .derive_one(
210                            PayloadVariantRole::Candidate,
211                            &seed(b"Bearer probe"),
212                            limits(),
213                        )
214                        .unwrap()
215                        .receipt()
216                })
217            })
218            .collect();
219
220        for handle in handles {
221            assert_eq!(handle.join().unwrap(), expected.receipt());
222        }
223    }
224
225    #[test]
226    fn unsafe_seeds_fail_closed_on_both_legs() {
227        let strategy = ApiAuthorizationContextPairStrategy::new();
228        for unsafe_seed in [
229            b"".as_slice(),
230            b" leading",
231            b"trailing ",
232            b"line\nbreak",
233            b"carriage\rreturn",
234            b"tab\ttoken",
235            b"null\0byte",
236        ] {
237            let seed = PayloadSeed::new(unsafe_seed.to_vec(), limits()).unwrap();
238            for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
239                assert!(
240                    matches!(
241                        strategy.derive_one(role, &seed, limits()),
242                        Err(PayloadStrategyError::DerivationFailed)
243                    ),
244                    "expected fail-closed derivation for seed {unsafe_seed:?} role {role:?}"
245                );
246            }
247        }
248    }
249
250    #[test]
251    fn oversized_candidate_value_fails_closed() {
252        let strategy = ApiAuthorizationContextPairStrategy::new();
253        let tight = PayloadStrategyLimits::new(64, 4).unwrap();
254        let seed = PayloadSeed::new(b"Bearer token".to_vec(), tight).unwrap();
255        assert!(matches!(
256            strategy.derive_one(PayloadVariantRole::Candidate, &seed, tight),
257            Err(PayloadStrategyError::ArtifactTooLarge { .. })
258        ));
259    }
260
261    #[test]
262    fn resolves_and_revalidates_through_registry() {
263        let strategy = ApiAuthorizationContextPairStrategy::new();
264        let reference = strategy.strategy_ref().clone();
265        let mut registry = PayloadStrategyRegistry::new();
266        registry.register(Arc::new(strategy)).unwrap();
267
268        let control = registry
269            .derive_one(
270                &reference,
271                PayloadVariantRole::Control,
272                &seed(b"Bearer token"),
273                limits(),
274            )
275            .unwrap();
276        let candidate = registry
277            .derive_one(
278                &reference,
279                PayloadVariantRole::Candidate,
280                &seed(b"Bearer token"),
281                limits(),
282            )
283            .unwrap();
284        assert!(control.as_bytes().is_empty());
285        assert_eq!(candidate.as_bytes(), b"Bearer token");
286    }
287
288    #[test]
289    fn raw_seed_never_enters_receipt_json() {
290        let strategy = ApiAuthorizationContextPairStrategy::new();
291        let artifact = strategy
292            .derive_one(
293                PayloadVariantRole::Candidate,
294                &seed(b"Bearer secret-token"),
295                limits(),
296            )
297            .unwrap();
298        let receipt_json = serde_json::to_string(&artifact.receipt()).unwrap();
299        let debug = format!("{artifact:?}");
300        assert!(!receipt_json.contains("secret-token"));
301        assert!(!debug.contains("secret-token"));
302        assert_eq!(artifact.receipt().sha256().len(), 64);
303    }
304}