st-protocol 0.1.0

Binary wire protocol for smart-tree daemon communication
Documentation
//! Authentication and security
//!
//! ## Auth Block Format
//!
//! ```text
//! Protected operation:
//!   0x0E                  ; SO = AUTH_START
//!   [level: 1B]           ; 0x01=pin, 0x02=fido, 0x03=bio
//!   [session: 16B]        ; UUID
//!   [sig: 32B]            ; Ed25519 signature
//!   0x0F                  ; SI = AUTH_END
//!   [verb]                ; Actual operation
//!   ...payload...
//!   0x00                  ; END
//! ```
//!
//! ## Security Levels
//!
//! - Level 0x00: Read-only (SCAN, SEARCH, STATS) - no auth required
//! - Level 0x01: Local write (FORMAT output, temp files) - session required
//! - Level 0x02: Mutate (EDIT, DELETE) - requires FIDO
//! - Level 0x03: Admin (PERMIT, config changes) - requires FIDO + PIN

#[cfg(feature = "std")]
extern crate std as alloc;

#[cfg(all(feature = "alloc", not(feature = "std")))]
extern crate alloc;

use crate::{Verb, ProtocolError, ProtocolResult};

/// Authentication level required for operations
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum AuthLevel {
    /// No authentication required (read-only operations)
    None = 0x00,
    /// Session token required (local writes)
    Session = 0x01,
    /// FIDO/WebAuthn required (mutations)
    Fido = 0x02,
    /// FIDO + PIN required (admin operations)
    FidoPin = 0x03,
}

impl AuthLevel {
    pub fn from_byte(b: u8) -> Option<Self> {
        match b {
            0x00 => Some(AuthLevel::None),
            0x01 => Some(AuthLevel::Session),
            0x02 => Some(AuthLevel::Fido),
            0x03 => Some(AuthLevel::FidoPin),
            _ => None,
        }
    }

    pub fn as_byte(self) -> u8 {
        self as u8
    }

    /// Human readable name
    pub fn name(self) -> &'static str {
        match self {
            AuthLevel::None => "none",
            AuthLevel::Session => "session",
            AuthLevel::Fido => "fido",
            AuthLevel::FidoPin => "fido+pin",
        }
    }
}

/// Session UUID (16 bytes)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct SessionId([u8; 16]);

impl SessionId {
    pub fn new(bytes: [u8; 16]) -> Self {
        SessionId(bytes)
    }

    pub fn from_slice(slice: &[u8]) -> Option<Self> {
        if slice.len() != 16 {
            return None;
        }
        let mut bytes = [0u8; 16];
        bytes.copy_from_slice(slice);
        Some(SessionId(bytes))
    }

    pub fn as_bytes(&self) -> &[u8; 16] {
        &self.0
    }

    /// Generate random session ID (std only)
    #[cfg(feature = "std")]
    pub fn random() -> Self {
        use std::time::{SystemTime, UNIX_EPOCH};

        // Simple pseudo-random based on time (for now)
        // Real implementation should use proper RNG
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();

        let mut bytes = [0u8; 16];
        for (i, b) in bytes.iter_mut().enumerate() {
            *b = ((now >> (i * 8)) & 0xFF) as u8;
        }
        SessionId(bytes)
    }
}


/// Ed25519 signature (32 bytes)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Signature([u8; 32]);

impl Signature {
    pub fn new(bytes: [u8; 32]) -> Self {
        Signature(bytes)
    }

    pub fn from_slice(slice: &[u8]) -> Option<Self> {
        if slice.len() != 32 {
            return None;
        }
        let mut bytes = [0u8; 32];
        bytes.copy_from_slice(slice);
        Some(Signature(bytes))
    }

    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Empty/null signature
    pub fn empty() -> Self {
        Signature([0u8; 32])
    }
}

impl Default for Signature {
    fn default() -> Self {
        Self::empty()
    }
}

/// Authentication block parsed from wire format
#[derive(Debug, Clone)]
pub struct AuthBlock {
    /// Required authentication level
    pub level: AuthLevel,
    /// Session identifier
    pub session: SessionId,
    /// Ed25519 signature over session + payload
    pub signature: Signature,
}

impl AuthBlock {
    /// Create a new auth block
    pub fn new(level: AuthLevel, session: SessionId, signature: Signature) -> Self {
        AuthBlock {
            level,
            session,
            signature,
        }
    }

    /// Create minimal auth block with just session
    pub fn with_session(session: SessionId) -> Self {
        AuthBlock {
            level: AuthLevel::Session,
            session,
            signature: Signature::empty(),
        }
    }

    /// Auth block size in bytes (1 + 16 + 32 = 49)
    pub const SIZE: usize = 1 + 16 + 32;

    /// Encode auth block (without SO/SI markers)
    #[cfg(any(feature = "std", feature = "alloc"))]
    pub fn encode(&self) -> alloc::vec::Vec<u8> {
        let mut out = alloc::vec::Vec::with_capacity(Self::SIZE);
        out.push(self.level.as_byte());
        out.extend_from_slice(self.session.as_bytes());
        out.extend_from_slice(self.signature.as_bytes());
        out
    }

