Skip to main content

sirr_server/store/
keys.rs

1use constant_time_eq::constant_time_eq_32;
2use serde::{Deserialize, Serialize};
3
4/// An API authentication credential. Stored in three redb tables:
5/// `keys_by_id`, `keys_by_hash` (blake3 token hash → id), `keys_by_name` (name → id).
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct KeyRecord {
8    /// ULID — sortable, displayed in CLI output.
9    pub id: String,
10    /// Operator-chosen human label. Unique per server.
11    pub name: String,
12    /// blake3 hash of the bearer token. The plaintext token is shown once and discarded.
13    pub hash: [u8; 32],
14    /// Unix seconds when the key was created.
15    pub created_at: i64,
16    /// Optional time-window start (unix seconds). `None` = no lower bound.
17    pub valid_after: Option<i64>,
18    /// Optional time-window end (unix seconds). `None` = no upper bound.
19    pub valid_before: Option<i64>,
20    /// Optional webhook URL fired on lifecycle events for secrets owned by this key.
21    pub webhook_url: Option<String>,
22}
23
24impl KeyRecord {
25    /// True when the key is within its validity window.
26    pub fn is_active(&self, now: i64) -> bool {
27        if let Some(after) = self.valid_after {
28            if now < after {
29                return false;
30            }
31        }
32        if let Some(before) = self.valid_before {
33            if now >= before {
34                return false;
35            }
36        }
37        true
38    }
39
40    /// Constant-time comparison: blake3-hash `token` and compare to stored `hash`.
41    pub fn verify_token(&self, token: &[u8]) -> bool {
42        let computed = blake3::hash(token);
43        constant_time_eq_32(computed.as_bytes(), &self.hash)
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    fn sample_hash() -> [u8; 32] {
52        *blake3::hash(b"my-secret-token").as_bytes()
53    }
54
55    fn sample() -> KeyRecord {
56        KeyRecord {
57            id: "01HZ_SAMPLE".to_string(),
58            name: "alice".to_string(),
59            hash: sample_hash(),
60            created_at: 1_712_846_400,
61            valid_after: None,
62            valid_before: None,
63            webhook_url: None,
64        }
65    }
66
67    #[test]
68    fn is_active_no_window() {
69        let k = sample();
70        assert!(k.is_active(0));
71        assert!(k.is_active(9_999_999_999));
72    }
73
74    #[test]
75    fn is_active_before_window_start() {
76        let mut k = sample();
77        k.valid_after = Some(1_000_000);
78        assert!(!k.is_active(999_999));
79        assert!(k.is_active(1_000_000));
80        assert!(k.is_active(2_000_000));
81    }
82
83    #[test]
84    fn is_active_after_window_end() {
85        let mut k = sample();
86        k.valid_before = Some(1_000_000);
87        assert!(k.is_active(999_999));
88        assert!(!k.is_active(1_000_000));
89        assert!(!k.is_active(2_000_000));
90    }
91
92    #[test]
93    fn is_active_within_window() {
94        let mut k = sample();
95        k.valid_after = Some(1_000);
96        k.valid_before = Some(2_000);
97        assert!(!k.is_active(999));
98        assert!(k.is_active(1_000));
99        assert!(k.is_active(1_500));
100        assert!(!k.is_active(2_000));
101    }
102
103    #[test]
104    fn verify_token_correct() {
105        let k = sample();
106        assert!(k.verify_token(b"my-secret-token"));
107    }
108
109    #[test]
110    fn verify_token_wrong() {
111        let k = sample();
112        assert!(!k.verify_token(b"wrong-token"));
113    }
114
115    #[test]
116    fn bincode_round_trip() {
117        let record = KeyRecord {
118            id: "01JTEST".to_string(),
119            name: "bob".to_string(),
120            hash: [42u8; 32],
121            created_at: 1_712_846_400,
122            valid_after: Some(1_000_000),
123            valid_before: None,
124            webhook_url: Some("https://example.com/hook".to_string()),
125        };
126
127        let encoded = bincode::serde::encode_to_vec(&record, bincode::config::standard()).unwrap();
128        let (decoded, _): (KeyRecord, _) =
129            bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
130
131        assert_eq!(record, decoded);
132    }
133}