nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! [KWT](https://crates.io/crates/kwt) (KDL Web Token) for **`/v1/protected/*`** only.
//! Clients send **`Authorization: KWT <token>`** or **`X-KWT`**. Replay control via AetherDB.

use std::sync::Arc;

use kwt::codec::{Role, Scope};
use kwt::crypto::MasterKey;
use thiserror::Error;

use crate::kwt_replay::JtiStore;

/// Parsed KWT validator configuration (32-byte master key + expected audience).
#[derive(Clone)]
pub struct KwtAccessConfig {
    pub master_key: MasterKey,
    pub audience: String,
    pub jti_store: Arc<JtiStore>,
    /// Token must include at least one of these roles (default: [`Role::Service`]).
    pub required_roles: Vec<Role>,
    /// When non-empty, token must include at least one of these scopes.
    pub required_scopes: Vec<Scope>,
}

#[derive(Debug, Error)]
pub enum KwtEnvError {
    #[error("IMAGE_TRUST_KWT_MASTER_KEY must decode to exactly 32 bytes (64 hex chars)")]
    BadKey,
    #[error("unknown KWT role in IMAGE_TRUST_KWT_REQUIRED_ROLES: `{0}`")]
    UnknownRole(String),
    #[error("unknown KWT scope in IMAGE_TRUST_KWT_REQUIRED_SCOPES: `{0}`")]
    UnknownScope(String),
    #[error(
        "KWT_REPLAY_REQUIRE_SHARED=1 but KWT_REPLAY_AETHERDB_URL is not a remote tcp:// endpoint"
    )]
    SharedReplayRequired,
    #[error(transparent)]
    Aetherdb(#[from] crate::kwt_replay::OpenError),
}

/// Load the KWT master key from **`IMAGE_TRUST_KWT_MASTER_KEY`** (64 hex chars), if set.
pub fn kwt_master_key_from_env() -> Result<Option<MasterKey>, KwtEnvError> {
    let key_hex = match std::env::var("IMAGE_TRUST_KWT_MASTER_KEY") {
        Ok(s) if !s.is_empty() => s,
        _ => return Ok(None),
    };
    let raw = hex::decode(key_hex.trim()).map_err(|_| KwtEnvError::BadKey)?;
    let master_key = MasterKey::from_bytes(&raw).map_err(|_| KwtEnvError::BadKey)?;
    Ok(Some(master_key))
}

/// Expected audience for issued KWTs (API gate). Default **`nautalid`**.
pub fn kwt_audience_from_env() -> String {
    std::env::var("IMAGE_TRUST_KWT_AUDIENCE").unwrap_or_else(|_| "nautalid".into())
}

fn parse_roles_from_env(name: &str, default: &[Role]) -> Result<Vec<Role>, KwtEnvError> {
    match std::env::var(name) {
        Ok(raw) if !raw.trim().is_empty() => parse_role_list(&raw),
        _ => Ok(default.to_vec()),
    }
}

fn parse_scopes_from_env(name: &str) -> Result<Vec<Scope>, KwtEnvError> {
    match std::env::var(name) {
        Ok(raw) if !raw.trim().is_empty() => parse_scope_list(&raw),
        _ => Ok(Vec::new()),
    }
}

fn parse_role_list(raw: &str) -> Result<Vec<Role>, KwtEnvError> {
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(parse_role)
        .collect()
}

fn parse_scope_list(raw: &str) -> Result<Vec<Scope>, KwtEnvError> {
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(parse_scope)
        .collect()
}

fn parse_role(s: &str) -> Result<Role, KwtEnvError> {
    match s.to_ascii_lowercase().as_str() {
        "admin" => Ok(Role::Admin),
        "editor" => Ok(Role::Editor),
        "viewer" => Ok(Role::Viewer),
        "service" => Ok(Role::Service),
        other => Err(KwtEnvError::UnknownRole(other.to_string())),
    }
}

fn parse_scope(s: &str) -> Result<Scope, KwtEnvError> {
    match s.to_ascii_lowercase().as_str() {
        "read:stats" => Ok(Scope::ReadStats),
        "write:logs" => Ok(Scope::WriteLogs),
        "read:users" => Ok(Scope::ReadUsers),
        "write:users" => Ok(Scope::WriteUsers),
        "admin" => Ok(Scope::Admin),
        other => Err(KwtEnvError::UnknownScope(other.to_string())),
    }
}

/// Returns true when **`claims`** satisfy configured role/scope requirements.
pub fn claims_authorized(claims: &kwt::codec::Claims, cfg: &KwtAccessConfig) -> bool {
    if !cfg.required_roles.is_empty()
        && !cfg
            .required_roles
            .iter()
            .any(|required| claims.roles.contains(required))
    {
        return false;
    }
    if !cfg.required_scopes.is_empty()
        && !cfg
            .required_scopes
            .iter()
            .any(|required| claims.scopes.contains(required))
    {
        return false;
    }
    true
}

/// Load KWT gate config from the environment. If unset, the HTTP layer still rejects `/v1/protected/*` with **401**.
pub fn kwt_access_from_env() -> Result<Option<KwtAccessConfig>, KwtEnvError> {
    kwt_access_with_store(Arc::new(JtiStore::open_from_env()?))
}

/// Assemble KWT gate config using an existing JTI store (unified LID runtime).
pub fn kwt_access_with_store(jti_store: Arc<JtiStore>) -> Result<Option<KwtAccessConfig>, KwtEnvError> {
    let Some(master_key) = kwt_master_key_from_env()? else {
        return Ok(None);
    };
    let required_roles = parse_roles_from_env("IMAGE_TRUST_KWT_REQUIRED_ROLES", &[Role::Service])?;
    let required_scopes = parse_scopes_from_env("IMAGE_TRUST_KWT_REQUIRED_SCOPES")?;
    if replay_shared_required() && !jti_store.is_shared() {
        return Err(KwtEnvError::SharedReplayRequired);
    }
    Ok(Some(KwtAccessConfig {
        master_key,
        audience: kwt_audience_from_env(),
        jti_store,
        required_roles,
        required_scopes,
    }))
}

fn replay_shared_required() -> bool {
    matches!(
        std::env::var("KWT_REPLAY_REQUIRE_SHARED").ok().as_deref(),
        Some("1") | Some("true") | Some("yes")
    )
}

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

    #[test]
    fn default_requires_service_role() {
        let key = MasterKey::generate().unwrap();
        let cfg = KwtAccessConfig {
            master_key: key,
            audience: "nautalid".into(),
            jti_store: Arc::new(JtiStore::disabled()),
            required_roles: vec![Role::Service],
            required_scopes: Vec::new(),
        };
        let mut ok = codec::new_claims("u", "nautalid", 3600).unwrap();
        ok.roles.push(Role::Service);
        assert!(claims_authorized(&ok, &cfg));

        let mut bad = codec::new_claims("u", "nautalid", 3600).unwrap();
        bad.roles.push(Role::Viewer);
        assert!(!claims_authorized(&bad, &cfg));
    }

    #[test]
    fn embedded_aetherdb_is_not_shared() {
        let store = JtiStore::open_from_env().expect("aetherdb");
        assert_eq!(store.backend_label(), "aetherdb");
        assert!(!store.is_shared());
    }
}