use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedExport {
pub schema: String,
pub payload: String,
pub sha256: String,
#[serde(default)]
pub hmac: Option<String>,
}
pub const SCHEMA: &str = "axiom_signed_export_v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProvenanceError {
HashMismatch,
SignatureMissing,
SignatureInvalid,
NoKeyToVerify,
BadSchema,
}
impl std::fmt::Display for ProvenanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ProvenanceError::HashMismatch => "payload hash mismatch (tampered)",
ProvenanceError::SignatureMissing => "fleet key configured but export is unsigned",
ProvenanceError::SignatureInvalid => "HMAC signature does not verify",
ProvenanceError::NoKeyToVerify => "export is signed but no fleet key is configured",
ProvenanceError::BadSchema => "unrecognised export schema",
};
f.write_str(s)
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
pub fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
const BLOCK: usize = 64;
let mut block = [0u8; BLOCK];
if key.len() > BLOCK {
block[..32].copy_from_slice(&Sha256::digest(key));
} else {
block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= block[i];
opad[i] ^= block[i];
}
let inner = Sha256::new().chain_update(ipad).chain_update(msg).finalize();
let outer = Sha256::new().chain_update(opad).chain_update(inner).finalize();
let mut out = String::with_capacity(64);
for b in outer {
out.push_str(&format!("{b:02x}"));
}
out
}
fn ct_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
pub fn sign_export(payload: &str, fleet_key: Option<&[u8]>) -> SignedExport {
let sha256 = sha256_hex(payload.as_bytes());
let hmac = fleet_key.map(|k| hmac_sha256_hex(k, sha256.as_bytes()));
SignedExport {
schema: SCHEMA.to_string(),
payload: payload.to_string(),
sha256,
hmac,
}
}
pub fn verify_export<'a>(
export: &'a SignedExport,
fleet_key: Option<&[u8]>,
) -> Result<&'a str, ProvenanceError> {
if export.schema != SCHEMA {
return Err(ProvenanceError::BadSchema);
}
if !ct_eq(&sha256_hex(export.payload.as_bytes()), &export.sha256) {
return Err(ProvenanceError::HashMismatch);
}
match (fleet_key, export.hmac.as_deref()) {
(Some(key), Some(mac)) => {
let expected = hmac_sha256_hex(key, export.sha256.as_bytes());
if !ct_eq(&expected, mac) {
return Err(ProvenanceError::SignatureInvalid);
}
}
(Some(_), None) => return Err(ProvenanceError::SignatureMissing),
(None, Some(_)) => return Err(ProvenanceError::NoKeyToVerify),
(None, None) => {}
}
Ok(&export.payload)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_sha256_not_truncated() {
assert_eq!(sha256_hex(b"").len(), 64, "full 256-bit hex, not Chimera's 32");
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hmac_matches_rfc_test_vector() {
let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
assert_eq!(
mac,
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn wrong_schema_is_rejected() {
let mut e = sign_export("payload", None);
e.schema = "not_axiom".into();
assert_eq!(verify_export(&e, None), Err(ProvenanceError::BadSchema));
}
#[test]
fn empty_payload_roundtrips() {
let e = sign_export("", None);
assert_eq!(verify_export(&e, None).unwrap(), "");
}
#[test]
fn unsigned_roundtrip_verifies() {
let e = sign_export("hello memory", None);
assert_eq!(verify_export(&e, None).unwrap(), "hello memory");
}
#[test]
fn tampered_payload_is_rejected() {
let mut e = sign_export("original", None);
e.payload = "tampered".to_string();
assert_eq!(verify_export(&e, None), Err(ProvenanceError::HashMismatch));
}
#[test]
fn signed_roundtrip_verifies_with_key() {
let key = b"fleet-secret";
let e = sign_export("payload", Some(key));
assert_eq!(verify_export(&e, Some(key)).unwrap(), "payload");
}
#[test]
fn wrong_key_is_rejected() {
let e = sign_export("payload", Some(b"right-key"));
assert_eq!(
verify_export(&e, Some(b"wrong-key")),
Err(ProvenanceError::SignatureInvalid)
);
}
#[test]
fn signed_export_rejected_when_node_has_no_key() {
let e = sign_export("payload", Some(b"k"));
assert_eq!(verify_export(&e, None), Err(ProvenanceError::NoKeyToVerify));
}
#[test]
fn unsigned_export_rejected_when_key_required() {
let e = sign_export("payload", None);
assert_eq!(
verify_export(&e, Some(b"k")),
Err(ProvenanceError::SignatureMissing)
);
}
}