Skip to main content

cpop_protocol/war/
ear.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! EAR (Entity Attestation Result) types per draft-ietf-rats-ear.
4//!
5//! Maps WritersLogic's proof-of-process appraisal onto standard RATS EAR
6//! structures with AR4SI trust vectors. Private-use keys 70001-70009
7//! carry WritersLogic-specific claims.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::rfc::wire_types::attestation::{
14    AbsenceClaim, EntropyReport, ForensicSummary, ForgeryCostEstimate,
15};
16
17/// EAT profile URI per draft-condrey-rats-pop-protocol.
18pub const POP_EAR_PROFILE: &str = "urn:ietf:params:rats:eat:profile:pop:1.0";
19
20pub const CWT_KEY_IAT: i64 = 6;
21pub const CWT_KEY_EAT_PROFILE: i64 = 265;
22pub const CWT_KEY_SUBMODS: i64 = 266;
23pub const EAR_KEY_STATUS: i64 = 1000;
24pub const EAR_KEY_TRUST_VECTOR: i64 = 1001;
25pub const EAR_KEY_POLICY_ID: i64 = 1003;
26pub const EAR_KEY_VERIFIER_ID: i64 = 1004;
27
28pub const POP_KEY_SEAL: i64 = 70001;
29pub const POP_KEY_EVIDENCE_REF: i64 = 70002;
30pub const POP_KEY_ENTROPY: i64 = 70003;
31pub const POP_KEY_FORGERY_COST: i64 = 70004;
32pub const POP_KEY_FORENSIC: i64 = 70005;
33pub const POP_KEY_CHAIN_LENGTH: i64 = 70006;
34pub const POP_KEY_CHAIN_DURATION: i64 = 70007;
35pub const POP_KEY_ABSENCE: i64 = 70008;
36pub const POP_KEY_WARNINGS: i64 = 70009;
37
38/// AR4SI appraisal status per draft-ietf-rats-ar4si.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[repr(i8)]
41pub enum Ar4siStatus {
42    /// No status determined
43    None = 0,
44    /// Evidence affirms trustworthiness
45    Affirming = 2,
46    /// Evidence contains warnings
47    Warning = 32,
48    /// Evidence contradicts trustworthiness
49    Contraindicated = 96,
50}
51
52impl Ar4siStatus {
53    /// Convert a raw i8 value to the corresponding status variant.
54    pub fn from_i8(v: i8) -> Self {
55        match v {
56            2 => Self::Affirming,
57            32 => Self::Warning,
58            96 => Self::Contraindicated,
59            _ => Self::None,
60        }
61    }
62
63    /// Return the lowercase string name of this status.
64    pub fn as_str(&self) -> &'static str {
65        match self {
66            Self::None => "none",
67            Self::Affirming => "affirming",
68            Self::Warning => "warning",
69            Self::Contraindicated => "contraindicated",
70        }
71    }
72}
73
74/// AR4SI trustworthiness vector — maps from WritersLogic evidence components.
75///
76/// Each component is a tier value from -128 to 127:
77/// - 2 = affirming, 32 = warning, 96 = contraindicated, 0 = none
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub struct TrustworthinessVector {
80    /// Hardware attestation tier (TPM/Secure Enclave)
81    #[serde(rename = "0")]
82    pub instance_identity: i8,
83    /// Software configuration integrity
84    #[serde(rename = "1")]
85    pub configuration: i8,
86    /// Binary attestation (TPM quote)
87    #[serde(rename = "2")]
88    pub executables: i8,
89    /// Document hash chain integrity (H1/H2/H3)
90    #[serde(rename = "3")]
91    pub file_system: i8,
92    /// TPM/Secure Enclave tier
93    #[serde(rename = "4")]
94    pub hardware: i8,
95    /// VDF proof strength
96    #[serde(rename = "5")]
97    pub runtime_opaque: i8,
98    /// Key hierarchy integrity
99    #[serde(rename = "6")]
100    pub storage_opaque: i8,
101    /// Behavioral entropy + jitter
102    #[serde(rename = "7")]
103    pub sourced_data: i8,
104}
105
106impl TrustworthinessVector {
107    /// Returns the minimum component value (weakest link).
108    pub fn min_component(&self) -> i8 {
109        [
110            self.instance_identity,
111            self.configuration,
112            self.executables,
113            self.file_system,
114            self.hardware,
115            self.runtime_opaque,
116            self.storage_opaque,
117            self.sourced_data,
118        ]
119        .into_iter()
120        .min()
121        .unwrap_or(0)
122    }
123
124    /// Derive overall AR4SI status from the weakest component.
125    pub fn overall_status(&self) -> Ar4siStatus {
126        let min = self.min_component();
127        if min >= Ar4siStatus::Contraindicated as i8 {
128            Ar4siStatus::Contraindicated
129        } else if min >= Ar4siStatus::Warning as i8 {
130            Ar4siStatus::Warning
131        } else if min >= Ar4siStatus::Affirming as i8 {
132            Ar4siStatus::Affirming
133        } else {
134            Ar4siStatus::None
135        }
136    }
137
138    /// Format as compact header string: "II=2 CO=2 EX=0 FS=2 HW=2 RO=2 SO=2 SD=2"
139    pub fn header_string(&self) -> String {
140        format!(
141            "II={} CO={} EX={} FS={} HW={} RO={} SO={} SD={}",
142            self.instance_identity,
143            self.configuration,
144            self.executables,
145            self.file_system,
146            self.hardware,
147            self.runtime_opaque,
148            self.storage_opaque,
149            self.sourced_data,
150        )
151    }
152
153    /// Parse from header string format.
154    pub fn parse_header(s: &str) -> Option<Self> {
155        let mut vals = [0i8; 8];
156        let labels = ["II=", "CO=", "EX=", "FS=", "HW=", "RO=", "SO=", "SD="];
157        for (i, label) in labels.iter().enumerate() {
158            let part = s.split_whitespace().find(|p| p.starts_with(label))?;
159            vals[i] = part[label.len()..].parse().ok()?;
160        }
161        Some(Self {
162            instance_identity: vals[0],
163            configuration: vals[1],
164            executables: vals[2],
165            file_system: vals[3],
166            hardware: vals[4],
167            runtime_opaque: vals[5],
168            storage_opaque: vals[6],
169            sourced_data: vals[7],
170        })
171    }
172}
173
174/// Verifier identity per EAR.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct VerifierId {
177    /// Build identifier string (e.g. "cpop-engine/0.3.6")
178    pub build: String,
179    /// Developer/organization name
180    pub developer: String,
181}
182
183impl Default for VerifierId {
184    fn default() -> Self {
185        Self {
186            build: format!("cpop-engine/{}", env!("CARGO_PKG_VERSION")),
187            developer: "writerslogic".to_string(),
188        }
189    }
190}
191
192/// Seal claims extracted from a WAR block for embedding in EAR.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct SealClaims {
195    /// H1: document/checkpoint/declaration binding hash
196    #[serde(with = "hex_bytes_32")]
197    pub h1: [u8; 32],
198    /// H2: jitter/identity binding hash
199    #[serde(with = "hex_bytes_32")]
200    pub h2: [u8; 32],
201    /// H3: VDF/document binding hash (signed)
202    #[serde(with = "hex_bytes_32")]
203    pub h3: [u8; 32],
204    /// Ed25519 signature over H3
205    #[serde(with = "hex_bytes_64")]
206    pub signature: [u8; 64],
207    /// Author's Ed25519 public key
208    #[serde(with = "hex_bytes_32")]
209    pub public_key: [u8; 32],
210}
211
212/// Single submodule appraisal within an EAR token.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct EarAppraisal {
215    /// AR4SI status
216    #[serde(rename = "1000")]
217    pub ear_status: Ar4siStatus,
218
219    /// Trustworthiness vector
220    #[serde(rename = "1001", default, skip_serializing_if = "Option::is_none")]
221    pub ear_trustworthiness_vector: Option<TrustworthinessVector>,
222
223    /// Appraisal policy ID
224    #[serde(rename = "1003", default, skip_serializing_if = "Option::is_none")]
225    pub ear_appraisal_policy_id: Option<String>,
226
227    /// WAR seal claims
228    #[serde(rename = "70001", default, skip_serializing_if = "Option::is_none")]
229    pub pop_seal: Option<SealClaims>,
230
231    /// SHA-256 of evidence packet
232    #[serde(rename = "70002", default, skip_serializing_if = "Option::is_none")]
233    pub pop_evidence_ref: Option<Vec<u8>>,
234
235    /// Entropy assessment report
236    #[serde(rename = "70003", default, skip_serializing_if = "Option::is_none")]
237    pub pop_entropy_report: Option<EntropyReport>,
238
239    /// Forgery cost estimate
240    #[serde(rename = "70004", default, skip_serializing_if = "Option::is_none")]
241    pub pop_forgery_cost: Option<ForgeryCostEstimate>,
242
243    /// Forensic assessment summary
244    #[serde(rename = "70005", default, skip_serializing_if = "Option::is_none")]
245    pub pop_forensic_summary: Option<ForensicSummary>,
246
247    /// Checkpoint chain length
248    #[serde(rename = "70006", default, skip_serializing_if = "Option::is_none")]
249    pub pop_chain_length: Option<u64>,
250
251    /// Chain duration (seconds)
252    #[serde(rename = "70007", default, skip_serializing_if = "Option::is_none")]
253    pub pop_chain_duration: Option<u64>,
254
255    /// Absence claims
256    #[serde(rename = "70008", default, skip_serializing_if = "Option::is_none")]
257    pub pop_absence_claims: Option<Vec<AbsenceClaim>>,
258
259    /// Warning messages
260    #[serde(rename = "70009", default, skip_serializing_if = "Option::is_none")]
261    pub pop_warnings: Option<Vec<String>>,
262}
263
264/// EAR token per draft-ietf-rats-ear, carrying one or more appraisals.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct EarToken {
267    /// EAT profile URI (CWT key 265)
268    #[serde(rename = "265")]
269    pub eat_profile: String,
270
271    /// Issued-at timestamp, epoch seconds (CWT key 6)
272    #[serde(rename = "6")]
273    pub iat: i64,
274
275    /// Verifier identity (key 1004)
276    #[serde(rename = "1004")]
277    pub ear_verifier_id: VerifierId,
278
279    /// Submodule appraisals keyed by name (key 266)
280    #[serde(rename = "266")]
281    pub submods: BTreeMap<String, EarAppraisal>,
282}
283
284impl EarToken {
285    /// Overall status: the worst (lowest) status across all submodule appraisals.
286    pub fn overall_status(&self) -> Ar4siStatus {
287        self.submods
288            .values()
289            .map(|a| a.ear_status as i8)
290            .min()
291            .map(Ar4siStatus::from_i8)
292            .unwrap_or(Ar4siStatus::None)
293    }
294
295    pub fn pop_appraisal(&self) -> Option<&EarAppraisal> {
296        self.submods.get("pop")
297    }
298}
299
300mod hex_bytes_32 {
301    use serde::{self, Deserialize, Deserializer, Serializer};
302
303    pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
304    where
305        S: Serializer,
306    {
307        serializer.serialize_str(&hex::encode(bytes))
308    }
309
310    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
311    where
312        D: Deserializer<'de>,
313    {
314        let s = String::deserialize(deserializer)?;
315        let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
316        if bytes.len() != 32 {
317            return Err(serde::de::Error::custom("expected 32 bytes"));
318        }
319        let mut arr = [0u8; 32];
320        arr.copy_from_slice(&bytes);
321        Ok(arr)
322    }
323}
324
325mod hex_bytes_64 {
326    use serde::{self, Deserialize, Deserializer, Serializer};
327
328    pub fn serialize<S>(bytes: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
329    where
330        S: Serializer,
331    {
332        serializer.serialize_str(&hex::encode(bytes))
333    }
334
335    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
336    where
337        D: Deserializer<'de>,
338    {
339        let s = String::deserialize(deserializer)?;
340        let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
341        if bytes.len() != 64 {
342            return Err(serde::de::Error::custom("expected 64 bytes"));
343        }
344        let mut arr = [0u8; 64];
345        arr.copy_from_slice(&bytes);
346        Ok(arr)
347    }
348}