use std::fmt;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OpaqueId {
id: String,
}
impl OpaqueId {
#[must_use]
pub fn new(db_id: impl Into<String>) -> Self {
let id_str = db_id.into();
let encoded = URL_SAFE_NO_PAD.encode(id_str.as_bytes());
Self { id: encoded }
}
#[must_use]
pub fn with_signature(db_id: impl Into<String>, secret: &[u8]) -> Self {
let id_str = db_id.into();
let mut hasher = Sha256::new();
hasher.update(id_str.as_bytes());
hasher.update(secret);
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
let opaque = format!("{}|{}", id_str, signature);
let encoded = URL_SAFE_NO_PAD.encode(opaque.as_bytes());
Self { id: encoded }
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.id
}
#[must_use]
pub fn decode(&self) -> Option<String> {
URL_SAFE_NO_PAD
.decode(&self.id)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
}
#[must_use]
pub fn verify_signature(&self, secret: &[u8]) -> bool {
let Some(decoded) = self.decode() else {
return false;
};
let Some((db_id, provided_sig)) = decoded.split_once('|') else {
return false;
};
let mut hasher = Sha256::new();
hasher.update(db_id.as_bytes());
hasher.update(secret);
let expected_sig = URL_SAFE_NO_PAD.encode(hasher.finalize());
constant_time_eq(provided_sig.as_bytes(), expected_sig.as_bytes())
}
}
impl fmt::Display for OpaqueId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.id)
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opaque_id_creation() {
let opaque = OpaqueId::new("12345");
assert!(!opaque.as_str().is_empty());
assert!(!opaque.as_str().contains("12345"));
}
#[test]
fn test_opaque_id_decode() {
let db_id = "user_42";
let opaque = OpaqueId::new(db_id);
let decoded = opaque.decode();
assert_eq!(decoded, Some(db_id.to_string()));
}
#[test]
fn test_opaque_id_with_signature() {
let db_id = "12345";
let secret = b"secret_key";
let opaque = OpaqueId::with_signature(db_id, secret);
assert!(opaque.verify_signature(secret));
assert!(!opaque.verify_signature(b"wrong_secret"));
}
#[test]
fn test_opaque_id_signature_tampering() {
let db_id = "sensitive_id_789";
let secret = b"super_secret";
let mut opaque = OpaqueId::with_signature(db_id, secret);
assert!(opaque.verify_signature(secret));
opaque.id = opaque.id.chars().rev().collect();
assert!(!opaque.verify_signature(secret));
}
#[test]
fn test_opaque_id_equality() {
let opaque1 = OpaqueId::new("same_id");
let opaque2 = OpaqueId::new("same_id");
assert_eq!(opaque1, opaque2);
let opaque3 = OpaqueId::new("different_id");
assert_ne!(opaque1, opaque3);
}
#[test]
fn test_opaque_id_prevents_enumeration() {
let ids: Vec<String> = (1..=5).map(|i| i.to_string()).collect();
let opaque_ids: Vec<OpaqueId> = ids.iter().map(OpaqueId::new).collect();
for i in 1..opaque_ids.len() {
assert_ne!(opaque_ids[i].as_str(), opaque_ids[i - 1].as_str());
}
for i in 0..opaque_ids.len() {
let original = ids[i].as_str();
let opaque = opaque_ids[i].as_str();
assert!(!opaque.contains(original));
}
}
}