Skip to main content

st_protocol/
auth.rs

1//! Authentication and security
2//!
3//! ## Auth Block Format
4//!
5//! ```text
6//! Protected operation:
7//!   0x0E                  ; SO = AUTH_START
8//!   [level: 1B]           ; 0x01=pin, 0x02=fido, 0x03=bio
9//!   [session: 16B]        ; UUID
10//!   [sig: 32B]            ; Ed25519 signature
11//!   0x0F                  ; SI = AUTH_END
12//!   [verb]                ; Actual operation
13//!   ...payload...
14//!   0x00                  ; END
15//! ```
16//!
17//! ## Security Levels
18//!
19//! - Level 0x00: Read-only (SCAN, SEARCH, STATS) - no auth required
20//! - Level 0x01: Local write (FORMAT output, temp files) - session required
21//! - Level 0x02: Mutate (EDIT, DELETE) - requires FIDO
22//! - Level 0x03: Admin (PERMIT, config changes) - requires FIDO + PIN
23
24#[cfg(feature = "std")]
25extern crate std as alloc;
26
27#[cfg(all(feature = "alloc", not(feature = "std")))]
28extern crate alloc;
29
30use crate::{Verb, ProtocolError, ProtocolResult};
31
32/// Authentication level required for operations
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34#[repr(u8)]
35pub enum AuthLevel {
36    /// No authentication required (read-only operations)
37    None = 0x00,
38    /// Session token required (local writes)
39    Session = 0x01,
40    /// FIDO/WebAuthn required (mutations)
41    Fido = 0x02,
42    /// FIDO + PIN required (admin operations)
43    FidoPin = 0x03,
44}
45
46impl AuthLevel {
47    pub fn from_byte(b: u8) -> Option<Self> {
48        match b {
49            0x00 => Some(AuthLevel::None),
50            0x01 => Some(AuthLevel::Session),
51            0x02 => Some(AuthLevel::Fido),
52            0x03 => Some(AuthLevel::FidoPin),
53            _ => None,
54        }
55    }
56
57    pub fn as_byte(self) -> u8 {
58        self as u8
59    }
60
61    /// Human readable name
62    pub fn name(self) -> &'static str {
63        match self {
64            AuthLevel::None => "none",
65            AuthLevel::Session => "session",
66            AuthLevel::Fido => "fido",
67            AuthLevel::FidoPin => "fido+pin",
68        }
69    }
70}
71
72/// Session UUID (16 bytes)
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
74pub struct SessionId([u8; 16]);
75
76impl SessionId {
77    pub fn new(bytes: [u8; 16]) -> Self {
78        SessionId(bytes)
79    }
80
81    pub fn from_slice(slice: &[u8]) -> Option<Self> {
82        if slice.len() != 16 {
83            return None;
84        }
85        let mut bytes = [0u8; 16];
86        bytes.copy_from_slice(slice);
87        Some(SessionId(bytes))
88    }
89
90    pub fn as_bytes(&self) -> &[u8; 16] {
91        &self.0
92    }
93
94    /// Generate random session ID (std only)
95    #[cfg(feature = "std")]
96    pub fn random() -> Self {
97        use std::time::{SystemTime, UNIX_EPOCH};
98
99        // Simple pseudo-random based on time (for now)
100        // Real implementation should use proper RNG
101        let now = SystemTime::now()
102            .duration_since(UNIX_EPOCH)
103            .unwrap()
104            .as_nanos();
105
106        let mut bytes = [0u8; 16];
107        for (i, b) in bytes.iter_mut().enumerate() {
108            *b = ((now >> (i * 8)) & 0xFF) as u8;
109        }
110        SessionId(bytes)
111    }
112}
113
114
115/// Ed25519 signature (32 bytes)
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Signature([u8; 32]);
118
119impl Signature {
120    pub fn new(bytes: [u8; 32]) -> Self {
121        Signature(bytes)
122    }
123
124    pub fn from_slice(slice: &[u8]) -> Option<Self> {
125        if slice.len() != 32 {
126            return None;
127        }
128        let mut bytes = [0u8; 32];
129        bytes.copy_from_slice(slice);
130        Some(Signature(bytes))
131    }
132
133    pub fn as_bytes(&self) -> &[u8; 32] {
134        &self.0
135    }
136
137    /// Empty/null signature
138    pub fn empty() -> Self {
139        Signature([0u8; 32])
140    }
141}
142
143impl Default for Signature {
144    fn default() -> Self {
145        Self::empty()
146    }
147}
148
149/// Authentication block parsed from wire format
150#[derive(Debug, Clone)]
151pub struct AuthBlock {
152    /// Required authentication level
153    pub level: AuthLevel,
154    /// Session identifier
155    pub session: SessionId,
156    /// Ed25519 signature over session + payload
157    pub signature: Signature,
158}
159
160impl AuthBlock {
161    /// Create a new auth block
162    pub fn new(level: AuthLevel, session: SessionId, signature: Signature) -> Self {
163        AuthBlock {
164            level,
165            session,
166            signature,
167        }
168    }
169
170    /// Create minimal auth block with just session
171    pub fn with_session(session: SessionId) -> Self {
172        AuthBlock {
173            level: AuthLevel::Session,
174            session,
175            signature: Signature::empty(),
176        }
177    }
178
179    /// Auth block size in bytes (1 + 16 + 32 = 49)
180    pub const SIZE: usize = 1 + 16 + 32;
181
182    /// Encode auth block (without SO/SI markers)
183    #[cfg(any(feature = "std", feature = "alloc"))]
184    pub fn encode(&self) -> alloc::vec::Vec<u8> {
185        let mut out = alloc::vec::Vec::with_capacity(Self::SIZE);
186        out.push(self.level.as_byte());
187        out.extend_from_slice(self.session.as_bytes());
188        out.extend_from_slice(self.signature.as_bytes());
189        out
190    }
191
192    /// Decode auth block (without SO/SI markers)
193    pub fn decode(data: &[u8]) -> ProtocolResult<Self> {
194        if data.len() < Self::SIZE {
195            return Err(ProtocolError::InvalidAuthBlock);
196        }
197
198        let level = AuthLevel::from_byte(data[0])
199            .ok_or(ProtocolError::InvalidAuthBlock)?;
200        let session = SessionId::from_slice(&data[1..17])
201            .ok_or(ProtocolError::InvalidAuthBlock)?;
202        let signature = Signature::from_slice(&data[17..49])
203            .ok_or(ProtocolError::InvalidAuthBlock)?;
204
205        Ok(AuthBlock {
206            level,
207            session,
208            signature,
209        })
210    }
211}
212
213/// Security context for a connection
214#[derive(Debug, Clone)]
215pub struct SecurityContext {
216    /// Current session (if authenticated)
217    session: Option<SessionId>,
218    /// Authenticated level
219    level: AuthLevel,
220    /// User identifier (if known)
221    user: Option<[u8; 32]>,
222}
223
224impl Default for SecurityContext {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl SecurityContext {
231    /// Create unauthenticated context
232    pub fn new() -> Self {
233        SecurityContext {
234            session: None,
235            level: AuthLevel::None,
236            user: None,
237        }
238    }
239
240    /// Create authenticated context
241    pub fn authenticated(session: SessionId, level: AuthLevel) -> Self {
242        SecurityContext {
243            session: Some(session),
244            level,
245            user: None,
246        }
247    }
248
249    /// Get current session
250    pub fn session(&self) -> Option<&SessionId> {
251        self.session.as_ref()
252    }
253
254    /// Get authentication level
255    pub fn level(&self) -> AuthLevel {
256        self.level
257    }
258
259    /// Check if a verb is permitted
260    pub fn can_execute(&self, verb: Verb) -> bool {
261        let required = verb.security_level();
262        self.level as u8 >= required
263    }
264
265    /// Elevate to higher auth level
266    pub fn elevate(&mut self, level: AuthLevel, session: SessionId) {
267        if level > self.level {
268            self.level = level;
269            self.session = Some(session);
270        }
271    }
272
273    /// Check if authenticated at all
274    pub fn is_authenticated(&self) -> bool {
275        self.level > AuthLevel::None
276    }
277
278    /// Set user identifier
279    pub fn set_user(&mut self, user: [u8; 32]) {
280        self.user = Some(user);
281    }
282}
283
284/// Protected paths that require elevation
285pub const PROTECTED_PATHS: &[&str] = &[
286    "~/.claude/settings.json",
287    "~/.claude/",
288    "~/.config/",
289    "~/.ssh/",
290    "~/.gnupg/",
291    "/etc/",
292];
293
294/// Check if a path requires elevated access
295pub fn is_protected_path(path: &str) -> bool {
296    for protected in PROTECTED_PATHS {
297        if path.starts_with(protected) || path.contains(protected) {
298            return true;
299        }
300    }
301    false
302}
303
304/// Get required auth level for a path
305pub fn path_auth_level(path: &str) -> AuthLevel {
306    if path.contains("/.claude/") || path.contains("/.ssh/") || path.contains("/.gnupg/") {
307        AuthLevel::Fido // FIDO required for sensitive configs
308    } else if path.starts_with("/etc/") {
309        AuthLevel::FidoPin // Admin required for system files
310    } else if is_protected_path(path) {
311        AuthLevel::Session // At least session for other protected paths
312    } else {
313        AuthLevel::None
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_auth_levels() {
323        assert!(AuthLevel::FidoPin > AuthLevel::Fido);
324        assert!(AuthLevel::Fido > AuthLevel::Session);
325        assert!(AuthLevel::Session > AuthLevel::None);
326    }
327
328    #[test]
329    fn test_auth_block_roundtrip() {
330        let original = AuthBlock {
331            level: AuthLevel::Fido,
332            session: SessionId::new([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
333            signature: Signature::new([42u8; 32]),
334        };
335
336        let encoded = original.encode();
337        let decoded = AuthBlock::decode(&encoded).unwrap();
338
339        assert_eq!(decoded.level, original.level);
340        assert_eq!(decoded.session.as_bytes(), original.session.as_bytes());
341        assert_eq!(decoded.signature.as_bytes(), original.signature.as_bytes());
342    }
343
344    #[test]
345    fn test_security_context() {
346        let mut ctx = SecurityContext::new();
347
348        // Can always execute read-only
349        assert!(ctx.can_execute(Verb::Scan));
350        assert!(ctx.can_execute(Verb::Ping));
351
352        // Cannot execute protected operations
353        assert!(!ctx.can_execute(Verb::Permit));
354
355        // Elevate
356        ctx.elevate(AuthLevel::FidoPin, SessionId::default());
357        assert!(ctx.can_execute(Verb::Permit));
358    }
359
360    #[test]
361    fn test_protected_paths() {
362        assert!(is_protected_path("~/.claude/settings.json"));
363        assert!(is_protected_path("/etc/passwd"));
364        assert!(!is_protected_path("/home/user/projects/foo"));
365    }
366
367    #[test]
368    fn test_path_auth_level() {
369        assert_eq!(path_auth_level("/home/user/file.txt"), AuthLevel::None);
370        assert_eq!(path_auth_level("~/.claude/settings.json"), AuthLevel::Fido);
371        assert_eq!(path_auth_level("/etc/hosts"), AuthLevel::FidoPin);
372    }
373}