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    /// No token decryption, base64 or claims decoding on a warm cache hit.
235    /// Hooks still consult current policy; FleetAuth checks its authenticated shared state.
236    ///
237    /// TTL is caller-owned and never exceeds token expiry. Zero/subsecond TTL disables
238    /// caching. Static checks and the hook run every time. Callers supply current Unix
239    /// seconds from a trusted clock, never from client input.
240    pub fn validate_cached(
241        &self,
242        token: &str,
243        now: u64,
244        cache: &dyn PrincipalCache,
245        ttl: Duration,
246        hook: &dyn ValidationHook
247    ) -> Result<Arc<Principal>> {
248        let ttl = ttl.as_secs();
249        if ttl == 0 {
250            return self.validate(token, now, hook);
251        }
252        let key = self.cache_key(token)?;
253        if let Some(entry) = cache.get(&key) {
254            // Reject swapped entries, clock rollback, and a caller-shortened TTL.
255            if entry.key == key
256                && entry.cached_at <= now
257                && now < entry.valid_until
258                && now < entry.cached_at.saturating_add(ttl)
259            {
260                self.check(&entry.principal, now)?;
261                hook.check(&entry.principal, now)?;
262                return Ok(entry.principal);
263            }
264        }
265        let principal = self.validate(token, now, hook)?;
266        let valid_until = now.saturating_add(ttl).min(principal.claims.expires_at);
267        cache.insert(
268            key,
269            CachedPrincipal { key, principal: principal.clone(), cached_at: now, valid_until }
270        );
271        Ok(principal)
272    }
273
274    /// Fingerprint calculation does not authenticate the token or expose its claims.
275    pub fn cache_key(
276        &self,
277        token: &str
278    ) -> Result<CacheKey> {
279        check_size(token)?;
280        let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(self.cache_secret.as_ref())
281            .expect("HMAC key");
282        mac.update(token.as_bytes());
283        Ok(CacheKey(mac.finalize().into_bytes().into()))
284    }
285
286    fn decrypt(
287        &self,
288        token: &str
289    ) -> Result<Principal> {
290        check_size(token)?;
291        let encoded = token.strip_prefix(PREFIX).ok_or(Error::InvalidToken)?;
292        let wire = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| Error::InvalidToken)?;
293        if wire.len() < HEADER_LEN + TAG_LEN {
294            return Err(Error::InvalidToken);
295        }
296        let id = KeyId::from_be_bytes(wire[..4].try_into().expect("checked header length"));
297        let key = self.keys.get(&id).ok_or(Error::InvalidToken)?;
298        let info = token_scope(
299            &self.realm,
300            &self.issuer,
301            &self.validation.audience,
302            self.validation.purpose,
303            id
304        );
305        let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("256-bit key");
306        let plaintext = Zeroizing::new(
307            cipher
308                .decrypt(
309                    XNonce::from_slice(&wire[4..HEADER_LEN]),
310                    Payload { msg: &wire[HEADER_LEN..], aad: &info }
311                )
312                .map_err(|_| Error::InvalidToken)?
313        );
314        serde_json::from_slice(&plaintext).map_err(|_| Error::InvalidToken)
315    }
316
317    fn check(
318        &self,
319        principal: &Principal,
320        now: u64
321    ) -> Result<()> {
322        let claims = &principal.claims;
323        claims.check_shape().map_err(|_| Error::InvalidToken)?;
324        if principal.realm != self.realm
325            || principal.issuer != self.issuer
326            || claims.audience != self.validation.audience
327            || claims.purpose != self.validation.purpose
328        {
329            return Err(Error::InvalidToken);
330        }
331        if now >= claims.expires_at {
332            return Err(Error::Expired);
333        }
334        if now < claims.not_before || now < claims.issued_at {
335            return Err(Error::NotYetValid);
336        }
337        if !self.validation.required_capabilities.is_subset(&claims.capabilities) {
338            return Err(Error::MissingCapability);
339        }
340        Ok(())
341    }
342}
343
344fn check_size(token: &str) -> Result<()> {
345    if token.len() > MAX_TOKEN_LEN {
346        return Err(Error::TokenTooLarge);
347    }
348    if token.is_empty() {
349        return Err(Error::InvalidToken);
350    }
351    Ok(())
352}
353
354fn token_scope(
355    realm: &str,
356    issuer: &str,
357    audience: &Audience,
358    purpose: Purpose,
359    id: KeyId
360) -> Vec<u8> {
361    context(&[
362        b"orbit-auth/v1/xchacha20poly1305",
363        realm.as_bytes(),
364        issuer.as_bytes(),
365        audience.as_str().as_bytes(),
366        purpose.as_bytes(),
367        &id.to_be_bytes()
368    ])
369}