Skip to main content

ijima_server/
auth.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Authentication + authorization via Schubert proof-carrying capability
5//! tokens.
6//!
7//! Per `docs/DESIGN.md` D4, Ijima uses Schubert for **both** authn and
8//! authz: a [`schubert::crypto::CapabilityToken`] is Ed25519-signed by an
9//! issuer and carries a `principal` + `capability`. Verifying the
10//! signature authenticates the principal; the [`AccessController`]
11//! authorizes the action geometrically on Gr(4,8).
12//!
13//! The capability vocabulary is declarative TOML at
14//! [`policy/policy.toml`](https://github.com/Industrial-Algebra/Ijima) —
15//! selected by Schubert's `recommend` CLI for Ijima's constraints
16//! (Gr(4,8), dim 16, features std/crypto/policy).
17//!
18//! ## Wire format
19//!
20//! Bearer tokens are base64 of a length-prefixed binary blob:
21//!
22//! ```text
23//! u16 BE principal_len | principal utf-8
24//! u16 BE capability_len | capability utf-8
25//! 32 bytes issuer public key
26//! 64 bytes Ed25519 signature
27//! ```
28//!
29//! The signature covers `principal \\0 capability \\0 issuer_key` (the
30//! exact message Schubert's issuer signs), so the wire format is pure
31//! transport — verification re-checks the signature cryptographically.
32
33use base64::{Engine, engine::general_purpose::STANDARD as B64};
34use ijima_core::{IjimaError, Result};
35use schubert::{
36    AccessController, AccessDecision, PrincipalId,
37    crypto::{CapabilityIssuer, CapabilityToken, CapabilityVerifier},
38};
39
40/// Ijima's Schubert policy, embedded at compile time.
41const POLICY_TOML: &str = include_str!("../policy/policy.toml");
42
43const ISSUER_KEY_LEN: usize = 32;
44const SIGNATURE_LEN: usize = 64;
45
46/// The authenticated principal + capability carried by a verified token.
47///
48/// Produced by [`IjimaAuth::verify_bearer`]. Handlers consult the
49/// `capability` field via [`may`](Self::may) to enforce a specific
50/// capability.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AuthenticatedPrincipal {
53    /// The principal this token was issued to.
54    pub principal: PrincipalId,
55    /// The single capability this token grants.
56    pub capability: String,
57}
58
59impl AuthenticatedPrincipal {
60    /// Returns true if this token's capability grants `required`,
61    /// directly or via the `admin` (point-class) capability.
62    pub fn may(&self, required: &str) -> bool {
63        self.capability == required || self.capability == ijima_core::capabilities::ADMIN
64    }
65
66    /// This principal's default personal namespace id
67    /// (`ns_<principal>_private`). Every request is scoped to this
68    /// namespace unless explicit namespace parameters land later.
69    pub fn personal_namespace(&self) -> ijima_core::NamespaceId {
70        ijima_core::NamespaceId::new(format!("ns_{}_private", self.principal.as_str()))
71    }
72}
73
74/// Ijima's auth core: an [`AccessController`] (authz) plus a capability
75/// token issuer and verifier (authn) sharing one Ed25519 key.
76///
77/// A daemon constructs one of these at startup; an admin CLI uses the
78/// issuer to mint tokens via [`IjimaAuth::issue_bearer`].
79#[derive(Debug)]
80pub struct IjimaAuth {
81    controller: AccessController,
82    issuer: CapabilityIssuer,
83    verifier: CapabilityVerifier,
84}
85
86impl IjimaAuth {
87    /// Loads the embedded `policy/policy.toml` and generates a fresh
88    /// Ed25519 issuer key pair.
89    ///
90    /// Use only for tests/ephemeral runs — every call produces a new key,
91    /// so issued tokens will not verify against a different instance. For
92    /// a persistent daemon/CLI, use
93    /// [`from_embedded_policy_with_seed`](Self::from_embedded_policy_with_seed)
94    /// with a seed from [`key_store`](crate::key_store).
95    ///
96    /// # Errors
97    ///
98    /// Returns [`IjimaError::InvalidInput`] if the policy TOML is invalid.
99    pub fn from_embedded_policy() -> Result<Self> {
100        Self::from_embedded_policy_with_seed(Self::generate_seed())
101    }
102
103    /// Loads the embedded policy and constructs the issuer from a known
104    /// 32-byte Ed25519 seed. The same seed must be shared by every
105    /// process that issues or verifies tokens for this Ijima instance.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`IjimaError::InvalidInput`] if the policy TOML is invalid.
110    pub fn from_embedded_policy_with_seed(seed: [u8; 32]) -> Result<Self> {
111        let controller = AccessController::from_policy_toml(POLICY_TOML)
112            .map_err(|e| IjimaError::invalid_input(format!("policy load: {e}")))?;
113        let issuer = CapabilityIssuer::from_seed(seed);
114        let verifier = CapabilityVerifier::new(issuer.public_key());
115        Ok(Self {
116            controller,
117            issuer,
118            verifier,
119        })
120    }
121
122    /// Generates a fresh random 32-byte issuer seed (for first-time setup).
123    pub fn generate_seed() -> [u8; 32] {
124        use rand::TryRngCore;
125        let mut seed = [0u8; 32];
126        rand::rngs::OsRng
127            .try_fill_bytes(&mut seed)
128            .expect("OsRng is infallible in practice");
129        seed
130    }
131
132    /// The issuer's Ed25519 public key as lowercase hex, for distribution
133    /// to verifiers and operator visibility.
134    pub fn issuer_public_key_hex(&self) -> String {
135        self.issuer
136            .public_key()
137            .iter()
138            .map(|b| format!("{b:02x}"))
139            .collect()
140    }
141
142    /// Returns the Grassmannian the controller operates on.
143    pub fn grassmannian(&self) -> (usize, usize) {
144        self.controller.grassmannian()
145    }
146
147    /// Issues a bearer token (base64 wire format) granting `capability`
148    /// to `principal`.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`IjimaError::InvalidInput`] if Schubert's issuer rejects
153    /// the inputs.
154    pub fn issue_bearer(
155        &self,
156        principal: impl Into<PrincipalId>,
157        capability: impl AsRef<str>,
158    ) -> Result<String> {
159        let capability_str = capability.as_ref();
160        let token = self
161            .issuer
162            .issue(principal, capability_str)
163            .map_err(|e| IjimaError::invalid_input(format!("token issue: {e}")))?;
164        encode_token(&token)
165    }
166
167    /// Decodes + cryptographically verifies a bearer token, returning the
168    /// authenticated principal and the capability the token grants.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`IjimaError::InvalidInput`] on a malformed or
173    /// bad-signature token.
174    pub fn verify_bearer(&self, bearer: &str) -> Result<AuthenticatedPrincipal> {
175        let token = decode_token(bearer)?;
176        let (principal, capability) = self
177            .verifier
178            .verify_and_extract(&token)
179            .map_err(|e| IjimaError::invalid_input(format!("token verify: {e}")))?;
180        Ok(AuthenticatedPrincipal {
181            principal: principal.clone(),
182            capability: capability.as_str().to_string(),
183        })
184    }
185
186    /// Authorizes `principal` for `required` capabilities via the
187    /// geometric Schubert check. Returns the [`AccessDecision`].
188    ///
189    /// This is the authorization half; combine with [`verify_bearer`] in
190    /// handlers that need both ("who is calling" + "may they do this").
191    pub fn check(&self, principal: &PrincipalId, required: &[&str]) -> Result<AccessDecision> {
192        self.controller
193            .check(principal, required)
194            .map_err(|e| IjimaError::invalid_input(format!("access check: {e}")))
195    }
196
197    /// Convenience guard for handlers: verifies the token (authn) and
198    /// authorizes via **proof-carrying** semantics — the token's capability
199    /// field is the authorization, verified cryptographically against the
200    /// issuer's public key. Succeeds when the token grants exactly
201    /// `required` or grants [`ADMIN`](ijima_core::capabilities::ADMIN)
202    /// (the point class implies every capability).
203    ///
204    /// For richer geometric implication (e.g. "mining:trigger composes over
205    /// session:ingest"), use [`check`](Self::check) against a controller
206    /// with explicit grants — that path is for offline policy analysis,
207    /// not per-request runtime checks.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the token is invalid or does not grant `required`.
212    pub fn require(&self, bearer: &str, required: &str) -> Result<AuthenticatedPrincipal> {
213        let principal = self.verify_bearer(bearer)?;
214        if principal.capability == required
215            || principal.capability == ijima_core::capabilities::ADMIN
216        {
217            Ok(principal)
218        } else {
219            Err(IjimaError::invalid_input(format!(
220                "access denied: token grants '{}' but '{}' is required",
221                principal.capability, required
222            )))
223        }
224    }
225}
226
227// ---------- wire encoding ----------
228
229fn encode_token(token: &CapabilityToken) -> Result<String> {
230    let p = token.principal.as_str().as_bytes();
231    let c = token.capability.as_str().as_bytes();
232    if p.len() > u16::MAX as usize || c.len() > u16::MAX as usize {
233        return Err(IjimaError::invalid_input("token field too long"));
234    }
235    let mut buf = Vec::with_capacity(2 + p.len() + 2 + c.len() + ISSUER_KEY_LEN + SIGNATURE_LEN);
236    buf.extend_from_slice(&(p.len() as u16).to_be_bytes());
237    buf.extend_from_slice(p);
238    buf.extend_from_slice(&(c.len() as u16).to_be_bytes());
239    buf.extend_from_slice(c);
240    if token.issuer_key.len() != ISSUER_KEY_LEN || token.signature.len() != SIGNATURE_LEN {
241        return Err(IjimaError::invalid_input(
242            "malformed issuer key or signature",
243        ));
244    }
245    buf.extend_from_slice(&token.issuer_key);
246    buf.extend_from_slice(&token.signature);
247    Ok(B64.encode(&buf))
248}
249
250fn decode_token(bearer: &str) -> Result<CapabilityToken> {
251    let buf = B64
252        .decode(bearer.trim())
253        .map_err(|e| IjimaError::invalid_input(format!("base64 decode: {e}")))?;
254    let mut pos = 0;
255    let plen = read_u16(&buf, &mut pos)?;
256    let principal = read_str(&buf, &mut pos, plen)?;
257    let clen = read_u16(&buf, &mut pos)?;
258    let capability = read_str(&buf, &mut pos, clen)?;
259    let issuer_key = read_bytes(&buf, &mut pos, ISSUER_KEY_LEN)?;
260    let signature = read_bytes(&buf, &mut pos, SIGNATURE_LEN)?;
261    if pos != buf.len() {
262        return Err(IjimaError::invalid_input("trailing bytes in token"));
263    }
264    Ok(CapabilityToken {
265        principal: PrincipalId::new(principal),
266        capability: schubert::CapabilityId::new(capability),
267        issuer_key: issuer_key.to_vec(),
268        signature: signature.to_vec(),
269    })
270}
271
272fn read_u16(buf: &[u8], pos: &mut usize) -> Result<usize> {
273    if *pos + 2 > buf.len() {
274        return Err(IjimaError::invalid_input("truncated token length"));
275    }
276    let v = u16::from_be_bytes([buf[*pos], buf[*pos + 1]]) as usize;
277    *pos += 2;
278    Ok(v)
279}
280
281fn read_str(buf: &[u8], pos: &mut usize, len: usize) -> Result<String> {
282    let bytes = read_bytes(buf, pos, len)?;
283    String::from_utf8(bytes.to_vec())
284        .map_err(|e| IjimaError::invalid_input(format!("non-utf8 token field: {e}")))
285}
286
287fn read_bytes<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> {
288    if *pos + len > buf.len() {
289        return Err(IjimaError::invalid_input("truncated token field"));
290    }
291    let slice = &buf[*pos..*pos + len];
292    *pos += len;
293    Ok(slice)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use ijima_core::capabilities::{ADMIN, MEMORY_READ, MEMORY_WRITE};
300
301    fn fresh() -> IjimaAuth {
302        IjimaAuth::from_embedded_policy().expect("embedded policy must load")
303    }
304
305    #[test]
306    fn embedded_policy_loads_on_gr_4_8() {
307        let auth = fresh();
308        assert_eq!(auth.grassmannian(), (4, 8));
309    }
310
311    #[test]
312    fn issue_then_verify_round_trips() {
313        let auth = fresh();
314        let bearer = auth
315            .issue_bearer("elliott", MEMORY_READ)
316            .expect("must issue");
317        let principal = auth.verify_bearer(&bearer).expect("must verify");
318        assert_eq!(principal.principal.as_str(), "elliott");
319        assert_eq!(principal.capability, MEMORY_READ);
320    }
321
322    #[test]
323    fn tampered_signature_is_rejected() {
324        let auth = fresh();
325        let mut buf = B64
326            .decode(
327                auth.issue_bearer("elliott", MEMORY_READ)
328                    .expect("must issue"),
329            )
330            .unwrap();
331        // Flip the last byte (part of the 64-byte signature).
332        let last = buf.len() - 1;
333        buf[last] ^= 0xff;
334        let tampered = B64.encode(&buf);
335        assert!(auth.verify_bearer(&tampered).is_err());
336    }
337
338    #[test]
339    fn admin_token_grants_any_capability() {
340        // Proof-carrying semantics: an ADMIN token (point class) implies
341        // every capability, so it satisfies a memory:read requirement.
342        let auth = fresh();
343        let bearer = auth.issue_bearer("root", ADMIN).expect("must issue");
344        let principal = auth.require(&bearer, MEMORY_READ).expect("admin may read");
345        assert_eq!(principal.principal.as_str(), "root");
346    }
347
348    #[test]
349    fn read_token_does_not_grant_write() {
350        let auth = fresh();
351        let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
352        // A principal only has the capability in their token here (the
353        // controller has no grants yet), so requiring MEMORY_WRITE must
354        // fail. This pins the authz half.
355        assert!(auth.require(&bearer, MEMORY_WRITE).is_err());
356    }
357
358    #[test]
359    fn malformed_bearer_rejected() {
360        let auth = fresh();
361        assert!(auth.verify_bearer("not-base64!!!").is_err());
362        assert!(auth.verify_bearer("").is_err());
363    }
364
365    #[test]
366    fn seed_based_issue_then_verify_across_instances() {
367        // Simulates the CLI→daemon flow: one IjimaAuth (the CLI) issues
368        // with a seed; a second IjimaAuth (the daemon) constructed from
369        // the SAME seed verifies the token.
370        let seed = IjimaAuth::generate_seed();
371        let issuer = IjimaAuth::from_embedded_policy_with_seed(seed).expect("issuer");
372        let bearer = issuer
373            .issue_bearer("elliott", MEMORY_READ)
374            .expect("must issue");
375        let public_key = issuer.issuer_public_key_hex();
376        assert_eq!(public_key.len(), 64);
377
378        // A *different* instance with the same seed must verify.
379        let daemon = IjimaAuth::from_embedded_policy_with_seed(seed).expect("daemon");
380        let principal = daemon.verify_bearer(&bearer).expect("must verify");
381        assert_eq!(principal.principal.as_str(), "elliott");
382        assert_eq!(principal.capability, MEMORY_READ);
383        // And derive the same public key.
384        assert_eq!(daemon.issuer_public_key_hex(), public_key);
385    }
386}