use std::sync::Arc;
use kwt::codec::{Role, Scope};
use kwt::crypto::MasterKey;
use thiserror::Error;
use crate::kwt_replay::JtiStore;
#[derive(Clone)]
pub struct KwtAccessConfig {
pub master_key: MasterKey,
pub audience: String,
pub jti_store: Arc<JtiStore>,
pub required_roles: Vec<Role>,
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),
}
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))
}
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())),
}
}
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
}
pub fn kwt_access_from_env() -> Result<Option<KwtAccessConfig>, KwtEnvError> {
kwt_access_with_store(Arc::new(JtiStore::open_from_env()?))
}
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());
}
}