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