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
39pub const MAX_TOKEN_LEN: usize = 16 * 1024;
41const PREFIX: &str = "oa1.";
42const HEADER_LEN: usize = 4 + 24;
43const TAG_LEN: usize = 16;
44
45pub struct Token(Zeroizing<String>);
47
48impl Token {
49 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
64pub 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 pub fn issue(
103 &self,
104 claims: Claims,
105 ) -> Result<Token> {
106 self.issue_bound(claims, None)
107 }
108
109 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 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
213pub 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
222pub type OwnedValidator = Validator<'static>;
225
226impl Validator<'_> {
227 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 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 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 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 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 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}