use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::env;
use subtle::ConstantTimeEq;
const NOOP_KEY: [u8; 32] = [0u8; 32];
const NOOP_SIG: [u8; 32] = [0u8; 32];
type HmacSha256 = Hmac<Sha256>;
#[cfg(test)]
pub(crate) static SIGNING_KEY_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn sign_snapshot(json_bytes: &[u8], key: &[u8; 32]) -> [u8; 32] {
if key == &NOOP_KEY {
return NOOP_SIG;
}
let mut mac = HmacSha256::new_from_slice(key)
.expect("HMAC accepts any key length; passing &[u8; 32] cannot fail");
mac.update(json_bytes);
let result = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
pub fn verify_snapshot(json_bytes: &[u8], signature: &[u8; 32], key: &[u8; 32]) -> bool {
let expected = sign_snapshot(json_bytes, key);
bool::from(expected.ct_eq(signature))
}
pub fn load_signing_key_from_env() -> Result<Option<[u8; 32]>, SnapshotKeyError> {
let raw = match env::var("DJOGI_SNAPSHOT_SIGNING_KEY") {
Ok(s) => s,
Err(env::VarError::NotPresent) => return Ok(None),
Err(env::VarError::NotUnicode(_)) => return Err(SnapshotKeyError::NonUnicodeEnvVar),
};
let bytes = raw.as_bytes();
if bytes.len() != 64 {
return Err(SnapshotKeyError::InvalidLength {
actual: bytes.len(),
});
}
let mut out = [0u8; 32];
let mut i = 0;
while i < 32 {
let hi = decode_hex_nibble(bytes[i * 2], i * 2)?;
let lo = decode_hex_nibble(bytes[i * 2 + 1], i * 2 + 1)?;
out[i] = (hi << 4) | lo;
i += 1;
}
Ok(Some(out))
}
fn decode_hex_nibble(byte: u8, idx: usize) -> Result<u8, SnapshotKeyError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(SnapshotKeyError::InvalidHexByte { idx, byte }),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapshotKeyError {
InvalidLength { actual: usize },
InvalidHexByte { idx: usize, byte: u8 },
NonUnicodeEnvVar,
}
impl std::fmt::Display for SnapshotKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SnapshotKeyError::InvalidLength { actual } => write!(
f,
"DJOGI_SNAPSHOT_SIGNING_KEY must be exactly 64 hex characters (got {actual})",
),
SnapshotKeyError::InvalidHexByte { idx, byte } => write!(
f,
"DJOGI_SNAPSHOT_SIGNING_KEY contains a non-hex byte at index {idx} (byte 0x{byte:02x})",
),
SnapshotKeyError::NonUnicodeEnvVar => write!(
f,
"DJOGI_SNAPSHOT_SIGNING_KEY is set but contains non-UTF-8 bytes; \
fix the value or unset the variable to disable signing",
),
}
}
}
impl std::error::Error for SnapshotKeyError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_known_input_known_key() {
let mut key = [0u8; 32];
for slot in key.iter_mut().take(20) {
*slot = 0x0b;
}
let data = b"Hi There";
let expected: [u8; 32] = [
0xb0, 0x34, 0x4c, 0x61, 0xd8, 0xdb, 0x38, 0x53, 0x5c, 0xa8, 0xaf, 0xce, 0xaf, 0x0b,
0xf1, 0x2b, 0x88, 0x1d, 0xc2, 0x00, 0xc9, 0x83, 0x3d, 0xa7, 0x26, 0xe9, 0x37, 0x6c,
0x2e, 0x32, 0xcf, 0xf7,
];
let actual = sign_snapshot(data, &key);
assert_eq!(
actual, expected,
"HMAC-SHA256 output drifted from pinned vector"
);
}
#[test]
fn verify_round_trip_succeeds() {
let key = [1u8; 32];
let payload = b"{\"models\":[]}";
let sig = sign_snapshot(payload, &key);
assert!(
verify_snapshot(payload, &sig, &key),
"freshly-signed payload must verify under the same key",
);
}
#[test]
fn verify_rejects_tampered_payload() {
let key = [1u8; 32];
let payload: &[u8] = b"{\"models\":[]}";
let sig = sign_snapshot(payload, &key);
let mut tampered = payload.to_vec();
tampered[0] ^= 0x01; assert!(
!verify_snapshot(&tampered, &sig, &key),
"tampered payload must not verify against the original signature",
);
}
#[test]
fn verify_rejects_tampered_signature() {
let key = [1u8; 32];
let payload = b"{\"models\":[]}";
let mut sig = sign_snapshot(payload, &key);
sig[0] ^= 0x01; assert!(
!verify_snapshot(payload, &sig, &key),
"tampered signature must not verify",
);
}
#[test]
fn verify_rejects_wrong_key() {
let signing_key = [1u8; 32];
let verifying_key = [2u8; 32];
let payload = b"{\"models\":[]}";
let sig = sign_snapshot(payload, &signing_key);
assert!(
!verify_snapshot(payload, &sig, &verifying_key),
"signature must not verify under a different key",
);
}
#[test]
fn noop_key_signs_to_zero() {
let payload = b"{\"models\":[]}";
let sig = sign_snapshot(payload, &[0u8; 32]);
assert_eq!(
sig, [0u8; 32],
"no-op key must short-circuit to zero signature",
);
}
#[test]
fn noop_key_zero_sig_verifies() {
let payload = b"{\"models\":[]}";
assert!(
verify_snapshot(payload, &[0u8; 32], &[0u8; 32]),
"(zero-key, zero-sig) must verify cleanly — round-trip of the no-op path",
);
}
#[test]
fn noop_key_nonzero_sig_does_not_verify() {
let payload = b"{\"models\":[]}";
let forged_sig = [1u8; 32];
assert!(
!verify_snapshot(payload, &forged_sig, &[0u8; 32]),
"non-zero forged signature must not bypass the no-op path",
);
}
#[test]
fn load_key_from_env_unset() {
let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
unsafe {
env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
}
assert_eq!(load_signing_key_from_env(), Ok(None));
}
#[test]
fn load_key_from_env_valid_hex() {
let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
let hex = "00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff";
unsafe {
env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
}
let result = load_signing_key_from_env();
unsafe {
env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
}
let expected: [u8; 32] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff,
];
assert_eq!(result, Ok(Some(expected)));
}
#[test]
fn load_key_from_env_short() {
let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
let hex = "00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeef";
unsafe {
env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
}
let result = load_signing_key_from_env();
unsafe {
env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
}
assert_eq!(result, Err(SnapshotKeyError::InvalidLength { actual: 63 }),);
}
#[test]
fn load_key_from_env_non_hex() {
let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
let hex = "g0112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff";
unsafe {
env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
}
let result = load_signing_key_from_env();
unsafe {
env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
}
assert_eq!(
result,
Err(SnapshotKeyError::InvalidHexByte { idx: 0, byte: b'g' }),
);
}
#[cfg(unix)]
#[test]
fn load_key_from_env_non_unicode_returns_error() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
unsafe {
env::set_var(
"DJOGI_SNAPSHOT_SIGNING_KEY",
OsString::from_vec(vec![0xFF, 0xFE, 0xFD]),
);
}
let result = load_signing_key_from_env();
unsafe {
env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
}
assert_eq!(result, Err(SnapshotKeyError::NonUnicodeEnvVar));
}
}