use std::fmt;
use chrono::{DateTime, Utc};
use zeroize::Zeroize;
use crate::crypt::base64url;
pub(crate) const ID_BYTES: usize = 16;
pub(crate) const SECRET_BYTES: usize = 32;
pub const RESET_TOKEN_PREFIX: &str = "arcpwr_";
const SEPARATOR: char = '.';
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) struct ResetTokenId([u8; ID_BYTES]);
impl ResetTokenId {
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for ResetTokenId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "ResetTokenId({})", base64url::encode(&self.0))
}
}
#[non_exhaustive]
pub struct PlaintextReset(String);
impl PlaintextReset {
pub(crate) fn new(plaintext: String) -> Self {
Self(plaintext)
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for PlaintextReset {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("PlaintextReset([redacted])")
}
}
impl Drop for PlaintextReset {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct IssuedPasswordReset {
subject: String,
expires_at: DateTime<Utc>,
plaintext: PlaintextReset,
}
impl IssuedPasswordReset {
pub(crate) fn new(
subject: String,
expires_at: DateTime<Utc>,
plaintext: PlaintextReset,
) -> 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) -> &PlaintextReset {
&self.plaintext
}
#[must_use]
pub fn into_parts(self) -> (String, DateTime<Utc>, PlaintextReset) {
(self.subject, self.expires_at, self.plaintext)
}
}
pub(crate) fn format_plaintext(id: &[u8; ID_BYTES], secret: &[u8; SECRET_BYTES]) -> String {
format!(
"{RESET_TOKEN_PREFIX}{}{SEPARATOR}{}",
base64url::encode(id),
base64url::encode(secret)
)
}
pub(crate) fn parse_plaintext(presented: &str) -> Option<(ResetTokenId, [u8; SECRET_BYTES])> {
let (id_text, secret_text) = presented
.strip_prefix(RESET_TOKEN_PREFIX)?
.split_once(SEPARATOR)?;
let id: [u8; ID_BYTES] = base64url::decode(id_text)?.try_into().ok()?;
let secret: [u8; SECRET_BYTES] = base64url::decode(secret_text)?.try_into().ok()?;
Some((ResetTokenId(id), secret))
}
#[cfg(test)]
mod tests {
use super::*;
#[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(RESET_TOKEN_PREFIX));
assert_eq!(plaintext.len(), RESET_TOKEN_PREFIX.len() + 22 + 1 + 43);
}
#[test]
fn a_minted_plaintext_parses_back_to_what_went_in() {
let id = [7u8; ID_BYTES];
let secret = [9u8; SECRET_BYTES];
let (parsed_id, parsed_secret) =
parse_plaintext(&format_plaintext(&id, &secret)).expect("round trip");
assert_eq!(parsed_id.as_bytes(), &id[..]);
assert_eq!(parsed_secret, secret);
}
#[test]
fn the_whole_plaintext_is_safe_in_a_url_without_escaping() {
let plaintext = format_plaintext(&[0xffu8; ID_BYTES], &[0x00u8; SECRET_BYTES]);
assert!(
plaintext
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')),
"not URL-safe: {plaintext}"
);
}
#[test]
fn a_string_this_crate_never_minted_costs_no_query() {
for hostile in [
"",
"arcpwr_",
"arcpat_AAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"arcpwr_AAAAAAAAAAAAAAAAAAAAAA",
"arcpwr_AAAAAAAAAAAAAAAAAAAAAA.short",
"arcpwr_short.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"arcpwr_AAAAAAAAAAAAAAAAAAAA==.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
] {
assert!(
parse_plaintext(hostile).is_none(),
"accepted {hostile:?} as a token"
);
}
}
#[test]
fn a_non_canonical_spelling_of_a_real_id_is_refused() {
let good = format_plaintext(&[0u8; ID_BYTES], &[0u8; SECRET_BYTES]);
assert!(parse_plaintext(&good).is_some());
let id_end = RESET_TOKEN_PREFIX.len() + 22;
assert_eq!(&good[id_end - 1..id_end], "A");
let smuggled = format!("{}B{}", &good[..id_end - 1], &good[id_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 = PlaintextReset::new("arcpwr_dead.beef".to_owned());
assert!(!format!("{plaintext:?}").contains("beef"));
}
#[test]
fn the_id_debug_is_not_the_plaintext() {
let plaintext = format_plaintext(&[0u8; ID_BYTES], &[0u8; SECRET_BYTES]);
let (id, _) = parse_plaintext(&plaintext).expect("round trip");
let rendered = format!("{id:?}");
assert!(rendered.starts_with("ResetTokenId("));
assert!(!rendered.contains(RESET_TOKEN_PREFIX));
}
}