use base64::{Engine, engine::general_purpose::STANDARD as B64};
use ijima_core::{IjimaError, Result};
use schubert::{
AccessController, AccessDecision, PrincipalId,
crypto::{CapabilityIssuer, CapabilityToken, CapabilityVerifier},
};
const POLICY_TOML: &str = include_str!("../policy/policy.toml");
const ISSUER_KEY_LEN: usize = 32;
const SIGNATURE_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticatedPrincipal {
pub principal: PrincipalId,
pub capability: String,
}
impl AuthenticatedPrincipal {
pub fn may(&self, required: &str) -> bool {
self.capability == required || self.capability == ijima_core::capabilities::ADMIN
}
pub fn personal_namespace(&self) -> ijima_core::NamespaceId {
ijima_core::NamespaceId::new(format!("ns_{}_private", self.principal.as_str()))
}
}
#[derive(Debug)]
pub struct IjimaAuth {
controller: AccessController,
issuer: CapabilityIssuer,
verifier: CapabilityVerifier,
}
impl IjimaAuth {
pub fn from_embedded_policy() -> Result<Self> {
Self::from_embedded_policy_with_seed(Self::generate_seed())
}
pub fn from_embedded_policy_with_seed(seed: [u8; 32]) -> Result<Self> {
let controller = AccessController::from_policy_toml(POLICY_TOML)
.map_err(|e| IjimaError::invalid_input(format!("policy load: {e}")))?;
let issuer = CapabilityIssuer::from_seed(seed);
let verifier = CapabilityVerifier::new(issuer.public_key());
Ok(Self {
controller,
issuer,
verifier,
})
}
pub fn generate_seed() -> [u8; 32] {
use rand::TryRngCore;
let mut seed = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut seed)
.expect("OsRng is infallible in practice");
seed
}
pub fn issuer_public_key_hex(&self) -> String {
self.issuer
.public_key()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn grassmannian(&self) -> (usize, usize) {
self.controller.grassmannian()
}
pub fn issue_bearer(
&self,
principal: impl Into<PrincipalId>,
capability: impl AsRef<str>,
) -> Result<String> {
let capability_str = capability.as_ref();
let token = self
.issuer
.issue(principal, capability_str)
.map_err(|e| IjimaError::invalid_input(format!("token issue: {e}")))?;
encode_token(&token)
}
pub fn verify_bearer(&self, bearer: &str) -> Result<AuthenticatedPrincipal> {
let token = decode_token(bearer)?;
let (principal, capability) = self
.verifier
.verify_and_extract(&token)
.map_err(|e| IjimaError::invalid_input(format!("token verify: {e}")))?;
Ok(AuthenticatedPrincipal {
principal: principal.clone(),
capability: capability.as_str().to_string(),
})
}
pub fn check(&self, principal: &PrincipalId, required: &[&str]) -> Result<AccessDecision> {
self.controller
.check(principal, required)
.map_err(|e| IjimaError::invalid_input(format!("access check: {e}")))
}
pub fn require(&self, bearer: &str, required: &str) -> Result<AuthenticatedPrincipal> {
let principal = self.verify_bearer(bearer)?;
if principal.capability == required
|| principal.capability == ijima_core::capabilities::ADMIN
{
Ok(principal)
} else {
Err(IjimaError::invalid_input(format!(
"access denied: token grants '{}' but '{}' is required",
principal.capability, required
)))
}
}
}
fn encode_token(token: &CapabilityToken) -> Result<String> {
let p = token.principal.as_str().as_bytes();
let c = token.capability.as_str().as_bytes();
if p.len() > u16::MAX as usize || c.len() > u16::MAX as usize {
return Err(IjimaError::invalid_input("token field too long"));
}
let mut buf = Vec::with_capacity(2 + p.len() + 2 + c.len() + ISSUER_KEY_LEN + SIGNATURE_LEN);
buf.extend_from_slice(&(p.len() as u16).to_be_bytes());
buf.extend_from_slice(p);
buf.extend_from_slice(&(c.len() as u16).to_be_bytes());
buf.extend_from_slice(c);
if token.issuer_key.len() != ISSUER_KEY_LEN || token.signature.len() != SIGNATURE_LEN {
return Err(IjimaError::invalid_input(
"malformed issuer key or signature",
));
}
buf.extend_from_slice(&token.issuer_key);
buf.extend_from_slice(&token.signature);
Ok(B64.encode(&buf))
}
fn decode_token(bearer: &str) -> Result<CapabilityToken> {
let buf = B64
.decode(bearer.trim())
.map_err(|e| IjimaError::invalid_input(format!("base64 decode: {e}")))?;
let mut pos = 0;
let plen = read_u16(&buf, &mut pos)?;
let principal = read_str(&buf, &mut pos, plen)?;
let clen = read_u16(&buf, &mut pos)?;
let capability = read_str(&buf, &mut pos, clen)?;
let issuer_key = read_bytes(&buf, &mut pos, ISSUER_KEY_LEN)?;
let signature = read_bytes(&buf, &mut pos, SIGNATURE_LEN)?;
if pos != buf.len() {
return Err(IjimaError::invalid_input("trailing bytes in token"));
}
Ok(CapabilityToken {
principal: PrincipalId::new(principal),
capability: schubert::CapabilityId::new(capability),
issuer_key: issuer_key.to_vec(),
signature: signature.to_vec(),
})
}
fn read_u16(buf: &[u8], pos: &mut usize) -> Result<usize> {
if *pos + 2 > buf.len() {
return Err(IjimaError::invalid_input("truncated token length"));
}
let v = u16::from_be_bytes([buf[*pos], buf[*pos + 1]]) as usize;
*pos += 2;
Ok(v)
}
fn read_str(buf: &[u8], pos: &mut usize, len: usize) -> Result<String> {
let bytes = read_bytes(buf, pos, len)?;
String::from_utf8(bytes.to_vec())
.map_err(|e| IjimaError::invalid_input(format!("non-utf8 token field: {e}")))
}
fn read_bytes<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> {
if *pos + len > buf.len() {
return Err(IjimaError::invalid_input("truncated token field"));
}
let slice = &buf[*pos..*pos + len];
*pos += len;
Ok(slice)
}
#[cfg(test)]
mod tests {
use super::*;
use ijima_core::capabilities::{ADMIN, MEMORY_READ, MEMORY_WRITE};
fn fresh() -> IjimaAuth {
IjimaAuth::from_embedded_policy().expect("embedded policy must load")
}
#[test]
fn embedded_policy_loads_on_gr_4_8() {
let auth = fresh();
assert_eq!(auth.grassmannian(), (4, 8));
}
#[test]
fn issue_then_verify_round_trips() {
let auth = fresh();
let bearer = auth
.issue_bearer("elliott", MEMORY_READ)
.expect("must issue");
let principal = auth.verify_bearer(&bearer).expect("must verify");
assert_eq!(principal.principal.as_str(), "elliott");
assert_eq!(principal.capability, MEMORY_READ);
}
#[test]
fn tampered_signature_is_rejected() {
let auth = fresh();
let mut buf = B64
.decode(
auth.issue_bearer("elliott", MEMORY_READ)
.expect("must issue"),
)
.unwrap();
let last = buf.len() - 1;
buf[last] ^= 0xff;
let tampered = B64.encode(&buf);
assert!(auth.verify_bearer(&tampered).is_err());
}
#[test]
fn admin_token_grants_any_capability() {
let auth = fresh();
let bearer = auth.issue_bearer("root", ADMIN).expect("must issue");
let principal = auth.require(&bearer, MEMORY_READ).expect("admin may read");
assert_eq!(principal.principal.as_str(), "root");
}
#[test]
fn read_token_does_not_grant_write() {
let auth = fresh();
let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
assert!(auth.require(&bearer, MEMORY_WRITE).is_err());
}
#[test]
fn malformed_bearer_rejected() {
let auth = fresh();
assert!(auth.verify_bearer("not-base64!!!").is_err());
assert!(auth.verify_bearer("").is_err());
}
#[test]
fn seed_based_issue_then_verify_across_instances() {
let seed = IjimaAuth::generate_seed();
let issuer = IjimaAuth::from_embedded_policy_with_seed(seed).expect("issuer");
let bearer = issuer
.issue_bearer("elliott", MEMORY_READ)
.expect("must issue");
let public_key = issuer.issuer_public_key_hex();
assert_eq!(public_key.len(), 64);
let daemon = IjimaAuth::from_embedded_policy_with_seed(seed).expect("daemon");
let principal = daemon.verify_bearer(&bearer).expect("must verify");
assert_eq!(principal.principal.as_str(), "elliott");
assert_eq!(principal.capability, MEMORY_READ);
assert_eq!(daemon.issuer_public_key_hex(), public_key);
}
}