use crate::error::{Error, Result};
use zeroize::Zeroizing;
const CHECKSUM_LEN: usize = 4;
const DOMAIN: &[u8] = b"crypto_vote/prefixed-checksum/v1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tag {
PublicKey,
SecretKey,
KeyImage,
Signature,
Ownership,
Nonce,
}
impl Tag {
pub const fn as_str(self) -> &'static str {
match self {
Tag::PublicKey => "pk",
Tag::SecretKey => "sk",
Tag::KeyImage => "ki",
Tag::Signature => "blsag",
Tag::Ownership => "own",
Tag::Nonce => "nonce",
}
}
}
fn checksum(tag: Tag, payload: &[u8]) -> [u8; CHECKSUM_LEN] {
let mut hasher = blake3::Hasher::new();
hasher.update(DOMAIN);
hasher.update(tag.as_str().as_bytes());
hasher.update(&[0u8]);
hasher.update(payload);
let hash = hasher.finalize();
let mut out = [0u8; CHECKSUM_LEN];
out.copy_from_slice(&hash.as_bytes()[..CHECKSUM_LEN]);
out
}
pub fn encode_prefixed(tag: Tag, payload: &[u8]) -> String {
let cs = checksum(tag, payload);
format!(
"{}_{}_{}",
tag.as_str(),
hex::encode(payload),
hex::encode(cs)
)
}
pub fn decode_prefixed(expected: Tag, s: &str) -> Result<Zeroizing<Vec<u8>>> {
let parts: Vec<&str> = s.split('_').collect();
let [tag_str, body_hex, cs_hex] = parts.as_slice() else {
return Err(Error::InvalidPrefix {
expected: expected.as_str(),
got: String::new(),
});
};
if *tag_str != expected.as_str() {
return Err(Error::InvalidPrefix {
expected: expected.as_str(),
got: (*tag_str).to_owned(),
});
}
let payload = Zeroizing::new(hex::decode(body_hex).map_err(|_| Error::InvalidHex)?);
let provided = hex::decode(cs_hex).map_err(|_| Error::InvalidHex)?;
if provided.len() != CHECKSUM_LEN {
return Err(Error::InvalidChecksum);
}
if provided[..] != checksum(expected, &payload)[..] {
return Err(Error::InvalidChecksum);
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_every_tag() {
let payload = [7u8; 32];
for tag in [
Tag::PublicKey,
Tag::SecretKey,
Tag::KeyImage,
Tag::Signature,
Tag::Ownership,
Tag::Nonce,
] {
let s = encode_prefixed(tag, &payload);
assert!(s.starts_with(tag.as_str()));
let back = decode_prefixed(tag, &s).unwrap();
assert_eq!(&back[..], &payload[..]);
}
}
#[test]
fn shape_is_tag_body_checksum() {
let s = encode_prefixed(Tag::PublicKey, &[0xab; 32]);
let parts: Vec<&str> = s.split('_').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "pk");
assert_eq!(parts[1].len(), 64); assert_eq!(parts[2].len(), 8); }
#[test]
fn rejects_wrong_tag() {
let s = encode_prefixed(Tag::PublicKey, &[1u8; 32]);
let err = decode_prefixed(Tag::KeyImage, &s).unwrap_err();
assert_eq!(
err,
Error::InvalidPrefix {
expected: "ki",
got: "pk".to_owned()
}
);
}
#[test]
fn rejects_relabelled_value() {
let s = encode_prefixed(Tag::PublicKey, &[2u8; 32]);
let relabelled = format!("ki{}", &s["pk".len()..]);
assert_eq!(
decode_prefixed(Tag::KeyImage, &relabelled).unwrap_err(),
Error::InvalidChecksum
);
}
#[test]
fn rejects_corrupted_checksum() {
let s = encode_prefixed(Tag::Signature, &[3u8; 96]);
let mut bytes = s.into_bytes();
let last = bytes.last_mut().unwrap();
*last = if *last == b'0' { b'1' } else { b'0' };
let corrupted = String::from_utf8(bytes).unwrap();
assert_eq!(
decode_prefixed(Tag::Signature, &corrupted).unwrap_err(),
Error::InvalidChecksum
);
}
#[test]
fn rejects_corrupted_body() {
let s = encode_prefixed(Tag::PublicKey, &[4u8; 32]);
let mut parts: Vec<String> = s.split('_').map(|p| p.to_owned()).collect();
let body = &mut parts[1];
let first = body.remove(0);
body.insert(0, if first == 'a' { 'b' } else { 'a' });
let corrupted = parts.join("_");
assert_eq!(
decode_prefixed(Tag::PublicKey, &corrupted).unwrap_err(),
Error::InvalidChecksum
);
}
#[test]
fn rejects_bare_hex_without_leaking_it() {
let secret_like = "ab".repeat(32);
let err = decode_prefixed(Tag::SecretKey, &secret_like).unwrap_err();
match err {
Error::InvalidPrefix { got, .. } => assert!(got.is_empty()),
other => panic!("expected InvalidPrefix, got {other:?}"),
}
}
#[test]
fn rejects_non_hex_parts() {
assert_eq!(
decode_prefixed(Tag::PublicKey, "pk_zzzz_d4e9a1b7").unwrap_err(),
Error::InvalidHex
);
}
}