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