#![deny(unsafe_op_in_unsafe_fn)]
#![doc = include_str!("../README.md")]
mod cache;
mod error;
mod fleet;
mod keyring;
mod principal;
mod protection;
mod table;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub use cache::{CacheKey, CachedPrincipal, MemoryCache, PrincipalCache};
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
pub use error::{Error, Result};
pub use fleet::{FleetAuth, ReplayGuard, Session, SessionId};
use hmac::{Hmac, Mac};
pub use keyring::{KeyId, Keyring, SecretKey};
use keyring::{context, derive};
pub use principal::{
AllowReusable, Audience, Capability, Claims, MAX_CAPABILITIES, MAX_LABEL_LEN, Principal,
Purpose, TokenId, Validation, ValidationHook
};
pub use protection::{Blake3State, EncryptedState, StateProtection, UnprotectedState};
use sha2::{Digest, Sha256};
pub use table::{AUTH_STATE_CAPACITY, AUTH_STATE_KIND};
use zeroize::Zeroizing;
pub const MAX_TOKEN_LEN: usize = 16 * 1024;
const PREFIX: &str = "oa1.";
const HEADER_LEN: usize = 4 + 24;
const TAG_LEN: usize = 16;
pub struct Token(Zeroizing<String>);
impl Token {
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Token {
fn fmt(
&self,
f: &mut fmt::Formatter<'_>
) -> fmt::Result {
f.write_str("Token([REDACTED])")
}
}
pub struct Authority {
realm: String,
issuer: String,
keyring: Keyring,
cache_seed: Zeroizing<[u8; 32]>
}
impl Authority {
pub fn new(
realm: impl Into<String>,
issuer: impl Into<String>,
keyring: Keyring
) -> Result<Self> {
let realm = realm.into();
let issuer = issuer.into();
if !principal::valid_label(&realm) || !principal::valid_label(&issuer) {
return Err(Error::InvalidInput);
}
let mut hash = Sha256::new();
hash.update(b"orbit-auth/v1/cache-keyring");
for (id, key) in &keyring.keys {
let info = context(&[b"cache", realm.as_bytes(), issuer.as_bytes(), &id.to_be_bytes()]);
let cache_key = derive(key, &info);
hash.update(id.to_be_bytes());
hash.update(cache_key.as_ref());
}
let cache_seed = Zeroizing::new(hash.finalize().into());
Ok(Self { realm, issuer, keyring, cache_seed })
}
pub fn issue(
&self,
claims: Claims
) -> Result<Token> {
self.issue_bound(claims, None)
}
pub fn issue_session(
&self,
claims: Claims,
session: &Session
) -> Result<Token> {
session.check_issuance(self, &claims)?;
self.issue_bound(claims, Some(session.binding()))
}
fn issue_bound(
&self,
claims: Claims,
session: Option<fleet::SessionBinding>
) -> Result<Token> {
claims.check_shape()?;
let mut jti = [0; 16];
let mut nonce = [0; 24];
getrandom::fill(&mut jti).map_err(|_| Error::Randomness)?;
getrandom::fill(&mut nonce).map_err(|_| Error::Randomness)?;
let id = self.keyring.active;
let info = self.scope(&claims.audience, claims.purpose, id);
let key = derive(&self.keyring.keys[&id], &info);
let principal = Principal {
realm: self.realm.clone(),
issuer: self.issuer.clone(),
jti: TokenId(jti),
session,
claims
};
let plaintext =
Zeroizing::new(serde_json::to_vec(&principal).map_err(|_| Error::InvalidInput)?);
let wire_len = PREFIX.len() + ((HEADER_LEN + TAG_LEN + plaintext.len()) * 4).div_ceil(3);
if wire_len > MAX_TOKEN_LEN {
return Err(Error::TokenTooLarge);
}
let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("256-bit key");
let ciphertext = cipher
.encrypt(XNonce::from_slice(&nonce), Payload { msg: &plaintext, aad: &info })
.map_err(|_| Error::InvalidToken)?;
let mut wire = Vec::with_capacity(HEADER_LEN + ciphertext.len());
wire.extend_from_slice(&id.to_be_bytes());
wire.extend_from_slice(&nonce);
wire.extend_from_slice(&ciphertext);
Ok(Token(Zeroizing::new(format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(wire)))))
}
pub fn validator(
&self,
validation: Validation
) -> Validator<'_> {
let keys = self
.keyring
.keys
.iter()
.map(|(id, key)| {
let info = self.scope(&validation.audience, validation.purpose, *id);
(*id, derive(key, &info))
})
.collect();
let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(self.cache_seed.as_ref())
.expect("HMAC key");
mac.update(&context(&[
b"orbit-auth/v1/cache-policy",
validation.audience.as_str().as_bytes(),
validation.purpose.as_bytes()
]));
for capability in &validation.required_capabilities {
mac.update(&context(&[capability.as_str().as_bytes()]));
}
let cache_secret = Zeroizing::new(mac.finalize().into_bytes().into());
Validator {
realm: Cow::Borrowed(&self.realm),
issuer: Cow::Borrowed(&self.issuer),
validation,
keys,
cache_secret
}
}
fn scope(
&self,
audience: &Audience,
purpose: Purpose,
id: KeyId
) -> Vec<u8> {
token_scope(&self.realm, &self.issuer, audience, purpose, id)
}
}
pub struct Validator<'a> {
realm: Cow<'a, str>,
issuer: Cow<'a, str>,
validation: Validation,
keys: BTreeMap<KeyId, Zeroizing<[u8; 32]>>,
cache_secret: Zeroizing<[u8; 32]>
}
pub type OwnedValidator = Validator<'static>;
impl Validator<'_> {
pub fn into_owned(self) -> OwnedValidator {
Validator {
realm: Cow::Owned(self.realm.into_owned()),
issuer: Cow::Owned(self.issuer.into_owned()),
validation: self.validation,
keys: self.keys,
cache_secret: self.cache_secret
}
}
pub fn validate(
&self,
token: &str,
now: u64,
hook: &dyn ValidationHook
) -> Result<Arc<Principal>> {
let principal = self.decrypt(token)?;
self.check(&principal, now)?;
hook.check(&principal, now)?;
Ok(Arc::new(principal))
}
pub fn validate_lapsed(
&self,
token: &str,
now: u64,
hook: &dyn ValidationHook
) -> Result<Arc<Principal>> {
let principal = self.decrypt(token)?;
self.check_lapsed(&principal, now)?;
hook.check_lapsed(&principal, now)?;
Ok(Arc::new(principal))
}
pub fn validate_cached(
&self,
token: &str,
now: u64,
cache: &dyn PrincipalCache,
ttl: Duration,
hook: &dyn ValidationHook
) -> Result<Arc<Principal>> {
let ttl = ttl.as_secs();
if ttl == 0 {
return self.validate(token, now, hook);
}
let key = self.cache_key(token)?;
if let Some(entry) = cache.get(&key) {
if entry.key == key
&& entry.cached_at <= now
&& now < entry.valid_until
&& now < entry.cached_at.saturating_add(ttl)
{
self.check(&entry.principal, now)?;
hook.check(&entry.principal, now)?;
return Ok(entry.principal);
}
}
let principal = self.validate(token, now, hook)?;
let valid_until = now.saturating_add(ttl).min(principal.claims.expires_at);
cache.insert(
key,
CachedPrincipal { key, principal: principal.clone(), cached_at: now, valid_until }
);
Ok(principal)
}
pub fn cache_key(
&self,
token: &str
) -> Result<CacheKey> {
check_size(token)?;
let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(self.cache_secret.as_ref())
.expect("HMAC key");
mac.update(token.as_bytes());
Ok(CacheKey(mac.finalize().into_bytes().into()))
}
fn decrypt(
&self,
token: &str
) -> Result<Principal> {
check_size(token)?;
let encoded = token.strip_prefix(PREFIX).ok_or(Error::InvalidToken)?;
let wire = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| Error::InvalidToken)?;
if wire.len() < HEADER_LEN + TAG_LEN {
return Err(Error::InvalidToken);
}
let id = KeyId::from_be_bytes(wire[..4].try_into().expect("checked header length"));
let key = self.keys.get(&id).ok_or(Error::InvalidToken)?;
let info = token_scope(
&self.realm,
&self.issuer,
&self.validation.audience,
self.validation.purpose,
id
);
let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("256-bit key");
let plaintext = Zeroizing::new(
cipher
.decrypt(
XNonce::from_slice(&wire[4..HEADER_LEN]),
Payload { msg: &wire[HEADER_LEN..], aad: &info }
)
.map_err(|_| Error::InvalidToken)?
);
serde_json::from_slice(&plaintext).map_err(|_| Error::InvalidToken)
}
fn check(
&self,
principal: &Principal,
now: u64
) -> Result<()> {
self.check_lapsed(principal, now)?;
if now >= principal.claims.expires_at {
return Err(Error::Expired);
}
Ok(())
}
fn check_lapsed(
&self,
principal: &Principal,
now: u64
) -> Result<()> {
let claims = &principal.claims;
claims.check_shape().map_err(|_| Error::InvalidToken)?;
if principal.realm != self.realm
|| principal.issuer != self.issuer
|| claims.audience != self.validation.audience
|| claims.purpose != self.validation.purpose
{
return Err(Error::InvalidToken);
}
if now < claims.not_before || now < claims.issued_at {
return Err(Error::NotYetValid);
}
if !self.validation.required_capabilities.is_subset(&claims.capabilities) {
return Err(Error::MissingCapability);
}
Ok(())
}
}
fn check_size(token: &str) -> Result<()> {
if token.len() > MAX_TOKEN_LEN {
return Err(Error::TokenTooLarge);
}
if token.is_empty() {
return Err(Error::InvalidToken);
}
Ok(())
}
fn token_scope(
realm: &str,
issuer: &str,
audience: &Audience,
purpose: Purpose,
id: KeyId
) -> Vec<u8> {
context(&[
b"orbit-auth/v1/xchacha20poly1305",
realm.as_bytes(),
issuer.as_bytes(),
audience.as_str().as_bytes(),
purpose.as_bytes(),
&id.to_be_bytes()
])
}