use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProcessAttestation {
pub artifact_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_hash: Option<String>,
pub intent_hash: String,
pub composed_root: String,
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_root: Option<String>,
pub attested_at: DateTime<Utc>,
}
const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
impl ProcessAttestation {
pub fn compose(
artifact_hash: String,
control_hash: Option<String>,
intent_hash: String,
previous_root: Option<String>,
generation: u64,
) -> Self {
let composed_root = Self::composed_hex(
&artifact_hash,
control_hash.as_deref(),
&intent_hash,
previous_root.as_deref(),
);
Self {
artifact_hash,
control_hash,
intent_hash,
composed_root,
generation,
previous_root,
attested_at: Utc::now(),
}
}
pub fn initial(
artifact_hash: String,
control_hash: Option<String>,
intent_hash: String,
) -> Self {
Self::compose(artifact_hash, control_hash, intent_hash, None, 0)
}
pub fn next(
&self,
artifact_hash: String,
control_hash: Option<String>,
intent_hash: String,
) -> Self {
Self::compose(
artifact_hash,
control_hash,
intent_hash,
Some(self.composed_root.clone()),
self.generation + 1,
)
}
pub fn verify(&self) -> bool {
let recomputed = Self::composed_hex(
&self.artifact_hash,
self.control_hash.as_deref(),
&self.intent_hash,
self.previous_root.as_deref(),
);
constant_time_eq(recomputed.as_bytes(), self.composed_root.as_bytes())
}
fn composed_hex(
artifact: &str,
control: Option<&str>,
intent: &str,
previous: Option<&str>,
) -> String {
let mut h = blake3::Hasher::new();
h.update(DOMAIN_TAG);
h.update(artifact.as_bytes());
h.update(b"\n");
h.update(control.unwrap_or("").as_bytes());
h.update(b"\n");
h.update(intent.as_bytes());
h.update(b"\n");
h.update(previous.unwrap_or("").as_bytes());
hex::encode(h.finalize().as_bytes())
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut acc: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_has_generation_zero() {
let a = ProcessAttestation::initial("a".into(), None, "i".into());
assert_eq!(a.generation, 0);
assert!(a.previous_root.is_none());
assert!(a.verify());
}
#[test]
fn chain_extends_previous_root() {
let a0 = ProcessAttestation::initial("a0".into(), Some("c0".into()), "i0".into());
let a1 = a0.next("a1".into(), Some("c1".into()), "i1".into());
assert_eq!(a1.generation, 1);
assert_eq!(a1.previous_root.as_deref(), Some(a0.composed_root.as_str()));
assert_ne!(a0.composed_root, a1.composed_root);
assert!(a1.verify());
}
#[test]
fn verify_detects_tamper() {
let mut a = ProcessAttestation::initial("a".into(), None, "i".into());
assert!(a.verify());
a.artifact_hash = "tampered".into();
assert!(!a.verify());
}
#[test]
fn control_hash_affects_root() {
let a = ProcessAttestation::initial("x".into(), None, "y".into());
let b = ProcessAttestation::initial("x".into(), Some("c".into()), "y".into());
assert_ne!(a.composed_root, b.composed_root);
}
}