Skip to main content

orbit_auth/
lib.rs

1#![deny(unsafe_op_in_unsafe_fn)]
2#![doc = include_str!("../README.md")]
3
4mod cache;
5mod error;
6mod fleet;
7mod keyring;
8mod principal;
9mod protection;
10mod table;
11
12use std::collections::BTreeMap;
13use std::fmt;
14use std::sync::Arc;
15use std::time::Duration;
16
17use base64::Engine;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19pub use cache::{CacheKey, CachedPrincipal, MemoryCache, PrincipalCache};
20use chacha20poly1305::aead::{Aead, Payload};
21use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
22pub use error::{Error, Result};
23pub use fleet::{FleetAuth, ReplayGuard, Session, SessionId};
24use hmac::{Hmac, Mac};
25pub use keyring::{KeyId, Keyring, SecretKey};
26use keyring::{context, derive};
27pub use principal::{
28    AllowReusable, Audience, Capability, Claims, MAX_CAPABILITIES, MAX_LABEL_LEN, Principal,
29    Purpose, TokenId, Validation, ValidationHook
30};
31pub use protection::{Blake3State, EncryptedState, StateProtection, UnprotectedState};
32use sha2::{Digest, Sha256};
33pub use table::{AUTH_STATE_CAPACITY, AUTH_STATE_KIND};
34use zeroize::Zeroizing;
35
36/// Protocol allocation bound. Transport adapters may impose smaller limits (e.g. cookies).
37pub const MAX_TOKEN_LEN: usize = 16 * 1024;
38const PREFIX: &str = "oa1.";
39const HEADER_LEN: usize = 4 + 24;
40const TAG_LEN: usize = 16;
41
42/// Opaque bearer credential. Debug output is redacted and storage is zeroized on drop.
43pub struct Token(Zeroizing<String>);
44
45impl Token {
46    /// Expose only for transport; do not log or use as a cache key.
47    pub fn expose(&self) -> &str {
48        &self.0
49    }
50}
51
52impl fmt::Debug for Token {
53    fn fmt(
54        &self,
55        f: &mut fmt::Formatter<'_>
56    ) -> fmt::Result {
57        f.write_str("Token([REDACTED])")
58    }
59}
60
61/// Shared symmetric trust realm, independent of hostnames and protocols.
62/// Every key holder can mint tokens; provision keys only to equally trusted services.
63pub struct Authority {
64    realm: String,
65    issuer: String,
66    keyring: Keyring,
67    cache_seed: Zeroizing<[u8; 32]>
68}
69
70impl Authority {
71    pub fn new(
72        realm: impl Into<String>,
73        issuer: impl Into<String>,
74        keyring: Keyring
75    ) -> Result<Self> {
76        let realm = realm.into();
77        let issuer = issuer.into();
78        if !principal::valid_label(&realm) || !principal::valid_label(&issuer) {
79            return Err(Error::InvalidInput);
80        }
81        let mut hash = Sha256::new();
82        hash.update(b"orbit-auth/v1/cache-keyring");
83        for (id, key) in &keyring.keys {
84            let info = context(&[b"cache", realm.as_bytes(), issuer.as_bytes(), &id.to_be_bytes()]);
85            let cache_key = derive(key, &info);
86            hash.update(id.to_be_bytes());
87            hash.update(cache_key.as_ref());
88        }
89        let cache_seed = Zeroizing::new(hash.finalize().into());
90        Ok(Self { realm, issuer, keyring, cache_seed })
91    }
92
93    /// Caller must authorize subject and capabilities before issuance.
94    pub fn issue(
95        &self,
96        claims: Claims
97    ) -> Result<Token> {
98        self.issue_bound(claims, None)
99    }
100
101    /// Issue a credential bound to shared session state. The caller authorizes
102    /// audience/capability delegation; validation must use the FleetAuth hook.
103    pub fn issue_session(
104        &self,
105        claims: Claims,
106        session: &Session
107    ) -> Result<Token> {
108        session.check_issuance(self, &claims)?;
109        self.issue_bound(claims, Some(session.binding()))
110    }
111
112    fn issue_bound(
113        &self,
114        claims: Claims,
115        session: Option<fleet::SessionBinding>
116    ) -> Result<Token> {
117        claims.check_shape()?;
118        let mut jti = [0; 16];
119        let mut nonce = [0; 24];
120        getrandom::fill(&mut jti).map_err(|_| Error::Randomness)?;
121        getrandom::fill(&mut nonce).map_err(|_| Error::Randomness)?;
122        let id = self.keyring.active;
123        let info = self.scope(&claims.audience, claims.purpose, id);
124        let key = derive(&self.keyring.keys[&id], &info);
125        let principal = Principal {
126            realm: self.realm.clone(),
127            issuer: self.issuer.clone(),
128            jti: TokenId(jti),
129            session,
130            claims
131        };
132        let plaintext =
133            Zeroizing::new(serde_json::to_vec(&principal).map_err(|_| Error::InvalidInput)?);
134        let wire_len = PREFIX.len() + ((HEADER_LEN + TAG_LEN + plaintext.len()) * 4).div_ceil(3);
135        if wire_len > MAX_TOKEN_LEN {
136            return Err(Error::TokenTooLarge);
137        }
138        let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("256-bit key");
139        let ciphertext = cipher
140            .encrypt(XNonce::from_slice(&nonce), Payload { msg: &plaintext, aad: &info })
141            .map_err(|_| Error::InvalidToken)?;
142        let mut wire = Vec::with_capacity(HEADER_LEN + ciphertext.len());
143        wire.extend_from_slice(&id.to_be_bytes());
144        wire.extend_from_slice(&nonce);
145        wire.extend_from_slice(&ciphertext);
146        Ok(Token(Zeroizing::new(format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(wire)))))
147    }
148
149    /// Build once per trusted recipient/use policy and reuse across requests.
150    pub fn validator(
151        &self,
152        validation: Validation
153    ) -> Validator<'_> {
154        let keys = self
155            .keyring
156            .keys
157            .iter()
158            .map(|(id, key)| {
159                let info = self.scope(&validation.audience, validation.purpose, *id);
160                (*id, derive(key, &info))
161            })
162            .collect();
163        let mut mac =
164            <Hmac<Sha256> as Mac>::new_from_slice(self.cache_seed.as_ref()).expect("HMAC key");
165        mac.update(&context(&[
166            b"orbit-auth/v1/cache-policy",
167            validation.audience.as_str().as_bytes(),
168            validation.purpose.as_bytes()
169        ]));
170        for capability in &validation.required_capabilities {
171            mac.update(&context(&[capability.as_str().as_bytes()]));
172        }
173        let cache_secret = Zeroizing::new(mac.finalize().into_bytes().into());
174        Validator { authority: self, validation, keys, cache_secret }
175    }
176
177    fn scope(
178        &self,
179        audience: &Audience,
180        purpose: Purpose,
181        id: KeyId
182    ) -> Vec<u8> {
183        context(&[
184            b"orbit-auth/v1/xchacha20poly1305",
185            self.realm.as_bytes(),
186            self.issuer.as_bytes(),
187            audience.as_str().as_bytes(),
188            purpose.as_bytes(),
189            &id.to_be_bytes()
190        ])
191    }
192}
193
194/// Prepared validator with mandatory audience/purpose and optional exact capabilities.
195pub struct Validator<'a> {
196    authority: &'a Authority,
197    validation: Validation,
198    keys: BTreeMap<KeyId, Zeroizing<[u8; 32]>>,
199    cache_secret: Zeroizing<[u8; 32]>
200}
201
202impl Validator<'_> {
203    pub fn validate(
204        &self,
205        token: &str,
206        now: u64,
207        hook: &dyn ValidationHook
208    ) -> Result<Arc<Principal>> {
209        let principal = self.decrypt(token)?;
210        self.check(&principal, now)?;
211        hook.check(&principal, now)?;
212        Ok(Arc::new(principal))
213    }
214
215    /// No token decryption, base64 or claims decoding on a warm cache hit.
216    /// Hooks still consult current policy; FleetAuth checks its authenticated shared state.
217    ///
218    /// TTL is caller-owned and never exceeds token expiry. Zero/subsecond TTL disables
219    /// caching. Static checks and the hook run every time. Callers supply current Unix
220    /// seconds from a trusted clock, never from client input.
221    pub fn validate_cached(
222        &self,
223        token: &str,
224        now: u64,
225        cache: &dyn PrincipalCache,
226        ttl: Duration,
227        hook: &dyn ValidationHook
228    ) -> Result<Arc<Principal>> {
229        let ttl = ttl.as_secs();
230        if ttl == 0 {
231            return self.validate(token, now, hook);
232        }
233        let key = self.cache_key(token)?;
234        if let Some(entry) = cache.get(&key) {
235            // Reject swapped entries, clock rollback, and a caller-shortened TTL.
236            if entry.key == key
237                && entry.cached_at <= now
238                && now < entry.valid_until
239                && now < entry.cached_at.saturating_add(ttl)
240            {
241                self.check(&entry.principal, now)?;
242                hook.check(&entry.principal, now)?;
243                return Ok(entry.principal);
244            }
245        }
246        let principal = self.validate(token, now, hook)?;
247        let valid_until = now.saturating_add(ttl).min(principal.claims.expires_at);
248        cache.insert(
249            key,
250            CachedPrincipal { key, principal: principal.clone(), cached_at: now, valid_until }
251        );
252        Ok(principal)
253    }
254
255    /// Fingerprint calculation does not authenticate the token or expose its claims.
256    pub fn cache_key(
257        &self,
258        token: &str
259    ) -> Result<CacheKey> {
260        check_size(token)?;
261        let mut mac =
262            <Hmac<Sha256> as Mac>::new_from_slice(self.cache_secret.as_ref()).expect("HMAC key");
263        mac.update(token.as_bytes());
264        Ok(CacheKey(mac.finalize().into_bytes().into()))
265    }
266
267    fn decrypt(
268        &self,
269        token: &str
270    ) -> Result<Principal> {
271        check_size(token)?;
272        let encoded = token.strip_prefix(PREFIX).ok_or(Error::InvalidToken)?;
273        let wire = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| Error::InvalidToken)?;
274        if wire.len() < HEADER_LEN + TAG_LEN {
275            return Err(Error::InvalidToken);
276        }
277        let id = KeyId::from_be_bytes(wire[..4].try_into().expect("checked header length"));
278        let key = self.keys.get(&id).ok_or(Error::InvalidToken)?;
279        let info = self.authority.scope(&self.validation.audience, self.validation.purpose, id);
280        let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("256-bit key");
281        let plaintext = Zeroizing::new(
282            cipher
283                .decrypt(
284                    XNonce::from_slice(&wire[4..HEADER_LEN]),
285                    Payload { msg: &wire[HEADER_LEN..], aad: &info }
286                )
287                .map_err(|_| Error::InvalidToken)?
288        );
289        serde_json::from_slice(&plaintext).map_err(|_| Error::InvalidToken)
290    }
291
292    fn check(
293        &self,
294        principal: &Principal,
295        now: u64
296    ) -> Result<()> {
297        let claims = &principal.claims;
298        claims.check_shape().map_err(|_| Error::InvalidToken)?;
299        if principal.realm != self.authority.realm
300            || principal.issuer != self.authority.issuer
301            || claims.audience != self.validation.audience
302            || claims.purpose != self.validation.purpose
303        {
304            return Err(Error::InvalidToken);
305        }
306        if now >= claims.expires_at {
307            return Err(Error::Expired);
308        }
309        if now < claims.not_before || now < claims.issued_at {
310            return Err(Error::NotYetValid);
311        }
312        if !self.validation.required_capabilities.is_subset(&claims.capabilities) {
313            return Err(Error::MissingCapability);
314        }
315        Ok(())
316    }
317}
318
319fn check_size(token: &str) -> Result<()> {
320    if token.len() > MAX_TOKEN_LEN {
321        return Err(Error::TokenTooLarge);
322    }
323    if token.is_empty() {
324        return Err(Error::InvalidToken);
325    }
326    Ok(())
327}