use std::fmt;
use chrono::{DateTime, Duration, Utc};
use zeroize::Zeroize;
pub(crate) const ID_BYTES: usize = 16;
pub(crate) const SECRET_BYTES: usize = 32;
pub const TOKEN_PREFIX: &str = "arcpat_";
const HEX: [u8; 16] = *b"0123456789abcdef";
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[usize::from(byte >> 4)] as char);
out.push(HEX[usize::from(byte & 0x0f)] as char);
}
out
}
pub(crate) fn hex_decode(text: &str, out: &mut [u8]) -> bool {
let bytes = text.as_bytes();
if bytes.len() != out.len() * 2 {
return false;
}
let (pairs, _) = bytes.as_chunks::<2>();
for (slot, pair) in out.iter_mut().zip(pairs) {
let (Some(high), Some(low)) = (nibble(pair[0]), nibble(pair[1])) else {
return false;
};
*slot = (high << 4) | low;
}
true
}
fn nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ApiTokenId([u8; ID_BYTES]);
impl ApiTokenId {
pub(crate) fn from_bytes(bytes: [u8; ID_BYTES]) -> Self {
Self(bytes)
}
#[must_use]
pub fn from_hex(text: &str) -> Option<Self> {
let mut bytes = [0u8; ID_BYTES];
hex_decode(text, &mut bytes).then_some(Self(bytes))
}
#[must_use]
pub fn to_hex(&self) -> String {
hex_encode(&self.0)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Display for ApiTokenId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for ApiTokenId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ApiTokenId({})", self.to_hex())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Abilities {
entries: Vec<String>,
}
impl Abilities {
pub const ALL: &'static str = "*";
#[must_use]
pub fn all() -> Self {
Self {
entries: vec![Self::ALL.to_owned()],
}
}
#[must_use]
pub fn none() -> Self {
Self {
entries: Vec::new(),
}
}
#[must_use]
pub fn of<I, S>(abilities: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
entries: abilities.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub fn with(mut self, ability: impl Into<String>) -> Self {
self.entries.push(ability.into());
self
}
#[must_use]
pub fn contains(&self, ability: &str) -> bool {
self.entries
.iter()
.any(|entry| entry == Self::ALL || entry == ability)
}
#[must_use]
pub fn is_all(&self) -> bool {
self.entries.iter().any(|entry| entry == Self::ALL)
}
#[must_use]
pub fn as_slice(&self) -> &[String] {
&self.entries
}
}
#[non_exhaustive]
pub struct PlaintextToken(String);
impl PlaintextToken {
pub(crate) fn new(plaintext: String) -> Self {
Self(plaintext)
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for PlaintextToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("PlaintextToken([redacted])")
}
}
impl Drop for PlaintextToken {
fn drop(&mut self) {
self.0.zeroize();
}
}
pub(crate) fn format_plaintext(id: &[u8; ID_BYTES], secret: &[u8; SECRET_BYTES]) -> String {
format!("{TOKEN_PREFIX}{}_{}", hex_encode(id), hex_encode(secret))
}
pub(crate) fn parse_plaintext(presented: &str) -> Option<(ApiTokenId, [u8; SECRET_BYTES])> {
let (id_hex, secret_hex) = presented.strip_prefix(TOKEN_PREFIX)?.split_once('_')?;
let mut id = [0u8; ID_BYTES];
let mut secret = [0u8; SECRET_BYTES];
if !hex_decode(id_hex, &mut id) || !hex_decode(secret_hex, &mut secret) {
return None;
}
Some((ApiTokenId(id), secret))
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ApiToken {
id: ApiTokenId,
tokenable_id: String,
name: String,
abilities: Abilities,
expires_at: DateTime<Utc>,
created_at: DateTime<Utc>,
}
impl ApiToken {
pub(crate) fn from_row(
id: ApiTokenId,
tokenable_id: String,
name: String,
abilities: Abilities,
expires_at: DateTime<Utc>,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
tokenable_id,
name,
abilities,
expires_at,
created_at,
}
}
#[must_use]
pub fn id(&self) -> ApiTokenId {
self.id
}
#[must_use]
pub fn tokenable_id(&self) -> &str {
&self.tokenable_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn abilities(&self) -> &Abilities {
&self.abilities
}
#[must_use]
pub fn can(&self, ability: &str) -> bool {
self.abilities.contains(ability)
}
#[must_use]
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
#[must_use]
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct NewApiToken {
tokenable_id: String,
name: String,
abilities: Abilities,
expires_at: DateTime<Utc>,
}
impl NewApiToken {
#[must_use]
pub fn new(
tokenable_id: impl Into<String>,
name: impl Into<String>,
expires_at: DateTime<Utc>,
) -> Self {
Self {
tokenable_id: tokenable_id.into(),
name: name.into(),
abilities: Abilities::none(),
expires_at,
}
}
#[must_use]
pub fn expiring_in(
tokenable_id: impl Into<String>,
name: impl Into<String>,
ttl: std::time::Duration,
) -> Self {
let delta = Duration::from_std(ttl).unwrap_or(Duration::MAX);
let expires_at = Utc::now()
.checked_add_signed(delta)
.unwrap_or(DateTime::<Utc>::MAX_UTC);
Self::new(tokenable_id, name, expires_at)
}
#[must_use]
pub fn abilities(mut self, abilities: Abilities) -> Self {
self.abilities = abilities;
self
}
#[must_use]
pub fn ability(mut self, ability: impl Into<String>) -> Self {
self.abilities = std::mem::take(&mut self.abilities).with(ability);
self
}
#[must_use]
pub fn tokenable_id(&self) -> &str {
&self.tokenable_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn abilities_ref(&self) -> &Abilities {
&self.abilities
}
#[must_use]
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct IssuedApiToken {
token: ApiToken,
plaintext: PlaintextToken,
}
impl IssuedApiToken {
pub(crate) fn new(token: ApiToken, plaintext: PlaintextToken) -> Self {
Self { token, plaintext }
}
#[must_use]
pub fn token(&self) -> &ApiToken {
&self.token
}
#[must_use]
pub fn plaintext(&self) -> &PlaintextToken {
&self.plaintext
}
#[must_use]
pub fn into_parts(self) -> (ApiToken, PlaintextToken) {
(self.token, self.plaintext)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips() {
let bytes = [0x00u8, 0x0f, 0xf0, 0xff, 0x7a];
let text = hex_encode(&bytes);
assert_eq!(text, "000ff0ff7a");
let mut back = [0u8; 5];
assert!(hex_decode(&text, &mut back));
assert_eq!(back, bytes);
}
#[test]
fn hex_decode_rejects_the_wrong_length() {
let mut out = [0u8; 4];
assert!(!hex_decode("0011", &mut out));
assert!(!hex_decode("001122334455", &mut out));
}
#[test]
fn hex_decode_rejects_a_non_hex_character() {
let mut out = [0u8; 2];
assert!(!hex_decode("00zz", &mut out));
}
#[test]
fn the_plaintext_carries_the_scanner_prefix_and_both_halves() {
let plaintext = format_plaintext(&[0xab; ID_BYTES], &[0xcd; SECRET_BYTES]);
assert!(plaintext.starts_with(TOKEN_PREFIX));
assert_eq!(plaintext.len(), TOKEN_PREFIX.len() + 32 + 1 + 64);
assert!(plaintext.contains("_abababababababababababababababab_"));
}
#[test]
fn the_wildcard_ability_is_the_only_one_that_is_not_literal() {
let abilities = Abilities::of(["a:b", "*x"]);
assert!(abilities.contains("a:b"));
assert!(abilities.contains("*x"));
assert!(!abilities.contains("anything"));
assert!(!abilities.is_all());
}
#[test]
fn a_redacted_debug_does_not_contain_the_secret() {
let token = PlaintextToken::new("arcpat_dead_beef".to_owned());
assert!(!format!("{token:?}").contains("beef"));
}
#[test]
fn an_absurd_ttl_saturates_into_the_future_rather_than_wrapping() {
let request = NewApiToken::expiring_in("u", "n", std::time::Duration::from_secs(u64::MAX));
assert!(request.expires_at() > Utc::now());
}
#[test]
fn an_id_round_trips_through_hex() {
let id = ApiTokenId::from_bytes([7u8; ID_BYTES]);
assert_eq!(ApiTokenId::from_hex(&id.to_hex()), Some(id));
}
}