Skip to main content

void_crypto/
scoped_keyring.rs

1//! Scoped access keys for limited read access.
2//!
3//! This module provides `ScopedKeyRing` and `ScopedAccessToken` for creating
4//! scoped read keys that limit access to specific paths or branches.
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::kdf::{derive_scoped_key, ContentKey, SecretKey};
9use crate::CryptoResult;
10
11/// A token granting scoped read access to specific paths or branches.
12#[derive(Debug, Clone)]
13pub struct ScopedAccessToken {
14    /// Scope pattern (e.g., "refs/heads/main" or "path:src/").
15    pub scope: String,
16    /// Derived key for this scope.
17    pub derived_key: ContentKey,
18    /// When the token was created (Unix timestamp in seconds).
19    pub created_at: u64,
20    /// Optional expiration time (Unix timestamp in seconds).
21    pub expires_at: Option<u64>,
22}
23
24impl ScopedAccessToken {
25    /// Create a new scoped access token.
26    pub fn new(scope: String, derived_key: ContentKey, expires_at: Option<u64>) -> Self {
27        let created_at = SystemTime::now()
28            .duration_since(UNIX_EPOCH)
29            .unwrap_or_default()
30            .as_secs();
31
32        Self {
33            scope,
34            derived_key,
35            created_at,
36            expires_at,
37        }
38    }
39
40    /// Check if this token has expired.
41    pub fn is_expired(&self) -> bool {
42        if let Some(expires_at) = self.expires_at {
43            let now = SystemTime::now()
44                .duration_since(UNIX_EPOCH)
45                .unwrap_or_default()
46                .as_secs();
47            now > expires_at
48        } else {
49            false
50        }
51    }
52
53    /// Check if this token grants access to the given path.
54    pub fn can_access(&self, path: &str) -> bool {
55        if self.is_expired() {
56            return false;
57        }
58
59        if self.scope == "*" {
60            return true;
61        }
62
63        if let Some(path_pattern) = self.scope.strip_prefix("path:") {
64            path.starts_with(path_pattern)
65        } else if self.scope.starts_with("refs/") {
66            true
67        } else {
68            path.starts_with(&self.scope)
69        }
70    }
71}
72
73/// A key ring holding scoped access tokens.
74///
75/// The root key is stored as a `SecretKey` so it is automatically zeroed
76/// on drop, preventing lingering key material in freed memory.
77pub struct ScopedKeyRing {
78    root_key: SecretKey,
79    tokens: Vec<ScopedAccessToken>,
80}
81
82impl std::fmt::Debug for ScopedKeyRing {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("ScopedKeyRing")
85            .field("root_key", &"[REDACTED]")
86            .field("tokens", &self.tokens)
87            .finish()
88    }
89}
90
91impl ScopedKeyRing {
92    /// Create a new ScopedKeyRing from a root key.
93    ///
94    /// Takes ownership of the raw bytes and wraps them in a `SecretKey`
95    /// that will be zeroed on drop.
96    pub fn new(root_key: [u8; 32]) -> Self {
97        Self {
98            root_key: SecretKey::new(root_key),
99            tokens: Vec::new(),
100        }
101    }
102
103    fn push_token(&mut self, token: ScopedAccessToken) -> &ScopedAccessToken {
104        self.tokens.push(token);
105        self.tokens.last().unwrap()
106    }
107
108    /// Create a scoped access token for the given scope.
109    pub fn create_token(
110        &mut self,
111        scope: &str,
112        expires_at: Option<u64>,
113    ) -> CryptoResult<&ScopedAccessToken> {
114        let derived_key = ContentKey::new(derive_scoped_key(self.root_key.as_bytes(), scope)?);
115        let token = ScopedAccessToken::new(scope.to_string(), derived_key, expires_at);
116        Ok(self.push_token(token))
117    }
118
119    /// Get the derived key for a scope if a valid token exists.
120    pub fn get_key_for_scope(&self, scope: &str) -> Option<&ContentKey> {
121        self.tokens
122            .iter()
123            .find(|t| t.scope == scope && !t.is_expired())
124            .map(|t| &t.derived_key)
125    }
126
127    /// Revoke all tokens for a scope.
128    pub fn revoke_scope(&mut self, scope: &str) -> bool {
129        let before = self.tokens.len();
130        self.tokens.retain(|t| t.scope != scope);
131        self.tokens.len() != before
132    }
133
134    /// Check if any valid token grants access to the given path.
135    pub fn can_access(&self, path: &str) -> bool {
136        self.tokens.iter().any(|t| t.can_access(path))
137    }
138
139    /// Get all valid (non-expired) tokens.
140    pub fn valid_tokens(&self) -> Vec<&ScopedAccessToken> {
141        self.tokens.iter().filter(|t| !t.is_expired()).collect()
142    }
143
144    /// Get the number of tokens (including expired ones).
145    pub fn token_count(&self) -> usize {
146        self.tokens.len()
147    }
148
149    /// Remove all expired tokens.
150    pub fn prune_expired(&mut self) -> usize {
151        let before = self.tokens.len();
152        self.tokens.retain(|t| !t.is_expired());
153        before - self.tokens.len()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::generate_key;
161
162    #[test]
163    fn scoped_token_creation() {
164        let root_key = generate_key();
165        let mut keyring = ScopedKeyRing::new(root_key);
166
167        let token = keyring.create_token("path:src/", None).unwrap();
168        assert_eq!(token.scope, "path:src/");
169        assert!(token.expires_at.is_none());
170        assert!(!token.is_expired());
171    }
172
173    #[test]
174    fn derive_scoped_key_deterministic() {
175        let root_key = generate_key();
176        let key1 = derive_scoped_key(&root_key, "path:src/").unwrap();
177        let key2 = derive_scoped_key(&root_key, "path:src/").unwrap();
178        assert_eq!(key1, key2);
179    }
180
181    #[test]
182    fn different_scopes_different_keys() {
183        let root_key = generate_key();
184        let key1 = derive_scoped_key(&root_key, "path:src/").unwrap();
185        let key2 = derive_scoped_key(&root_key, "path:test/").unwrap();
186        assert_ne!(key1, key2);
187    }
188
189    #[test]
190    fn keyring_can_access_checks_prefix() {
191        let root_key = generate_key();
192        let mut keyring = ScopedKeyRing::new(root_key);
193
194        keyring.create_token("path:src/", None).unwrap();
195
196        assert!(keyring.can_access("src/lib.rs"));
197        assert!(keyring.can_access("src/utils/helper.rs"));
198        assert!(!keyring.can_access("test/lib.rs"));
199    }
200
201    #[test]
202    fn token_can_access_wildcard() {
203        let token = ScopedAccessToken::new("*".to_string(), ContentKey::new([0u8; 32]), None);
204        assert!(token.can_access("anything"));
205        assert!(token.can_access("src/lib.rs"));
206    }
207
208    #[test]
209    fn refs_scope_grants_full_path_access() {
210        let token = ScopedAccessToken::new("refs/heads/main".to_string(), ContentKey::new([0u8; 32]), None);
211        assert!(token.can_access("src/lib.rs"));
212        assert!(token.can_access("test/integration.rs"));
213    }
214
215    #[test]
216    fn expired_token_denies_access() {
217        let past = SystemTime::now()
218            .duration_since(UNIX_EPOCH)
219            .unwrap()
220            .as_secs()
221            - 100;
222        let token = ScopedAccessToken::new("*".to_string(), ContentKey::new([0u8; 32]), Some(past));
223        assert!(!token.can_access("anything"));
224        assert!(token.is_expired());
225    }
226}