use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmationClaims {
pub v: u8,
pub sub: String,
pub purpose: String,
pub tx_hash_hex: String,
pub jti: String,
pub method: String,
pub iat: i64,
pub exp: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenError {
Invalid,
Expired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecretError;
impl std::fmt::Display for SecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("CONFIRMATION_TOKEN_SECRET must be valid base64 decoding to >= 32 bytes")
}
}
impl std::error::Error for SecretError {}
pub fn decode_secret(raw: &str) -> Result<Vec<u8>, SecretError> {
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE};
let trimmed = raw.trim();
let key = STANDARD
.decode(trimmed)
.or_else(|_| STANDARD_NO_PAD.decode(trimmed))
.or_else(|_| URL_SAFE.decode(trimmed))
.or_else(|_| URL_SAFE_NO_PAD.decode(trimmed))
.map_err(|_| SecretError)?;
if key.len() < 32 {
return Err(SecretError);
}
Ok(key)
}
pub fn mint(secret: &[u8], claims: &ConfirmationClaims) -> String {
let payload_b64 =
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims serialize"));
let mut mac =
<HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(payload_b64.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload_b64}.{sig_b64}")
}
pub fn verify(
secret: &[u8],
token: &str,
now_epoch: i64,
) -> Result<ConfirmationClaims, TokenError> {
let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Invalid)?;
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| TokenError::Invalid)?;
let mut mac =
<HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(payload_b64.as_bytes());
mac.verify_slice(&sig).map_err(|_| TokenError::Invalid)?;
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| TokenError::Invalid)?;
let claims: ConfirmationClaims =
serde_json::from_slice(&payload).map_err(|_| TokenError::Invalid)?;
if claims.v != 1 {
return Err(TokenError::Invalid);
}
if now_epoch >= claims.exp {
return Err(TokenError::Expired);
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET_B64: &str = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";
fn claims(now: i64) -> ConfirmationClaims {
ConfirmationClaims {
v: 1,
sub: "user-1".into(),
purpose: "withdrawal".into(),
tx_hash_hex: "deadbeef".into(),
jti: "jti-1".into(),
method: "webauthn".into(),
iat: now,
exp: now + 90,
}
}
#[test]
fn mint_then_verify_roundtrips() {
let secret = decode_secret(SECRET_B64).unwrap();
let now = 1_700_000_000;
let token = mint(&secret, &claims(now));
let got = verify(&secret, &token, now).unwrap();
assert_eq!(got.sub, "user-1");
assert_eq!(got.tx_hash_hex, "deadbeef");
assert_eq!(got.jti, "jti-1");
}
#[test]
fn tampered_signature_is_invalid() {
let secret = decode_secret(SECRET_B64).unwrap();
let now = 1_700_000_000;
let mut token = mint(&secret, &claims(now));
token.replace_range(token.len() - 4.., "AAAA");
assert_eq!(verify(&secret, &token, now).unwrap_err(), TokenError::Invalid);
}
#[test]
fn expired_token_reports_expired() {
let secret = decode_secret(SECRET_B64).unwrap();
let now = 1_700_000_000;
let token = mint(&secret, &claims(now));
assert_eq!(
verify(&secret, &token, now + 91).unwrap_err(),
TokenError::Expired
);
}
#[test]
fn short_secret_rejected() {
assert!(decode_secret("YWJj").is_err()); }
}