    /// Decode auth block (without SO/SI markers)
    pub fn decode(data: &[u8]) -> ProtocolResult<Self> {
        if data.len() < Self::SIZE {
            return Err(ProtocolError::InvalidAuthBlock);
        }

        let level = AuthLevel::from_byte(data[0])
            .ok_or(ProtocolError::InvalidAuthBlock)?;
        let session = SessionId::from_slice(&data[1..17])
            .ok_or(ProtocolError::InvalidAuthBlock)?;
        let signature = Signature::from_slice(&data[17..49])
            .ok_or(ProtocolError::InvalidAuthBlock)?;

        Ok(AuthBlock {
            level,
            session,
            signature,
        })
    }
}

/// Security context for a connection
#[derive(Debug, Clone)]
pub struct SecurityContext {
    /// Current session (if authenticated)
    session: Option<SessionId>,
    /// Authenticated level
    level: AuthLevel,
    /// User identifier (if known)
    user: Option<[u8; 32]>,
}

impl Default for SecurityContext {
    fn default() -> Self {
        Self::new()
    }
}

impl SecurityContext {
    /// Create unauthenticated context
    pub fn new() -> Self {
        SecurityContext {
            session: None,
            level: AuthLevel::None,
            user: None,
        }
    }

    /// Create authenticated context
    pub fn authenticated(session: SessionId, level: AuthLevel) -> Self {
        SecurityContext {
            session: Some(session),
            level,
            user: None,
        }
    }

    /// Get current session
    pub fn session(&self) -> Option<&SessionId> {
        self.session.as_ref()
    }

    /// Get authentication level
    pub fn level(&self) -> AuthLevel {
        self.level
    }

    /// Check if a verb is permitted
    pub fn can_execute(&self, verb: Verb) -> bool {
        let required = verb.security_level();
        self.level as u8 >= required
    }

    /// Elevate to higher auth level
    pub fn elevate(&mut self, level: AuthLevel, session: SessionId) {
        if level > self.level {
            self.level = level;
            self.session = Some(session);
        }
    }

    /// Check if authenticated at all
    pub fn is_authenticated(&self) -> bool {
        self.level > AuthLevel::None
    }

    /// Set user identifier
    pub fn set_user(&mut self, user: [u8; 32]) {
        self.user = Some(user);
    }
}

/// Protected paths that require elevation
pub const PROTECTED_PATHS: &[&str] = &[
    "~/.claude/settings.json",
    "~/.claude/",
    "~/.config/",
    "~/.ssh/",
    "~/.gnupg/",
    "/etc/",
];

/// Check if a path requires elevated access
pub fn is_protected_path(path: &str) -> bool {
    for protected in PROTECTED_PATHS {
        if path.starts_with(protected) || path.contains(protected) {
            return true;
        }
    }
    false
}

/// Get required auth level for a path
pub fn path_auth_level(path: &str) -> AuthLevel {
    if path.contains("/.claude/") || path.contains("/.ssh/") || path.contains("/.gnupg/") {
        AuthLevel::Fido // FIDO required for sensitive configs
    } else if path.starts_with("/etc/") {
        AuthLevel::FidoPin // Admin required for system files
    } else if is_protected_path(path) {
        AuthLevel::Session // At least session for other protected paths
    } else {
        AuthLevel::None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_auth_levels() {
        assert!(AuthLevel::FidoPin > AuthLevel::Fido);
        assert!(AuthLevel::Fido > AuthLevel::Session);
        assert!(AuthLevel::Session > AuthLevel::None);
    }

    #[test]
    fn test_auth_block_roundtrip() {
        let original = AuthBlock {
            level: AuthLevel::Fido,
            session: SessionId::new([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
            signature: Signature::new([42u8; 32]),
        };

        let encoded = original.encode();
        let decoded = AuthBlock::decode(&encoded).unwrap();

        assert_eq!(decoded.level, original.level);
        assert_eq!(decoded.session.as_bytes(), original.session.as_bytes());
        assert_eq!(decoded.signature.as_bytes(), original.signature.as_bytes());
    }

    #[test]
    fn test_security_context() {
        let mut ctx = SecurityContext::new();

        // Can always execute read-only
        assert!(ctx.can_execute(Verb::Scan));
        assert!(ctx.can_execute(Verb::Ping));

        // Cannot execute protected operations
        assert!(!ctx.can_execute(Verb::Permit));

        // Elevate
        ctx.elevate(AuthLevel::FidoPin, SessionId::default());
        assert!(ctx.can_execute(Verb::Permit));
    }

    #[test]
    fn test_protected_paths() {
        assert!(is_protected_path("~/.claude/settings.json"));
        assert!(is_protected_path("/etc/passwd"));
        assert!(!is_protected_path("/home/user/projects/foo"));
    }

    #[test]
    fn test_path_auth_level() {
        assert_eq!(path_auth_level("/home/user/file.txt"), AuthLevel::None);
        assert_eq!(path_auth_level("~/.claude/settings.json"), AuthLevel::Fido);
        assert_eq!(path_auth_level("/etc/hosts"), AuthLevel::FidoPin);
    }
}