use std::collections::HashMap;
use hmac::{Hmac, Mac};
use rand::Rng;
use sha2::Sha256;
use zeroize::{Zeroize, Zeroizing};
const SUFFIX_HEX_LEN: usize = 8;
pub enum NonceStrategy {
Random,
Deterministic { key: Zeroizing<Vec<u8>> },
}
impl NonceStrategy {
#[must_use]
pub fn deterministic(key: &[u8]) -> Self {
Self::Deterministic {
key: Zeroizing::new(key.to_vec()),
}
}
}
enum Suffix {
Fixed(String),
Keyed(Zeroizing<Vec<u8>>),
}
impl Suffix {
fn for_value(&self, value: &str) -> String {
match self {
Suffix::Fixed(nonce) => nonce.clone(),
Suffix::Keyed(key) => keyed_suffix(key, value),
}
}
}
pub struct Vault {
by_placeholder: HashMap<String, String>,
by_value: HashMap<String, String>,
restorable: std::collections::HashSet<String>,
counters: HashMap<String, u32>,
restored: HashMap<String, u32>,
suffix: Suffix,
}
impl Vault {
#[must_use]
pub fn new() -> Self {
Self::with_nonce(random_nonce())
}
#[must_use]
pub fn deterministic(key: &[u8]) -> Self {
Self::with_strategy(NonceStrategy::deterministic(key))
}
#[must_use]
pub fn with_strategy(strategy: NonceStrategy) -> Self {
let suffix = match strategy {
NonceStrategy::Random => Suffix::Fixed(random_nonce()),
NonceStrategy::Deterministic { key } => Suffix::Keyed(key),
};
Self::with_suffix(suffix)
}
#[must_use]
pub fn with_nonce(nonce: String) -> Self {
Self::with_suffix(Suffix::Fixed(nonce))
}
fn with_suffix(suffix: Suffix) -> Self {
Self {
by_placeholder: HashMap::new(),
by_value: HashMap::new(),
restorable: std::collections::HashSet::new(),
counters: HashMap::new(),
restored: HashMap::new(),
suffix,
}
}
#[must_use]
pub fn nonce(&self) -> Option<&str> {
match &self.suffix {
Suffix::Fixed(nonce) => Some(nonce),
Suffix::Keyed(_) => None,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_placeholder.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.by_placeholder.len()
}
pub fn intern(&mut self, ty: &str, original: &str, restorable: bool) -> String {
if let Some(existing) = self.by_value.get(original) {
if restorable {
self.restorable.insert(existing.clone());
}
return existing.clone();
}
let suffix = self.suffix.for_value(original);
let counter = self.counters.entry(ty.to_owned()).or_insert(0);
*counter += 1;
let placeholder = format!("[REDACTED_{ty}_{counter}_{suffix}]");
self.by_placeholder
.insert(placeholder.clone(), original.to_owned());
self.by_value
.insert(original.to_owned(), placeholder.clone());
if restorable {
self.restorable.insert(placeholder.clone());
}
placeholder
}
#[must_use]
pub fn is_restorable(&self, placeholder: &str) -> bool {
self.restorable.contains(placeholder)
}
#[must_use]
pub fn original_for(&self, placeholder: &str) -> Option<&str> {
self.by_placeholder.get(placeholder).map(String::as_str)
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.by_placeholder
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn interned_counts(&self) -> impl Iterator<Item = (&str, u32)> {
self.counters.iter().map(|(ty, n)| (ty.as_str(), *n))
}
pub fn note_restored(&mut self, ty: &str, n: u32) {
if n == 0 {
return;
}
*self.restored.entry(ty.to_owned()).or_insert(0) += n;
}
pub fn restored_counts(&self) -> impl Iterator<Item = (&str, u32)> {
self.restored.iter().map(|(ty, n)| (ty.as_str(), *n))
}
}
impl Default for Vault {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mode = match self.suffix {
Suffix::Fixed(_) => "Random",
Suffix::Keyed(_) => "Deterministic",
};
f.debug_struct("Vault")
.field("entries", &self.by_placeholder.len())
.field("mode", &mode)
.finish_non_exhaustive()
}
}
fn keyed_suffix(key: &[u8], value: &str) -> String {
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(key) else {
unreachable!("HMAC accepts any key length")
};
mac.update(value.as_bytes());
let tag = mac.finalize().into_bytes();
hex::encode(&tag[..SUFFIX_HEX_LEN / 2])
}
fn random_nonce() -> String {
let bytes: [u8; SUFFIX_HEX_LEN / 2] = rand::thread_rng().gen();
hex::encode(bytes)
}
impl Drop for Vault {
fn drop(&mut self) {
for (_, mut original) in self.by_placeholder.drain() {
original.zeroize();
}
for (mut original, _) in self.by_value.drain() {
original.zeroize();
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use super::*;
#[test]
fn interns_and_numbers_per_type() {
let mut v = Vault::with_nonce("deadbeef".to_owned());
let a = v.intern("EMAIL", "alice@x.com", true);
let b = v.intern("EMAIL", "bob@y.com", true);
assert_eq!(a, "[REDACTED_EMAIL_1_deadbeef]");
assert_eq!(b, "[REDACTED_EMAIL_2_deadbeef]");
}
#[test]
fn same_value_dedupes_to_same_sentinel() {
let mut v = Vault::new();
let a = v.intern("EMAIL", "alice@x.com", true);
let a2 = v.intern("EMAIL", "alice@x.com", true);
assert_eq!(a, a2);
}
#[test]
fn distinct_types_number_independently() {
let mut v = Vault::with_nonce("deadbeef".to_owned());
assert_eq!(
v.intern("EMAIL", "a@b.com", true),
"[REDACTED_EMAIL_1_deadbeef]"
);
assert_eq!(
v.intern("PHONE", "555-1234", true),
"[REDACTED_PHONE_1_deadbeef]"
);
}
#[test]
fn original_for_round_trips_the_sentinel() {
let mut v = Vault::new();
let p = v.intern("EMAIL", "alice@x.com", true);
assert_eq!(v.original_for(&p), Some("alice@x.com"));
assert_eq!(v.original_for("[REDACTED_EMAIL_9_zzzz]"), None);
}
#[test]
fn nonce_is_eight_hex_chars() {
let v = Vault::new();
let nonce = v.nonce().unwrap();
assert_eq!(nonce.len(), SUFFIX_HEX_LEN);
assert!(nonce.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn distinct_vaults_get_distinct_nonces() {
let a = Vault::new();
let b = Vault::new();
assert_ne!(a.nonce(), b.nonce());
}
#[test]
fn empty_vault_reports_empty() {
let v = Vault::new();
assert!(v.is_empty());
assert_eq!(v.len(), 0);
}
#[test]
fn drop_zeroizes_originals() {
let mut v = Vault::new();
let _ = v.intern("EMAIL", "secret@example.com", true);
assert!(!v.is_empty());
drop(v);
}
fn suffix_of(sentinel: &str) -> &str {
sentinel
.strip_suffix(']')
.and_then(|s| s.rsplit_once('_'))
.map_or("", |(_, suffix)| suffix)
}
#[test]
fn deterministic_same_key_same_sentinel_in_replay_order() {
let key = b"conversation-key";
let mut a = Vault::deterministic(key);
let mut b = Vault::deterministic(key);
let sa = a.intern("EMAIL", "alice@x.com", true);
let sb = b.intern("EMAIL", "alice@x.com", true);
assert_eq!(sa, sb, "same value + same key + same order must match");
}
#[test]
fn deterministic_suffix_is_value_keyed_independent_of_order() {
let key = b"conversation-key";
let mut a = Vault::deterministic(key);
let mut b = Vault::deterministic(key);
a.intern("EMAIL", "bob@y.com", true); let sa = a.intern("EMAIL", "alice@x.com", true);
let sb = b.intern("EMAIL", "alice@x.com", true);
assert_eq!(suffix_of(&sa), suffix_of(&sb));
}
#[test]
fn deterministic_different_keys_different_sentinels() {
let mut a = Vault::deterministic(b"key-one");
let mut b = Vault::deterministic(b"key-two");
let sa = a.intern("EMAIL", "alice@x.com", true);
let sb = b.intern("EMAIL", "alice@x.com", true);
assert_ne!(sa, sb, "different keys must diverge for the same value");
}
#[test]
fn deterministic_suffix_is_eight_hex_chars() {
let mut v = Vault::deterministic(b"k");
let s = v.intern("EMAIL", "alice@x.com", true);
let suffix = s
.strip_prefix("[REDACTED_EMAIL_1_")
.and_then(|s| s.strip_suffix(']'))
.unwrap();
assert_eq!(suffix.len(), SUFFIX_HEX_LEN);
assert!(suffix.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn deterministic_nonce_accessor_is_none() {
let v = Vault::deterministic(b"k");
assert_eq!(v.nonce(), None);
}
#[test]
fn deterministic_round_trips_the_sentinel() {
let mut v = Vault::deterministic(b"conversation-key");
let p = v.intern("EMAIL", "alice@x.com", true);
assert_eq!(v.original_for(&p), Some("alice@x.com"));
assert!(v.is_restorable(&p));
}
#[test]
fn deterministic_distinct_values_distinct_suffixes() {
let mut v = Vault::deterministic(b"k");
let a = v.intern("EMAIL", "alice@x.com", true);
let b = v.intern("EMAIL", "bob@y.com", true);
assert_ne!(a, b);
}
#[test]
fn interned_counts_track_distinct_values_per_type() {
let mut v = Vault::with_nonce("deadbeef".to_owned());
v.intern("EMAIL", "a@b.com", true);
v.intern("EMAIL", "c@d.com", true);
v.intern("EMAIL", "a@b.com", true); v.intern("PHONE", "555-1234", true);
let counts: std::collections::BTreeMap<_, _> = v
.interned_counts()
.map(|(ty, n)| (ty.to_owned(), n))
.collect();
assert_eq!(counts.get("EMAIL"), Some(&2));
assert_eq!(counts.get("PHONE"), Some(&1));
}
#[test]
fn restored_counts_accumulate_per_type() {
let mut v = Vault::new();
v.note_restored("EMAIL", 2);
v.note_restored("EMAIL", 1);
v.note_restored("PHONE", 0); let counts: std::collections::BTreeMap<_, _> = v
.restored_counts()
.map(|(ty, n)| (ty.to_owned(), n))
.collect();
assert_eq!(counts.get("EMAIL"), Some(&3));
assert_eq!(counts.get("PHONE"), None);
}
#[test]
fn deterministic_dedupes_same_value() {
let mut v = Vault::deterministic(b"k");
let a = v.intern("EMAIL", "alice@x.com", true);
let a2 = v.intern("EMAIL", "alice@x.com", true);
assert_eq!(a, a2);
assert_eq!(v.len(), 1);
}
}