use hmac::{Hmac, Mac};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const DOMAIN: &[u8] = b"faucet-dlq-masking/v1";
#[derive(Clone)]
pub struct MaskingProof {
key: Vec<u8>,
tenant: String,
rules: String,
}
impl std::fmt::Debug for MaskingProof {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MaskingProof")
.field("tenant", &self.tenant)
.field("rules", &self.rules)
.finish_non_exhaustive()
}
}
impl MaskingProof {
pub fn new(key: impl Into<Vec<u8>>, tenant: Option<&str>, rules: impl Into<String>) -> Self {
Self {
key: key.into(),
tenant: tenant.unwrap_or_default().to_string(),
rules: rules.into(),
}
}
pub fn rules(&self) -> &str {
&self.rules
}
pub fn sign(&self, envelope: &mut Value) {
let Some(obj) = envelope.as_object() else {
return;
};
let sig = self.mac(
str_of(obj.get("pipeline")),
str_of(obj.get("row")),
obj.get("payload").unwrap_or(&Value::Null),
);
if let Some(obj) = envelope.as_object_mut() {
obj.insert(
"masking".into(),
json!({ "rules": self.rules, "sig": hex(&sig) }),
);
}
}
pub fn verify(&self, pipeline: &str, row: &str, payload: &Value, masking: &Value) -> bool {
let (Some(rules), Some(sig)) = (
masking.get("rules").and_then(Value::as_str),
masking.get("sig").and_then(Value::as_str),
) else {
return false;
};
let Some(sig) = unhex(sig) else {
return false;
};
if rules != self.rules {
return false;
}
self.hmac(pipeline, row, payload).verify_slice(&sig).is_ok()
}
fn mac(&self, pipeline: &str, row: &str, payload: &Value) -> Vec<u8> {
self.hmac(pipeline, row, payload)
.finalize()
.into_bytes()
.to_vec()
}
fn hmac(&self, pipeline: &str, row: &str, payload: &Value) -> HmacSha256 {
let mut mac =
HmacSha256::new_from_slice(&self.key).expect("HMAC takes a key of any length");
mac.update(DOMAIN);
let payload = Sha256::digest(crate::util::canonical_json(payload).as_bytes());
for part in [
self.tenant.as_bytes(),
pipeline.as_bytes(),
row.as_bytes(),
self.rules.as_bytes(),
payload.as_slice(),
] {
mac.update(&(part.len() as u64).to_be_bytes());
mac.update(part);
}
mac
}
}
pub fn derive_key(secret: &[u8], label: &str) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC takes a key of any length");
mac.update(b"faucet-derive/");
mac.update(label.as_bytes());
mac.finalize().into_bytes().to_vec()
}
pub fn rules_fingerprint(rules: &Value, masking_key: Option<&str>, proof_key: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(proof_key).expect("HMAC takes a key of any length");
mac.update(b"faucet-masking-key/");
mac.update(masking_key.unwrap_or_default().as_bytes());
let key_mac = mac.finalize().into_bytes();
let mut h = Sha256::new();
h.update(crate::util::canonical_json(rules).as_bytes());
h.update([u8::from(masking_key.is_some())]);
h.update(key_mac);
hex(&h.finalize())
}
fn str_of(v: Option<&Value>) -> &str {
v.and_then(Value::as_str).unwrap_or_default()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) || !s.is_ascii() {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn envelope(payload: Value) -> Value {
json!({ "pipeline": "p", "row": "r", "payload": payload })
}
fn check(p: &MaskingProof, e: &Value) -> bool {
p.verify(
e["pipeline"].as_str().unwrap_or(""),
e["row"].as_str().unwrap_or(""),
&e["payload"],
&e["masking"],
)
}
#[test]
fn a_signed_envelope_verifies_only_as_signed() {
let key = derive_key(b"server-secret", "dlq-masking");
let rules = rules_fingerprint(&json!([{"name": "email"}]), Some("k"), &key);
let p = MaskingProof::new(key.clone(), Some("acme"), rules.clone());
assert_eq!(p.rules(), rules);
assert!(format!("{p:?}").contains("acme"));
let mut e = envelope(json!({"email": "***", "id": 1}));
p.sign(&mut e);
assert!(check(&p, &e));
let mut tampered = e.clone();
tampered["payload"]["email"] = json!("a@b.example");
assert!(!check(&p, &tampered), "payload changed");
let mut moved = e.clone();
moved["row"] = json!("other");
assert!(!check(&p, &moved), "row changed");
let other_tenant = MaskingProof::new(key.clone(), Some("globex"), rules.clone());
assert!(!check(&other_tenant, &e));
let other_rules = MaskingProof::new(
key.clone(),
Some("acme"),
rules_fingerprint(&json!([{"name": "email"}]), Some("k2"), &key),
);
assert!(
!check(&other_rules, &e),
"a changed masking key changes the rules"
);
let other_key = MaskingProof::new(derive_key(b"x", "dlq-masking"), Some("acme"), rules);
assert!(!check(&other_key, &e));
for bad in [
json!(null),
json!({"rules": p.rules()}),
json!({"rules": p.rules(), "sig": "zz"}),
json!({"rules": p.rules(), "sig": "abc"}),
json!({"rules": p.rules(), "sig": "é1"}),
json!({"rules": "x", "sig": e["masking"]["sig"]}),
] {
let mut forged = e.clone();
forged["masking"] = bad;
assert!(!check(&p, &forged));
}
let mut not_object = json!("x");
p.sign(&mut not_object);
assert_eq!(not_object, json!("x"));
}
#[test]
fn fingerprints_follow_rules_and_key() {
let k = derive_key(b"s", "dlq-masking");
let a = rules_fingerprint(&json!([{"b": 1, "a": 2}]), None, &k);
assert_eq!(a, rules_fingerprint(&json!([{"a": 2, "b": 1}]), None, &k));
assert_ne!(a, rules_fingerprint(&json!([{"a": 2}]), None, &k));
assert_ne!(
a,
rules_fingerprint(&json!([{"b": 1, "a": 2}]), Some(""), &k)
);
assert_ne!(derive_key(b"s", "a"), derive_key(b"s", "b"));
}
}