1use std::{net::IpAddr, time::Duration};
4
5use chrono::TimeDelta;
6use ipnet::IpNet;
7
8use crate::{
9 checks::{Checks, Thresholds},
10 cookie::CookieConfig,
11 error::ConfigError,
12};
13
14const MAX_DURATION: Duration = Duration::from_hours(100 * 365 * 24);
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum Mode {
20 #[default]
22 Enforce,
23 Shadow,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum StepUpPolicy {
32 #[default]
35 Require,
36 Revoke,
39 Allow,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct RotationConfig {
47 pub interval: Duration,
49 pub grace: Duration,
52 pub reuse_detection: Duration,
55}
56
57impl Default for RotationConfig {
58 fn default() -> Self {
59 Self {
60 interval: Duration::from_mins(10),
61 grace: Duration::from_secs(30),
62 reuse_detection: Duration::from_hours(7 * 24),
63 }
64 }
65}
66
67#[derive(Clone, Debug)]
70pub struct GuardConfig {
71 pub(crate) cookie: CookieConfig,
72 pub(crate) session_lifetime: TimeDelta,
73 pub(crate) idle_timeout: TimeDelta,
74 pub(crate) trusted_proxies: Vec<IpNet>,
75 pub(crate) tls_fingerprint_header: Option<String>,
76 pub(crate) rotation_interval: TimeDelta,
77 pub(crate) rotation_grace: TimeDelta,
78 pub(crate) reuse_detection: TimeDelta,
79 pub(crate) checks: Checks,
80 pub(crate) thresholds: Thresholds,
81 pub(crate) mode: Mode,
82 pub(crate) step_up_policy: StepUpPolicy,
83 pub(crate) ping_pong_window: TimeDelta,
84 pub(crate) last_seen_write_interval: TimeDelta,
85 pub(crate) event_retention: TimeDelta,
86 pub(crate) minimize_event_ip: bool,
87 pub(crate) shadow_dedup_window: TimeDelta,
88 pub(crate) max_header_len: usize,
89 pub(crate) token_prefix: Option<String>,
90 pub(crate) orphan_reissue_window: Option<TimeDelta>,
91}
92
93impl Default for GuardConfig {
94 fn default() -> Self {
95 GuardConfigBuilder::default()
96 .build()
97 .unwrap_or_else(|_| unreachable!("default configuration is valid"))
98 }
99}
100
101impl GuardConfig {
102 pub fn builder() -> GuardConfigBuilder {
104 GuardConfigBuilder::default()
105 }
106
107 pub fn cookie(&self) -> &CookieConfig {
109 &self.cookie
110 }
111
112 pub fn session_lifetime(&self) -> Duration {
114 to_std(self.session_lifetime)
115 }
116
117 pub fn idle_timeout(&self) -> Duration {
119 to_std(self.idle_timeout)
120 }
121
122 pub fn trusted_proxies(&self) -> &[IpNet] {
124 &self.trusted_proxies
125 }
126
127 pub fn tls_fingerprint_header(&self) -> Option<&str> {
129 self.tls_fingerprint_header.as_deref()
130 }
131
132 pub fn rotation(&self) -> RotationConfig {
134 RotationConfig {
135 interval: to_std(self.rotation_interval),
136 grace: to_std(self.rotation_grace),
137 reuse_detection: to_std(self.reuse_detection),
138 }
139 }
140
141 pub fn checks(&self) -> &Checks {
143 &self.checks
144 }
145
146 pub fn thresholds(&self) -> Thresholds {
148 self.thresholds
149 }
150
151 pub fn mode(&self) -> Mode {
153 self.mode
154 }
155
156 pub fn step_up_policy(&self) -> StepUpPolicy {
158 self.step_up_policy
159 }
160
161 pub fn ping_pong_window(&self) -> Duration {
163 to_std(self.ping_pong_window)
164 }
165
166 pub fn last_seen_write_interval(&self) -> Duration {
168 to_std(self.last_seen_write_interval)
169 }
170
171 pub fn event_retention(&self) -> Duration {
174 to_std(self.event_retention)
175 }
176
177 pub fn minimize_event_ip(&self) -> bool {
179 self.minimize_event_ip
180 }
181
182 pub fn shadow_dedup_window(&self) -> Duration {
184 to_std(self.shadow_dedup_window)
185 }
186
187 pub fn max_header_len(&self) -> usize {
189 self.max_header_len
190 }
191
192 pub fn token_prefix(&self) -> Option<&str> {
194 self.token_prefix.as_deref()
195 }
196
197 pub fn orphan_reissue_window(&self) -> Option<Duration> {
199 self.orphan_reissue_window.map(to_std)
200 }
201}
202
203fn to_std(delta: TimeDelta) -> Duration {
204 delta.to_std().unwrap_or_default()
205}
206
207#[derive(Clone, Debug)]
209#[must_use]
210pub struct GuardConfigBuilder {
211 cookie: CookieConfig,
212 session_lifetime: Duration,
213 idle_timeout: Duration,
214 trusted_proxies: Vec<IpNet>,
215 tls_fingerprint_header: Option<String>,
216 rotation: RotationConfig,
217 checks: Checks,
218 thresholds: Thresholds,
219 mode: Mode,
220 step_up_policy: StepUpPolicy,
221 ping_pong_window: Duration,
222 last_seen_write_interval: Duration,
223 event_retention: Duration,
224 minimize_event_ip: bool,
225 shadow_dedup_window: Duration,
226 max_header_len: usize,
227 token_prefix: Option<String>,
228 orphan_reissue_window: Option<Duration>,
229}
230
231impl Default for GuardConfigBuilder {
232 fn default() -> Self {
233 const MINUTE: u64 = 60;
234 const DAY: u64 = 24 * 60 * MINUTE;
235 Self {
236 cookie: CookieConfig::default(),
237 session_lifetime: Duration::from_secs(30 * DAY),
238 idle_timeout: Duration::from_secs(7 * DAY),
239 trusted_proxies: Vec::new(),
240 tls_fingerprint_header: None,
241 rotation: RotationConfig::default(),
242 checks: Checks::default(),
243 thresholds: Thresholds::default(),
244 mode: Mode::Enforce,
245 step_up_policy: StepUpPolicy::Require,
246 ping_pong_window: Duration::from_secs(5 * MINUTE),
247 last_seen_write_interval: Duration::from_secs(MINUTE),
248 event_retention: Duration::from_secs(90 * DAY),
249 minimize_event_ip: false,
250 shadow_dedup_window: Duration::from_secs(10 * MINUTE),
251 max_header_len: 512,
252 token_prefix: None,
253 orphan_reissue_window: None,
254 }
255 }
256}
257
258impl GuardConfigBuilder {
259 pub fn cookie(mut self, cookie: CookieConfig) -> Self {
261 self.cookie = cookie;
262 self
263 }
264
265 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
267 self.cookie = self.cookie.name(name);
268 self
269 }
270
271 pub fn session_lifetime(mut self, lifetime: Duration) -> Self {
274 self.session_lifetime = lifetime;
275 self
276 }
277
278 pub fn idle_timeout(mut self, timeout: Duration) -> Self {
280 self.idle_timeout = timeout;
281 self
282 }
283
284 pub fn trusted_proxies<I, T>(mut self, proxies: I) -> Result<Self, ConfigError>
291 where
292 I: IntoIterator<Item = T>,
293 T: AsRef<str>,
294 {
295 self.trusted_proxies = proxies
296 .into_iter()
297 .map(|p| {
298 let p = p.as_ref().trim();
299 p.parse::<IpNet>()
300 .or_else(|_| p.parse::<IpAddr>().map(IpNet::from))
301 .map(|net| net.trunc())
302 .map_err(|_| ConfigError::InvalidProxy(p.to_owned()))
303 })
304 .collect::<Result<_, _>>()?;
305 Ok(self)
306 }
307
308 pub fn tls_fingerprint_header(mut self, header: impl Into<String>) -> Self {
312 self.tls_fingerprint_header = Some(header.into().to_ascii_lowercase());
313 self
314 }
315
316 pub fn rotation(mut self, rotation: RotationConfig) -> Self {
318 self.rotation = rotation;
319 self
320 }
321
322 pub fn checks(mut self, checks: Checks) -> Self {
324 self.checks = checks;
325 self
326 }
327
328 pub fn thresholds(mut self, thresholds: Thresholds) -> Self {
330 self.thresholds = thresholds;
331 self
332 }
333
334 pub fn mode(mut self, mode: Mode) -> Self {
336 self.mode = mode;
337 self
338 }
339
340 pub fn step_up_policy(mut self, policy: StepUpPolicy) -> Self {
342 self.step_up_policy = policy;
343 self
344 }
345
346 pub fn orphan_reissue_window(mut self, window: Duration) -> Self {
353 self.orphan_reissue_window = Some(window);
354 self
355 }
356
357 pub fn ping_pong_window(mut self, window: Duration) -> Self {
359 self.ping_pong_window = window;
360 self
361 }
362
363 pub fn last_seen_write_interval(mut self, interval: Duration) -> Self {
366 self.last_seen_write_interval = interval;
367 self
368 }
369
370 pub fn event_retention(mut self, retention: Duration) -> Self {
372 self.event_retention = retention;
373 self
374 }
375
376 pub fn minimize_event_ip(mut self, minimize: bool) -> Self {
378 self.minimize_event_ip = minimize;
379 self
380 }
381
382 pub fn shadow_dedup_window(mut self, window: Duration) -> Self {
384 self.shadow_dedup_window = window;
385 self
386 }
387
388 pub fn max_header_len(mut self, len: usize) -> Self {
390 self.max_header_len = len;
391 self
392 }
393
394 pub fn token_prefix(mut self, prefix: impl Into<String>) -> Self {
402 self.token_prefix = Some(prefix.into());
403 self
404 }
405
406 pub fn build(self) -> Result<GuardConfig, ConfigError> {
413 self.cookie.validate()?;
414 if !self.thresholds.is_valid() {
415 return Err(ConfigError::InvalidThresholds);
416 }
417 if self.rotation.grace >= self.rotation.interval {
418 return Err(ConfigError::GraceTooLong);
419 }
420 if self.max_header_len == 0 {
421 return Err(ConfigError::InvalidDuration("max_header_len"));
422 }
423 if let Some(h) = &self.tls_fingerprint_header
424 && (h.is_empty() || !h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
425 {
426 return Err(ConfigError::InvalidHeaderName(h.clone()));
427 }
428 if let Some(p) = &self.token_prefix
429 && (p.is_empty() || p.len() > 32 || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
430 {
431 return Err(ConfigError::InvalidTokenPrefix(p.clone()));
432 }
433 Ok(GuardConfig {
434 cookie: self.cookie,
435 session_lifetime: positive(self.session_lifetime, "session_lifetime")?,
436 idle_timeout: positive(self.idle_timeout, "idle_timeout")?,
437 trusted_proxies: self.trusted_proxies,
438 tls_fingerprint_header: self.tls_fingerprint_header,
439 rotation_interval: positive(self.rotation.interval, "rotation.interval")?,
440 rotation_grace: bounded(self.rotation.grace, "rotation.grace")?,
441 reuse_detection: positive(self.rotation.reuse_detection, "rotation.reuse_detection")?,
442 checks: self.checks,
443 thresholds: self.thresholds,
444 mode: self.mode,
445 step_up_policy: self.step_up_policy,
446 ping_pong_window: bounded(self.ping_pong_window, "ping_pong_window")?,
447 last_seen_write_interval: bounded(self.last_seen_write_interval, "last_seen_write_interval")?,
448 event_retention: positive(self.event_retention, "event_retention")?,
449 minimize_event_ip: self.minimize_event_ip,
450 shadow_dedup_window: bounded(self.shadow_dedup_window, "shadow_dedup_window")?,
451 max_header_len: self.max_header_len,
452 token_prefix: self.token_prefix,
453 orphan_reissue_window: self
454 .orphan_reissue_window
455 .map(|w| bounded(w, "orphan_reissue_window"))
456 .transpose()?,
457 })
458 }
459}
460
461fn positive(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
462 if d.is_zero() {
463 return Err(ConfigError::InvalidDuration(name));
464 }
465 bounded(d, name)
466}
467
468fn bounded(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
469 if d > MAX_DURATION {
470 return Err(ConfigError::InvalidDuration(name));
471 }
472 TimeDelta::from_std(d).map_err(|_| ConfigError::InvalidDuration(name))
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::checks::Check;
479
480 #[test]
481 fn defaults_match_the_spec() {
482 let c = GuardConfig::default();
483 assert_eq!(c.cookie().cookie_name(), "__Host-sid");
484 assert_eq!(c.session_lifetime(), Duration::from_hours(30 * 24));
485 assert_eq!(c.idle_timeout(), Duration::from_hours(7 * 24));
486 assert_eq!(c.rotation(), RotationConfig::default());
487 assert_eq!(c.rotation().interval, Duration::from_secs(600));
488 assert_eq!(c.rotation().grace, Duration::from_secs(30));
489 assert_eq!(c.mode(), Mode::Enforce);
490 assert_eq!(c.step_up_policy(), StepUpPolicy::Require);
491 assert_eq!(c.checks(), &Checks::default());
492 assert_eq!(c.thresholds(), Thresholds::default());
493 assert_eq!(c.event_retention(), Duration::from_hours(90 * 24));
494 assert!(!c.minimize_event_ip());
495 assert_eq!(c.trusted_proxies(), &[]);
496 assert_eq!(c.orphan_reissue_window(), None);
497 }
498
499 #[test]
500 fn builder_sets_everything() {
501 let c = GuardConfig::builder()
502 .cookie_name("__Host-app")
503 .session_lifetime(Duration::from_secs(3600))
504 .idle_timeout(Duration::from_secs(600))
505 .trusted_proxies(["10.0.0.0/8", "::1", " 192.168.1.7 "])
506 .unwrap()
507 .tls_fingerprint_header("X-JA4")
508 .checks(Checks::default().disable(Check::AsnChanged))
509 .thresholds(Thresholds {
510 step_up: 30,
511 revoke: 90,
512 })
513 .mode(Mode::Shadow)
514 .step_up_policy(StepUpPolicy::Allow)
515 .minimize_event_ip(true)
516 .orphan_reissue_window(Duration::from_secs(3600))
517 .build()
518 .unwrap();
519 assert_eq!(c.cookie().cookie_name(), "__Host-app");
520 assert_eq!(c.orphan_reissue_window(), Some(Duration::from_secs(3600)));
521 assert_eq!(c.trusted_proxies().len(), 3);
522 assert!(
523 c.trusted_proxies()
524 .iter()
525 .any(|n| n.contains(&"10.1.2.3".parse::<IpAddr>().unwrap()))
526 );
527 assert!(
528 c.trusted_proxies()
529 .iter()
530 .any(|n| n.contains(&"192.168.1.7".parse::<IpAddr>().unwrap()))
531 );
532 assert!(
533 !c.trusted_proxies()
534 .iter()
535 .any(|n| n.contains(&"192.168.1.8".parse::<IpAddr>().unwrap()))
536 );
537 assert_eq!(c.tls_fingerprint_header(), Some("x-ja4"));
538 assert_eq!(c.mode(), Mode::Shadow);
539 assert_eq!(c.step_up_policy(), StepUpPolicy::Allow);
540 assert!(c.minimize_event_ip());
541 }
542
543 #[test]
544 fn invalid_configs_are_rejected() {
545 let b = GuardConfig::builder;
546 assert_eq!(
547 b().trusted_proxies(["nope"]).unwrap_err(),
548 ConfigError::InvalidProxy("nope".into())
549 );
550 assert!(matches!(
551 b().cookie(CookieConfig::default().secure(false)).build(),
552 Err(ConfigError::InvalidCookie(_))
553 ));
554 assert_eq!(
555 b().thresholds(Thresholds {
556 step_up: 90,
557 revoke: 80
558 })
559 .build()
560 .unwrap_err(),
561 ConfigError::InvalidThresholds
562 );
563 assert_eq!(
564 b().rotation(RotationConfig {
565 grace: Duration::from_secs(600),
566 ..RotationConfig::default()
567 })
568 .build()
569 .unwrap_err(),
570 ConfigError::GraceTooLong
571 );
572 assert_eq!(
573 b().session_lifetime(Duration::ZERO).build().unwrap_err(),
574 ConfigError::InvalidDuration("session_lifetime")
575 );
576 assert_eq!(
577 b().idle_timeout(Duration::MAX).build().unwrap_err(),
578 ConfigError::InvalidDuration("idle_timeout")
579 );
580 assert_eq!(
581 b().orphan_reissue_window(Duration::MAX).build().unwrap_err(),
582 ConfigError::InvalidDuration("orphan_reissue_window")
583 );
584 assert!(matches!(
585 b().tls_fingerprint_header("x ja4").build(),
586 Err(ConfigError::InvalidHeaderName(_))
587 ));
588 }
589
590 #[test]
591 fn token_prefix_is_optional_and_validated() {
592 assert_eq!(GuardConfig::default().token_prefix(), None);
593 let c = GuardConfig::builder().token_prefix("sess_").build().unwrap();
594 assert_eq!(c.token_prefix(), Some("sess_"));
595 for bad in ["", "se ss_", "sess;", "sess=", "é_", &"a".repeat(33)] {
596 assert_eq!(
597 GuardConfig::builder().token_prefix(bad).build().unwrap_err(),
598 ConfigError::InvalidTokenPrefix(bad.to_owned()),
599 "{bad:?}"
600 );
601 }
602 }
603
604 #[test]
605 fn zero_is_allowed_where_it_means_off() {
606 let c = GuardConfig::builder()
607 .ping_pong_window(Duration::ZERO)
608 .last_seen_write_interval(Duration::ZERO)
609 .shadow_dedup_window(Duration::ZERO)
610 .orphan_reissue_window(Duration::ZERO)
611 .rotation(RotationConfig {
612 grace: Duration::ZERO,
613 ..RotationConfig::default()
614 })
615 .build()
616 .unwrap();
617 assert_eq!(c.rotation().grace, Duration::ZERO);
618 assert_eq!(c.orphan_reissue_window(), Some(Duration::ZERO));
619 }
620}