use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::ports::signer::{Kid, Payload, Signer};
type HmacSha256 = Hmac<Sha256>;
pub const MIN_SECRET_BYTES: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SignerError {
#[error("harness secret must be at least {MIN_SECRET_BYTES} bytes")]
SecretTooShort,
}
#[derive(Serialize, Deserialize)]
struct PayloadJson {
purpose: String,
subject: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
exp: Option<u64>,
kid: String,
}
#[derive(Debug, Clone)]
pub struct HmacSigner {
cur: Vec<u8>,
prev: Option<Vec<u8>>,
}
impl HmacSigner {
pub fn new(
current_secret: impl Into<String>,
previous_secret: Option<String>,
) -> Result<Self, SignerError> {
let cur: Vec<u8> = current_secret.into().into_bytes();
if cur.len() < MIN_SECRET_BYTES {
return Err(SignerError::SecretTooShort);
}
Ok(Self {
cur,
prev: previous_secret.map(String::into_bytes),
})
}
fn mac(key: &[u8], encoded_payload: &str) -> [u8; 32] {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(encoded_payload.as_bytes());
let mut out = [0u8; 32];
out.copy_from_slice(&mac.finalize().into_bytes());
out
}
fn key_for(&self, kid: Kid) -> (&[u8], Option<Kid>) {
match (kid, self.prev.as_deref()) {
(Kid::Cur, Some(_)) => (&self.cur, Some(Kid::Prev)),
(Kid::Prev, Some(prev)) => (prev, Some(Kid::Cur)),
(Kid::Cur | Kid::Prev, None) => (&self.cur, None),
}
}
}
impl Signer for HmacSigner {
fn sign(&self, payload: &Payload) -> String {
let (kid, key) = match (payload.kid, self.prev.as_deref()) {
(Kid::Prev, Some(prev)) => (Kid::Prev, prev),
_ => (Kid::Cur, &self.cur[..]),
};
let json = PayloadJson {
purpose: payload.purpose.clone(),
subject: payload.subject.clone(),
exp: payload.exp,
kid: kid_name(kid).to_string(),
};
let encoded =
URL_SAFE_NO_PAD.encode(serde_json::to_string(&json).expect("payload serializes"));
let mac = Self::mac(key, &encoded);
format!("{encoded}.{}", URL_SAFE_NO_PAD.encode(mac))
}
fn verify(&self, token: &str, expected_purpose: &str) -> Option<Payload> {
let (encoded_payload, encoded_mac) = token.split_once('.')?;
if encoded_payload.is_empty() || encoded_mac.is_empty() {
return None;
}
if token.matches('.').count() != 1 {
return None;
}
let mac: [u8; 32] = URL_SAFE_NO_PAD.decode(encoded_mac).ok()?.try_into().ok()?;
let json = URL_SAFE_NO_PAD
.decode(encoded_payload)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())?;
let payload: PayloadJson = serde_json::from_str(&json).ok()?;
let kid = parse_kid(&payload.kid)?;
let (named_key, fallback_kid) = self.key_for(kid);
let named_ok = bool::from(mac.ct_eq(&Self::mac(named_key, encoded_payload)));
let verified = named_ok
|| match fallback_kid {
Some(other) => {
let (other_key, _) = self.key_for(other);
bool::from(mac.ct_eq(&Self::mac(other_key, encoded_payload)))
}
None => false,
};
if !verified {
return None;
}
if payload.purpose != expected_purpose {
return None;
}
if let Some(exp) = payload.exp {
let now = time::OffsetDateTime::now_utc()
.unix_timestamp()
.max(0)
.cast_unsigned();
if now >= exp {
return None;
}
}
Some(Payload {
purpose: payload.purpose,
subject: payload.subject,
exp: payload.exp,
kid,
})
}
}
fn kid_name(kid: Kid) -> &'static str {
match kid {
Kid::Cur => "cur",
Kid::Prev => "prev",
}
}
fn parse_kid(name: &str) -> Option<Kid> {
match name {
"cur" => Some(Kid::Cur),
"prev" => Some(Kid::Prev),
_ => None,
}
}