Skip to main content

tatara_process/
attestation.rs

1//! Three-pillar BLAKE3 attestation — wire-compatible with
2//! `tatara_engine::domain::attestation::ConvergenceAttestation`.
3
4use chrono::{DateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8/// Attestation written to `Process.status.attestation` after each convergence cycle.
9///
10/// Composition:
11/// ```text
12/// composed_root = BLAKE3(
13///     "tatara-process/v1alpha1\n"
14///     ++ artifact_hash ++ "\n"
15///     ++ control_hash.unwrap_or("") ++ "\n"
16///     ++ intent_hash ++ "\n"
17///     ++ previous_root.unwrap_or("")
18/// )
19/// ```
20#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "camelCase")]
22pub struct ProcessAttestation {
23    /// `BLAKE3(rendered resources ++ their applied-status digests)`.
24    pub artifact_hash: String,
25    /// `BLAKE3(compliance-verification proof)` — absent iff no compliance bindings.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub control_hash: Option<String>,
28    /// `BLAKE3(canonical-spec ++ nix-store-path? ++ lisp-AST?)`.
29    pub intent_hash: String,
30    /// `BLAKE3` of the three pillars + previous root.
31    pub composed_root: String,
32    /// Monotonic generation counter — starts at 0, increments each cycle.
33    pub generation: u64,
34    /// The prior `composed_root` in the chain. `None` for generation 0.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub previous_root: Option<String>,
37    /// When the attestation was computed.
38    pub attested_at: DateTime<Utc>,
39}
40
41const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
42
43impl ProcessAttestation {
44    /// Compose an attestation from the three pillars + chain context.
45    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    /// Convenience for the initial attestation (generation 0, no previous root).
70    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    /// Convenience for chaining: `self.next(new_pillars)` yields the next attestation.
79    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    /// Verify that `composed_root` is consistent with the pillars + `previous_root`.
95    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}