tatara_process/
attestation.rs1use chrono::{DateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "camelCase")]
22pub struct ProcessAttestation {
23 pub artifact_hash: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub control_hash: Option<String>,
28 pub intent_hash: String,
30 pub composed_root: String,
32 pub generation: u64,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub previous_root: Option<String>,
37 pub attested_at: DateTime<Utc>,
39}
40
41const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
42
43impl ProcessAttestation {
44 pub fn compose(
46 artifact_hash: String,
47 control_hash: Option<String>,
48 intent_hash: String,
49 previous_root: Option<String>,
50 generation: u64,
51 ) -> Self {
52 let composed_root = Self::composed_hex(
53 &artifact_hash,
54 control_hash.as_deref(),
55 &intent_hash,
56 previous_root.as_deref(),
57 );
58 Self {
59 artifact_hash,
60 control_hash,
61 intent_hash,
62 composed_root,
63 generation,
64 previous_root,
65 attested_at: Utc::now(),
66 }
67 }
68
69 pub fn initial(
71 artifact_hash: String,
72 control_hash: Option<String>,
73 intent_hash: String,
74 ) -> Self {
75 Self::compose(artifact_hash, control_hash, intent_hash, None, 0)
76 }
77
78 pub fn next(
80 &self,
81 artifact_hash: String,
82 control_hash: Option<String>,
83 intent_hash: String,
84 ) -> Self {
85 Self::compose(
86 artifact_hash,
87 control_hash,
88 intent_hash,
89 Some(self.composed_root.clone()),
90 self.generation + 1,
91 )
92 }
93
94 pub fn verify(&self) -> bool {
96 let recomputed = Self::composed_hex(
97 &self.artifact_hash,
98 self.control_hash.as_deref(),
99 &self.intent_hash,
100 self.previous_root.as_deref(),
101 );
102 constant_time_eq(recomputed.as_bytes(), self.composed_root.as_bytes())
103 }
104
105 fn composed_hex(
106 artifact: &str,
107 control: Option<&str>,
108 intent: &str,
109 previous: Option<&str>,
110 ) -> String {
111 let mut h = blake3::Hasher::new();
112 h.update(DOMAIN_TAG);
113 h.update(artifact.as_bytes());
114 h.update(b"\n");
115 h.update(control.unwrap_or("").as_bytes());
116 h.update(b"\n");
117 h.update(intent.as_bytes());
118 h.update(b"\n");
119 h.update(previous.unwrap_or("").as_bytes());
120 hex::encode(h.finalize().as_bytes())
121 }
122}
123
124fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
125 if a.len() != b.len() {
126 return false;
127 }
128 let mut acc: u8 = 0;
129 for (x, y) in a.iter().zip(b.iter()) {
130 acc |= x ^ y;
131 }
132 acc == 0
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn initial_has_generation_zero() {
141 let a = ProcessAttestation::initial("a".into(), None, "i".into());
142 assert_eq!(a.generation, 0);
143 assert!(a.previous_root.is_none());
144 assert!(a.verify());
145 }
146
147 #[test]
148 fn chain_extends_previous_root() {
149 let a0 = ProcessAttestation::initial("a0".into(), Some("c0".into()), "i0".into());
150 let a1 = a0.next("a1".into(), Some("c1".into()), "i1".into());
151 assert_eq!(a1.generation, 1);
152 assert_eq!(a1.previous_root.as_deref(), Some(a0.composed_root.as_str()));
153 assert_ne!(a0.composed_root, a1.composed_root);
154 assert!(a1.verify());
155 }
156
157 #[test]
158 fn verify_detects_tamper() {
159 let mut a = ProcessAttestation::initial("a".into(), None, "i".into());
160 assert!(a.verify());
161 a.artifact_hash = "tampered".into();
162 assert!(!a.verify());
163 }
164
165 #[test]
166 fn control_hash_affects_root() {
167 let a = ProcessAttestation::initial("x".into(), None, "y".into());
168 let b = ProcessAttestation::initial("x".into(), Some("c".into()), "y".into());
169 assert_ne!(a.composed_root, b.composed_root);
170 }
171}