use crate::WireError;
use crate::aad::Aad;
use crate::cipher::{DataKey, Iv, decrypt_leaf_as_string, encrypt_leaf};
use crate::leaf::{EncryptedLeaf, Plaintext};
use sha2::{Digest, Sha512};
use subtle::ConstantTimeEq;
pub const MAC_ONLY_ENCRYPTED_SEED: [u8; 32] = [
0x8a, 0x3f, 0xd2, 0xad, 0x54, 0xce, 0x66, 0x52, 0x7b, 0x10, 0x34, 0xf3, 0xd1, 0x47, 0xbe, 0x0b,
0x0b, 0x97, 0x5b, 0x3b, 0xf4, 0x4f, 0x72, 0xc6, 0xfd, 0xad, 0xec, 0x81, 0x76, 0xf2, 0x7d, 0x69,
];
#[derive(Clone)]
pub struct Mac(String);
impl Mac {
#[must_use]
pub fn as_hex(&self) -> &str {
&self.0
}
#[must_use]
pub fn from_file(hex: impl Into<String>) -> Self {
Self(hex.into())
}
#[must_use]
pub fn is_absent(&self) -> bool {
self.0.is_empty()
}
}
impl PartialEq for Mac {
fn eq(&self, other: &Self) -> bool {
self.0.len() == other.0.len() && self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
}
}
impl Eq for Mac {}
impl std::fmt::Debug for Mac {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0.len() > 24 {
write!(f, "Mac({}…{})", &self.0[..16], &self.0[self.0.len() - 8..])
} else {
write!(f, "Mac({})", self.0)
}
}
}
pub struct MacAccumulator {
hash: Sha512,
mac_only_encrypted: bool,
fed: usize,
}
impl MacAccumulator {
#[must_use]
pub fn new(mac_only_encrypted: bool) -> Self {
let mut hash = Sha512::new();
if mac_only_encrypted {
hash.update(MAC_ONLY_ENCRYPTED_SEED);
}
Self {
hash,
mac_only_encrypted,
fed: 0,
}
}
#[must_use]
pub fn mac_only_encrypted(&self) -> bool {
self.mac_only_encrypted
}
pub fn feed(&mut self, plaintext: &Plaintext) {
self.hash.update(plaintext.mac_bytes());
self.fed += 1;
}
#[must_use]
pub fn leaves_fed(&self) -> usize {
self.fed
}
#[must_use]
pub fn finish(self) -> Mac {
use std::fmt::Write as _;
let digest = self.hash.finalize();
Mac(digest.iter().fold(String::with_capacity(128), |mut s, b| {
let _ = write!(s, "{b:02X}");
s
}))
}
}
#[must_use]
pub fn mac_field_aad(lastmodified_verbatim: &str) -> Aad {
Aad::field(lastmodified_verbatim)
}
pub fn verify_mac_field(
key: &DataKey,
mac_field: &str,
lastmodified_verbatim: &str,
computed: &Mac,
) -> Result<Mac, WireError> {
let leaf = EncryptedLeaf::parse(mac_field).map_err(|_| WireError::MacUndecryptable)?;
let aad = mac_field_aad(lastmodified_verbatim);
let stored =
decrypt_leaf_as_string(key, &leaf, &aad).map_err(|_| WireError::MacUndecryptable)?;
let stored = Mac::from_file(stored.as_str());
if stored == *computed {
Ok(stored)
} else {
Err(WireError::MacMismatch)
}
}
pub fn seal_mac_field(
key: &DataKey,
mac: &Mac,
lastmodified_verbatim: &str,
iv: Option<Iv>,
) -> Result<String, WireError> {
let aad = mac_field_aad(lastmodified_verbatim);
let pt = Plaintext::string(mac.as_hex());
let leaf = encrypt_leaf(key, &pt, &aad, iv)?.ok_or(WireError::MacUndecryptable)?;
Ok(leaf.render())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_seed_is_sha256_of_the_word_sops() {
use sha2::Sha256;
let mut h = Sha256::new();
h.update(b"sops");
assert_eq!(h.finalize().as_slice(), MAC_ONLY_ENCRYPTED_SEED);
}
#[test]
fn digest_is_128_uppercase_hex_chars() {
let mut acc = MacAccumulator::new(false);
acc.feed(&Plaintext::string("a"));
let mac = acc.finish();
assert_eq!(mac.as_hex().len(), 128);
assert!(
mac.as_hex()
.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase())
);
}
#[test]
fn known_answer_for_a_single_leaf() {
let mut acc = MacAccumulator::new(false);
acc.feed(&Plaintext::string("a"));
assert_eq!(
acc.finish().as_hex(),
"1F40FC92DA241694750979EE6CF582F2D5D7D28E18335DE05ABC54D0560E0F5302860C652BF08D560252AA5E74210546F369FBBBCE8C12CFC7957B2652FE9A75"
);
}
#[test]
fn the_seed_changes_the_digest() {
let plain = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("x"));
a.finish()
};
let seeded = {
let mut a = MacAccumulator::new(true);
a.feed(&Plaintext::string("x"));
a.finish()
};
assert_ne!(plain, seeded, "the seed exists precisely to separate these");
}
#[test]
fn order_changes_the_digest() {
let ab = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("a"));
a.feed(&Plaintext::string("b"));
a.finish()
};
let ba = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("b"));
a.feed(&Plaintext::string("a"));
a.finish()
};
assert_ne!(ab, ba);
}
#[test]
fn concatenation_is_unseparated_upstream_collision_included() {
let joined = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("ab"));
a.finish()
};
let split = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("a"));
a.feed(&Plaintext::string("b"));
a.finish()
};
assert_eq!(joined, split, "reproduced, not endorsed");
}
#[test]
fn the_denominator_is_reported() {
let mut acc = MacAccumulator::new(false);
assert_eq!(acc.leaves_fed(), 0);
acc.feed(&Plaintext::string("a"));
acc.feed(&Plaintext::string("b"));
assert_eq!(acc.leaves_fed(), 2);
}
#[test]
fn mac_field_round_trips_and_binds_to_lastmodified() {
let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
let mut acc = MacAccumulator::new(false);
acc.feed(&Plaintext::string("value"));
let mac = acc.finish();
let ts = "2026-08-18T12:00:00Z";
let field = seal_mac_field(&key, &mac, ts, None).expect("seal");
assert_eq!(
verify_mac_field(&key, &field, ts, &mac).expect("verify"),
mac
);
assert_eq!(
verify_mac_field(&key, &field, "2026-08-18T12:00:01Z", &mac),
Err(WireError::MacUndecryptable)
);
}
#[test]
fn a_changed_leaf_is_a_mismatch_not_an_undecryptable_field() {
let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
let ts = "2026-08-18T12:00:00Z";
let original = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("before"));
a.finish()
};
let field = seal_mac_field(&key, &original, ts, None).expect("seal");
let tampered = {
let mut a = MacAccumulator::new(false);
a.feed(&Plaintext::string("after"));
a.finish()
};
assert_eq!(
verify_mac_field(&key, &field, ts, &tampered),
Err(WireError::MacMismatch)
);
}
#[test]
fn debug_elides_the_middle() {
let mut acc = MacAccumulator::new(false);
acc.feed(&Plaintext::string("a"));
let shown = format!("{:?}", acc.finish());
assert!(shown.starts_with("Mac(1F40FC92DA241694…"), "got {shown}");
}
}