use crate::error::StorageError;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use doido_core::Result;
use hmac::digest::KeyInit;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Disposition {
#[default]
Inline,
Attachment,
}
impl Disposition {
pub fn header(&self, filename: &str) -> String {
let kind = match self {
Disposition::Inline => "inline",
Disposition::Attachment => "attachment",
};
format!("{kind}; filename=\"{}\"", filename.replace('"', ""))
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Payload {
data: String,
purpose: String,
#[serde(skip_serializing_if = "Option::is_none")]
exp: Option<u64>,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Clone)]
pub struct Signer {
secret: Vec<u8>,
}
impl Signer {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self {
secret: secret.into(),
}
}
pub fn from_env() -> Self {
match std::env::var("DOIDO_SECRET_KEY_BASE") {
Ok(s) if !s.is_empty() => Self::new(s.into_bytes()),
_ => {
doido_core::tracing::warn!(
"DOIDO_SECRET_KEY_BASE unset; using an insecure development signing key"
);
Self::new(b"doido-storage-development-secret".to_vec())
}
}
}
fn mac(&self, message: &[u8]) -> Vec<u8> {
let mut mac =
HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts keys of any length");
mac.update(message);
mac.finalize().into_bytes().to_vec()
}
pub fn sign(&self, data: &str, purpose: &str, expires_in: Option<Duration>) -> String {
let payload = Payload {
data: data.to_string(),
purpose: purpose.to_string(),
exp: expires_in.map(|d| now_secs() + d.as_secs()),
};
let json = serde_json::to_vec(&payload).expect("payload serializes");
let message = B64.encode(&json);
let sig = B64.encode(self.mac(message.as_bytes()));
format!("{message}--{sig}")
}
pub fn verify(&self, token: &str, purpose: &str) -> Result<String> {
let (message, sig) = token
.split_once("--")
.ok_or_else(|| StorageError::InvalidSignature("malformed token".into()))?;
let expected = self.mac(message.as_bytes());
let provided = B64
.decode(sig)
.map_err(|_| StorageError::InvalidSignature("bad signature encoding".into()))?;
let mut mac =
HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts keys of any length");
mac.update(message.as_bytes());
mac.verify_slice(&provided)
.map_err(|_| StorageError::InvalidSignature("signature mismatch".into()))?;
let _ = expected;
let json = B64
.decode(message)
.map_err(|_| StorageError::InvalidSignature("bad payload encoding".into()))?;
let payload: Payload = serde_json::from_slice(&json)
.map_err(|_| StorageError::InvalidSignature("bad payload".into()))?;
if payload.purpose != purpose {
return Err(StorageError::InvalidSignature("purpose mismatch".into()).into());
}
if let Some(exp) = payload.exp {
if now_secs() > exp {
return Err(StorageError::InvalidSignature("token expired".into()).into());
}
}
Ok(payload.data)
}
}