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
36pub const MAX_TOKEN_LEN: usize = 16 * 1024;
38const PREFIX: &str = "oa1.";
39const HEADER_LEN: usize = 4 + 24;
40const TAG_LEN: usize = 16;
41
42pub struct Token(Zeroizing<String>);
44
45impl Token {
46 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
61pub 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 pub fn issue(
95 &self,
96 claims: Claims
97 ) -> Result<Token> {
98 self.issue_bound(claims, None)
99 }
100
101 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 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
194pub 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 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 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 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}