Skip to main content

wsc/airgapped/
config.rs

1//! Configuration for air-gapped verification
2
3use std::time::Duration;
4
5/// Configuration for air-gapped verification
6#[derive(Debug, Clone)]
7pub struct AirGappedConfig {
8    /// Maximum signature age in seconds
9    ///
10    /// Signatures older than this are rejected even if otherwise valid.
11    /// `None` means no maximum age (trust bundle validity is the limit).
12    pub max_signature_age: Option<Duration>,
13
14    /// Whether to check the revocation list
15    pub check_revocations: bool,
16
17    /// Maximum certificate chain depth
18    pub max_chain_depth: u8,
19
20    /// Required identity patterns (optional)
21    pub identity_requirements: Option<IdentityRequirements>,
22
23    /// How to handle expired trust bundles
24    pub grace_period_behavior: GracePeriodBehavior,
25
26    /// Whether to enforce anti-rollback protection
27    ///
28    /// When true, bundle version must be >= stored device state version.
29    /// Requires persistent storage for device state.
30    pub enforce_rollback_protection: bool,
31}
32
33impl Default for AirGappedConfig {
34    fn default() -> Self {
35        Self {
36            max_signature_age: None,
37            check_revocations: true,
38            max_chain_depth: 4,
39            identity_requirements: None,
40            grace_period_behavior: GracePeriodBehavior::WarnDuringGrace,
41            enforce_rollback_protection: false,
42        }
43    }
44}
45
46impl AirGappedConfig {
47    /// Create config for fully air-gapped devices (no time source)
48    pub fn fully_airgapped() -> Self {
49        Self {
50            max_signature_age: None,
51            check_revocations: true,
52            max_chain_depth: 4,
53            identity_requirements: None,
54            grace_period_behavior: GracePeriodBehavior::WarnOnly,
55            enforce_rollback_protection: false,
56        }
57    }
58
59    /// Create config for intermittently connected devices
60    pub fn intermittent() -> Self {
61        Self {
62            max_signature_age: Some(Duration::from_secs(90 * 24 * 3600)), // 90 days
63            check_revocations: true,
64            max_chain_depth: 4,
65            identity_requirements: None,
66            grace_period_behavior: GracePeriodBehavior::WarnDuringGrace,
67            enforce_rollback_protection: true,
68        }
69    }
70
71    /// Create config for high-security environments
72    pub fn high_security() -> Self {
73        Self {
74            max_signature_age: Some(Duration::from_secs(7 * 24 * 3600)), // 7 days
75            check_revocations: true,
76            max_chain_depth: 2,
77            identity_requirements: None,
78            grace_period_behavior: GracePeriodBehavior::Strict,
79            enforce_rollback_protection: true,
80        }
81    }
82
83    /// Set maximum signature age
84    pub fn with_max_age(mut self, age: Duration) -> Self {
85        self.max_signature_age = Some(age);
86        self
87    }
88
89    /// Set identity requirements
90    pub fn with_identity_requirements(mut self, requirements: IdentityRequirements) -> Self {
91        self.identity_requirements = Some(requirements);
92        self
93    }
94
95    /// Enable rollback protection
96    pub fn with_rollback_protection(mut self) -> Self {
97        self.enforce_rollback_protection = true;
98        self
99    }
100}
101
102/// Required identity patterns
103#[derive(Debug, Clone)]
104pub struct IdentityRequirements {
105    /// Allowed OIDC issuers (exact match or glob patterns)
106    ///
107    /// Example: `["https://token.actions.githubusercontent.com"]`
108    pub allowed_issuers: Vec<String>,
109
110    /// Allowed subjects (exact match or glob patterns)
111    ///
112    /// Example: `["https://github.com/myorg/*"]`
113    pub allowed_subjects: Vec<String>,
114}
115
116impl IdentityRequirements {
117    /// Create requirements for GitHub Actions
118    pub fn github_actions(org: &str) -> Self {
119        Self {
120            allowed_issuers: vec!["https://token.actions.githubusercontent.com".to_string()],
121            allowed_subjects: vec![format!("https://github.com/{}/*", org)],
122        }
123    }
124
125    /// Check if an issuer matches the requirements
126    pub fn matches_issuer(&self, issuer: &str) -> bool {
127        self.allowed_issuers.iter().any(|pattern| {
128            if pattern.contains('*') {
129                glob_match(pattern, issuer)
130            } else {
131                pattern == issuer
132            }
133        })
134    }
135
136    /// Check if a subject matches the requirements
137    pub fn matches_subject(&self, subject: &str) -> bool {
138        self.allowed_subjects.iter().any(|pattern| {
139            if pattern.contains('*') {
140                glob_match(pattern, subject)
141            } else {
142                pattern == subject
143            }
144        })
145    }
146}
147
148/// Simple glob matching (* matches any characters)
149fn glob_match(pattern: &str, text: &str) -> bool {
150    let parts: Vec<&str> = pattern.split('*').collect();
151
152    if parts.is_empty() {
153        return pattern == text;
154    }
155
156    let mut pos = 0;
157    for (i, part) in parts.iter().enumerate() {
158        if part.is_empty() {
159            continue;
160        }
161
162        if let Some(found) = text[pos..].find(part) {
163            if i == 0 && found != 0 {
164                // First part must match at start
165                return false;
166            }
167            pos += found + part.len();
168        } else {
169            return false;
170        }
171    }
172
173    // If pattern doesn't end with *, text must end at pos
174    if !pattern.ends_with('*') && pos != text.len() {
175        return false;
176    }
177
178    true
179}
180
181/// How to handle expired trust bundles
182#[derive(Debug, Clone, Default)]
183pub enum GracePeriodBehavior {
184    /// Fail immediately when bundle expires
185    Strict,
186
187    /// Allow with warnings during grace period, then fail
188    #[default]
189    WarnDuringGrace,
190
191    /// Always allow with warnings (never hard fail due to expiry)
192    WarnOnly,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_default_config() {
201        let config = AirGappedConfig::default();
202        assert!(config.max_signature_age.is_none());
203        assert!(config.check_revocations);
204        assert!(!config.enforce_rollback_protection);
205    }
206
207    #[test]
208    fn test_high_security_config() {
209        let config = AirGappedConfig::high_security();
210        assert!(config.max_signature_age.is_some());
211        assert!(config.enforce_rollback_protection);
212        assert!(matches!(config.grace_period_behavior, GracePeriodBehavior::Strict));
213    }
214
215    #[test]
216    fn test_identity_requirements_exact_match() {
217        let req = IdentityRequirements {
218            allowed_issuers: vec!["https://issuer.example.com".to_string()],
219            allowed_subjects: vec!["user@example.com".to_string()],
220        };
221
222        assert!(req.matches_issuer("https://issuer.example.com"));
223        assert!(!req.matches_issuer("https://other.example.com"));
224
225        assert!(req.matches_subject("user@example.com"));
226        assert!(!req.matches_subject("other@example.com"));
227    }
228
229    #[test]
230    fn test_identity_requirements_glob_match() {
231        let req = IdentityRequirements::github_actions("myorg");
232
233        assert!(req.matches_issuer("https://token.actions.githubusercontent.com"));
234        assert!(req.matches_subject("https://github.com/myorg/repo/.github/workflows/ci.yml@refs/heads/main"));
235        assert!(!req.matches_subject("https://github.com/otherorg/repo"));
236    }
237
238    #[test]
239    fn test_glob_match() {
240        assert!(glob_match("hello*", "hello world"));
241        assert!(glob_match("*world", "hello world"));
242        assert!(glob_match("hello*world", "hello beautiful world"));
243        assert!(glob_match("*", "anything"));
244        assert!(glob_match("exact", "exact"));
245
246        assert!(!glob_match("hello*", "world hello"));
247        assert!(!glob_match("*world", "world hello"));
248        assert!(!glob_match("exact", "not exact"));
249    }
250}