use std::fmt;
use chrono::{DateTime, Utc};
use zeroize::Zeroize;
use crate::crypt::base64url;
pub(crate) const SERIES_BYTES: usize = 16;
pub(crate) const SECRET_BYTES: usize = 32;
pub const REMEMBER_TOKEN_PREFIX: &str = "arcrmb_";
const SEPARATOR: char = '.';
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) struct SeriesId([u8; SERIES_BYTES]);
impl SeriesId {
pub(crate) fn as_bytes(&self) -> &[u8; SERIES_BYTES] {
&self.0
}
}
impl fmt::Debug for SeriesId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "SeriesId({})", base64url::encode(&self.0))
}
}
#[non_exhaustive]
pub struct PlaintextRememberToken(String);
impl PlaintextRememberToken {
pub(crate) fn new(plaintext: String) -> Self {
Self(plaintext)
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for PlaintextRememberToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("PlaintextRememberToken([redacted])")
}
}
impl Drop for PlaintextRememberToken {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct IssuedRememberToken {
subject: String,
expires_at: DateTime<Utc>,
plaintext: PlaintextRememberToken,
}
impl IssuedRememberToken {
pub(crate) fn new(
subject: String,
expires_at: DateTime<Utc>,
plaintext: PlaintextRememberToken,
) -> Self {
Self {
subject,
expires_at,
plaintext,
}
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
#[must_use]
pub fn plaintext(&self) -> &PlaintextRememberToken {
&self.plaintext
}
#[must_use]
pub fn into_parts(self) -> (String, DateTime<Utc>, PlaintextRememberToken) {
(self.subject, self.expires_at, self.plaintext)
}
}
pub(crate) fn format_plaintext(series: &[u8; SERIES_BYTES], secret: &[u8; SECRET_BYTES]) -> String {
format!(
"{REMEMBER_TOKEN_PREFIX}{}{SEPARATOR}{}",
base64url::encode(series),
base64url::encode(secret)
)
}
pub(crate) fn parse_plaintext(presented: &str) -> Option<(SeriesId, [u8; SECRET_BYTES])> {
let (series_text, secret_text) = presented
.strip_prefix(REMEMBER_TOKEN_PREFIX)?
.split_once(SEPARATOR)?;
let series: [u8; SERIES_BYTES] = base64url::decode(series_text)?.try_into().ok()?;
let secret: [u8; SECRET_BYTES] = base64url::decode(secret_text)?.try_into().ok()?;
Some((SeriesId(series), secret))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_plaintext_carries_the_scanner_prefix_and_both_halves() {
let plaintext = format_plaintext(&[0xab; SERIES_BYTES], &[0xcd; SECRET_BYTES]);
assert!(plaintext.starts_with(REMEMBER_TOKEN_PREFIX));
assert_eq!(plaintext.len(), REMEMBER_TOKEN_PREFIX.len() + 22 + 1 + 43);
}
#[test]
fn a_minted_plaintext_parses_back_to_what_went_in() {
let series = [7u8; SERIES_BYTES];
let secret = [9u8; SECRET_BYTES];
let (parsed_series, parsed_secret) =
parse_plaintext(&format_plaintext(&series, &secret)).expect("round trip");
assert_eq!(parsed_series.as_bytes(), &series);
assert_eq!(parsed_secret, secret);
}
#[test]
fn the_whole_plaintext_is_a_legal_cookie_value_without_quoting() {
let plaintext = format_plaintext(&[0xffu8; SERIES_BYTES], &[0x00u8; SECRET_BYTES]);
assert!(
plaintext
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')),
"not a bare cookie value: {plaintext}"
);
}
#[test]
fn a_string_this_crate_never_minted_costs_no_query() {
for hostile in [
"",
"session=abc",
"arcrmb_",
"arcpwr_AAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"arcrmb_AAAAAAAAAAAAAAAAAAAAAA",
"arcrmb_AAAAAAAAAAAAAAAAAAAAAA.short",
"arcrmb_short.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"arcrmb_AAAAAAAAAAAAAAAAAAAA==.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
] {
assert!(
parse_plaintext(hostile).is_none(),
"accepted {hostile:?} as a token"
);
}
}
#[test]
fn a_non_canonical_spelling_of_a_real_series_is_refused() {
let good = format_plaintext(&[0u8; SERIES_BYTES], &[0u8; SECRET_BYTES]);
assert!(parse_plaintext(&good).is_some());
let series_end = REMEMBER_TOKEN_PREFIX.len() + 22;
assert_eq!(&good[series_end - 1..series_end], "A");
let smuggled = format!("{}B{}", &good[..series_end - 1], &good[series_end..]);
assert_ne!(smuggled, good, "the test did not actually change anything");
assert!(parse_plaintext(&smuggled).is_none());
}
#[test]
fn a_redacted_debug_does_not_contain_the_secret() {
let plaintext = PlaintextRememberToken::new("arcrmb_dead.beef".to_owned());
assert!(!format!("{plaintext:?}").contains("beef"));
}
#[test]
fn the_series_debug_is_not_the_plaintext() {
let plaintext = format_plaintext(&[0u8; SERIES_BYTES], &[0u8; SECRET_BYTES]);
let (series, _) = parse_plaintext(&plaintext).expect("round trip");
let rendered = format!("{series:?}");
assert!(rendered.starts_with("SeriesId("));
assert!(!rendered.contains(REMEMBER_TOKEN_PREFIX));
}
#[cfg(all(feature = "api-tokens", feature = "auth-reset"))]
#[test]
fn the_three_credential_prefixes_are_distinct() {
assert_ne!(REMEMBER_TOKEN_PREFIX, crate::tokens::TOKEN_PREFIX);
assert_ne!(
REMEMBER_TOKEN_PREFIX,
super::super::super::RESET_TOKEN_PREFIX
);
}
}