Skip to main content

osproxy_server/
directive.rs

1//! Concrete HMAC verifier for the signed `X-Debug-Directive` header, the
2//! surgical, single-request diagnostics channel (`docs/05` ยง3). An operator mints
3//! a token off-band with the shared key; a client cannot forge one, so it cannot
4//! self-enable verbose diagnostics (NFR-S3). The token rides the request and is
5//! verified by whichever instance handles it.
6//!
7//! Token wire form: `{payload_hex}.{sig_hex}` where `payload` is a small JSON
8//! object and `sig` is `HMAC-SHA256(key, payload_bytes)`. The MAC is computed and
9//! checked through the build's **validated** crypto module (ring under `non-fips`,
10//! aws-lc-rs under `fips`, cfg-selected exactly like the TLS cert fingerprint) so
11//! a FIPS artifact never authenticates with a non-validated primitive.
12//!
13//! Payload fields: `level` (required, a [`DiagLevel`] name), `exp` (required,
14//! absolute unix-seconds expiry), and optional targeting `tenant`/`index`/
15//! `principal`, `sample_per_mille` (default 1000), `ring_buffer` (default false).
16
17use std::sync::Arc;
18use std::time::Duration;
19
20use osproxy_core::{Clock, IndexName, PartitionId, PrincipalId};
21use osproxy_observe::{DiagLevel, DiagnosticsDirective, DirectiveMatch, DirectiveVerifier};
22use serde_json::Value;
23
24// Exactly one validated crypto module must be linked, just like the transport
25// crate's provider guard (ADR-009): catch a mis-invocation at compile time rather
26// than failing opaquely on an unresolved `hmac::Key` or, worse, building an
27// artifact that authenticates with no validated primitive.
28#[cfg(all(feature = "fips", feature = "non-fips"))]
29compile_error!(
30    "features `fips` and `non-fips` are mutually exclusive; build with \
31     `--no-default-features --features fips` for a FIPS artifact"
32);
33#[cfg(not(any(feature = "fips", feature = "non-fips")))]
34compile_error!("enable exactly one crypto provider feature: `fips` or `non-fips`");
35
36// The MAC stays on whichever validated module the build linked, same cfg-select
37// as `cert_fingerprint` in the transport TLS path (ADR-009). ring and aws-lc-rs
38// share this `hmac` API (`Key::new`, constant-time `verify`).
39#[cfg(feature = "fips")]
40use aws_lc_rs::hmac;
41#[cfg(feature = "non-fips")]
42use ring::hmac;
43
44/// Verifies signed `X-Debug-Directive` tokens against a shared HMAC key.
45pub struct HmacDirectiveVerifier {
46    key: hmac::Key,
47    clock: Arc<dyn Clock>,
48}
49
50impl std::fmt::Debug for HmacDirectiveVerifier {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        // Never render the key. Shape only.
53        f.debug_struct("HmacDirectiveVerifier")
54            .finish_non_exhaustive()
55    }
56}
57
58impl HmacDirectiveVerifier {
59    /// Builds a verifier from the shared `secret` and a clock (used to enforce the
60    /// token's absolute expiry against current time).
61    #[must_use]
62    pub fn new(secret: &[u8], clock: Arc<dyn Clock>) -> Self {
63        Self {
64            key: hmac::Key::new(hmac::HMAC_SHA256, secret),
65            clock,
66        }
67    }
68
69    /// Turns a verified payload into the directive it authorizes, or `None` if the
70    /// payload is malformed or already expired.
71    fn to_directive(&self, payload: &[u8]) -> Option<DiagnosticsDirective> {
72        let v: Value = serde_json::from_slice(payload).ok()?;
73        let level = parse_level(v.get("level")?.as_str()?)?;
74        let exp = v.get("exp")?.as_u64()?;
75
76        // Convert the absolute unix-seconds expiry into an `Instant` on our clock;
77        // a token whose expiry has already passed authorizes nothing.
78        let now_secs = self.clock.unix_nanos() / 1_000_000_000;
79        let remaining = exp.checked_sub(now_secs)?;
80        if remaining == 0 {
81            return None;
82        }
83        let expires_at = self
84            .clock
85            .now()
86            .saturating_add(Duration::from_secs(remaining));
87
88        let mut match_ = DirectiveMatch::all();
89        if let Some(t) = v.get("tenant").and_then(Value::as_str) {
90            match_ = match_.for_tenant(PartitionId::from(t));
91        }
92        if let Some(i) = v.get("index").and_then(Value::as_str) {
93            match_ = match_.for_index(IndexName::from(i));
94        }
95        if let Some(p) = v.get("principal").and_then(Value::as_str) {
96            match_ = match_.for_principal(PrincipalId::from(p));
97        }
98        // Default to always-sample; a present rate must be a valid per-mille
99        // (`0..=1000`). An out-of-range value authorizes nothing rather than
100        // failing open to the broadest capture, same strictness as `level`.
101        let sample_per_mille = match v.get("sample_per_mille") {
102            None => 1000,
103            Some(n) => match n.as_u64() {
104                Some(n) if n <= 1000 => u16::try_from(n).unwrap_or(1000),
105                _ => return None,
106            },
107        };
108
109        Some(DiagnosticsDirective {
110            // A fixed label, never a tenant value, marks the header origin.
111            id: "x-debug-header".to_owned(),
112            match_,
113            level,
114            sample_per_mille,
115            expires_at,
116            ring_buffer: v
117                .get("ring_buffer")
118                .and_then(Value::as_bool)
119                .unwrap_or(false),
120            capture: v.get("capture").and_then(Value::as_bool).unwrap_or(false),
121        })
122    }
123}
124
125impl DirectiveVerifier for HmacDirectiveVerifier {
126    fn verify(&self, header_value: &str) -> Option<DiagnosticsDirective> {
127        let (payload_hex, sig_hex) = header_value.split_once('.')?;
128        let payload = decode_hex(payload_hex)?;
129        let sig = decode_hex(sig_hex)?;
130        // Constant-time tag comparison inside the validated module.
131        hmac::verify(&self.key, &payload, &sig).ok()?;
132        self.to_directive(&payload)
133    }
134}
135
136/// Maps a [`DiagLevel`] name to the level, for the signed-token vocabulary. A thin
137/// alias over [`DiagLevel::from_name`] so the token, the admin decoder, and the
138/// etcd store all parse levels through one source of truth.
139pub(crate) fn parse_level(name: &str) -> Option<DiagLevel> {
140    DiagLevel::from_name(name)
141}
142
143/// Decodes a lowercase/uppercase hex string into bytes, or `None` if it is not
144/// valid hex (odd length or a non-hex digit).
145fn decode_hex(s: &str) -> Option<Vec<u8>> {
146    if !s.len().is_multiple_of(2) {
147        return None;
148    }
149    let bytes = s.as_bytes();
150    let mut out = Vec::with_capacity(s.len() / 2);
151    let mut i = 0;
152    while i < bytes.len() {
153        let hi = (bytes[i] as char).to_digit(16)?;
154        let lo = (bytes[i + 1] as char).to_digit(16)?;
155        out.push(u8::try_from(hi * 16 + lo).ok()?);
156        i += 2;
157    }
158    Some(out)
159}
160
161/// Encodes bytes as lowercase hex. Mints tokens (operator tooling, exercised by
162/// the verify-path tests); the verify path itself only decodes.
163#[cfg(test)]
164#[must_use]
165pub(crate) fn encode_hex(bytes: &[u8]) -> String {
166    let mut out = String::with_capacity(bytes.len() * 2);
167    for &b in bytes {
168        out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
169        out.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap_or('0'));
170    }
171    out
172}
173
174/// Mints a token string `{payload_hex}.{sig_hex}` for `payload` signed with
175/// `secret`. Operator-side helper (and the basis for the verify-path tests).
176#[cfg(test)]
177#[must_use]
178pub(crate) fn sign_token(secret: &[u8], payload: &[u8]) -> String {
179    let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
180    let tag = hmac::sign(&key, payload);
181    format!("{}.{}", encode_hex(payload), encode_hex(tag.as_ref()))
182}
183
184#[cfg(test)]
185#[path = "directive_tests.rs"]
186mod tests;