diff --git a/jouleclaw/jouleclaw-rs/Cargo.lock b/jouleclaw/jouleclaw-rs/Cargo.lock index a56e3783d..70b6b9b06 100644 --- a/jouleclaw/jouleclaw-rs/Cargo.lock +++ b/jouleclaw/jouleclaw-rs/Cargo.lock @@ -3280,9 +3280,11 @@ version = "0.1.0" dependencies = [ "blake3", "chrono", + "ed25519-dalek", "jouleclaw-core", "jouleclaw-energy", "jouleclaw-prov", + "jouleclaw-transparency-log", "serde", "serde_json", "thiserror 2.0.20", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml index 7810d1ada..71fc842e6 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml @@ -6,14 +6,18 @@ rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Regulatory-compliance wedge for JouleClaw: ISO/IEC 21031 SCI calculator, signed AI-service energy passport (BLAKE3-hashed for tamper evidence), and CSRD Scope-3 / EU AI Act / EED / ESPR report generators. Built on jouleclaw-energy + jouleclaw-prov." +description = "Regulatory-compliance wedge for JouleClaw: ISO/IEC 21031 SCI calculator, signed AI-service energy passport (BLAKE3-hashed for tamper evidence), and CSRD Scope-3 / EU AI Act / EED / ESPR report generators. Built on jouleclaw-energy + jouleclaw-prov. Also an EU Cyber Resilience Act Article 14 filing chain: hash-linked, Merkle-logged, with each stage's deadline." [dependencies] jouleclaw-core = { path = "../jouleclaw-core" } jouleclaw-energy = { path = "../jouleclaw-energy" } jouleclaw-prov = { path = "../jouleclaw-prov" } +jouleclaw-transparency-log = { path = "../jouleclaw-transparency-log" } serde = { workspace = true } serde_json = { workspace = true } thiserror.workspace = true chrono = { workspace = true } blake3 = { workspace = true } + +[dev-dependencies] +ed25519-dalek = { workspace = true } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs new file mode 100644 index 000000000..6628243ab --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs @@ -0,0 +1,1404 @@ +//! EU Cyber Resilience Act, Article 14 reporting as a tamper-evident filing chain. +//! +//! Regulation (EU) 2024/2847, Art. 14 has a manufacturer report two kinds of +//! case, each in three stages. This module records one case's filings as a +//! hash-linked chain whose every entry is also a leaf in an append-only Merkle +//! log, and reads the case's deadlines off that chain. +//! +//! ## The obligations encoded here +//! +//! | Stage | Actively exploited vulnerability, Art. 14(2) | Severe incident, Art. 14(4) | +//! |---|---|---| +//! | (a) early warning | within 24 hours of becoming aware of it | within 24 hours of becoming aware of it | +//! | (b) notification | within 72 hours of becoming aware of it | within 72 hours of becoming aware of it | +//! | (c) final report | no later than 14 days after a corrective or mitigating measure is available | within one month after the submission of the incident notification under point (b) | +//! +//! The final report's clock starts at a different event for each subject. For +//! an actively exploited vulnerability (Regulation (EU) 2024/2847, Art. +//! 14(2)(c)) it starts when a corrective or mitigating measure is available, +//! not at awareness. For a severe incident (Regulation (EU) 2024/2847, Art. +//! 14(4)(c)) it starts when the incident notification is filed, and the period +//! is a calendar month: this module adds one month to that filing time, and +//! where the later month has no such day the due time falls on its last day +//! (a notification filed on 31 January has its final report due on the last +//! day of February, at the same time of day). +//! +//! Article 14 applies from 11 September 2026 (Regulation (EU) 2024/2847, Art. +//! 71(2)); see [`article_14_applies_from`]. +//! +//! ## The chain +//! +//! A [`FilingChain`] holds one case. Each [`Filing`] has a content id, the +//! BLAKE3 hash of its canonical JSON bytes, and names in `supersedes` the +//! content id of the filing before it, so changing any filing breaks the link +//! from the filing after it. Each accepted filing's content hash is appended as +//! a leaf to the chain's Merkle log, and [`FilingChain::file`] returns a +//! [`FiledReceipt`]: the leaf index, the content id, an inclusion proof and the +//! log root after the append. A filing altered after it was filed has a +//! different content id and no longer verifies against its receipt. +//! [`FilingChain::tree_head`] gives the log's head for the caller to sign, and +//! [`FilingChain::consistency_proof`] shows that an earlier root is a prefix of +//! a later one. +//! +//! [`FilingChain::file`] refuses a filing, and leaves the chain as it was, when: +//! +//! - its case id or its subject is not the chain's; +//! - its `supersedes` is not the content id of the chain's current head +//! (`None` only for the first filing); +//! - it is dated earlier than the filing before it; +//! - its stage is behind the furthest stage already filed (a later filing at +//! the same stage is an update, and is accepted); +//! - it states a later awareness time than the chain already records: an +//! awareness clock cannot be moved later after the fact. An *earlier* +//! awareness is accepted, since it is the stricter statement, and every +//! deadline measured from awareness moves earlier with it; +//! - it is dated before the awareness it states, or it names one product +//! twice. +//! +//! ## When a stage counts as filed +//! +//! Points (b) and (c) of both paragraphs begin "unless the relevant +//! information has already been provided"; the early warnings do not. This +//! module reads that as follows: +//! +//! - each stage's content contains the earlier stages': a notification carries +//! everything an early warning does, and a final report everything a +//! notification does. So a stage is satisfied by the first filing at that +//! stage or a later one. The early warning is therefore satisfied by the +//! chain's first filing, whatever its stage, and a final report filed +//! without a separate notification satisfies the notification too (a stage +//! cannot be filed after a later one, so reading it otherwise would leave +//! the notification overdue for good); +//! - a filing can also carry a LATER stage's information early, and says so +//! in `also_provides`: an early warning that already holds the full +//! notification satisfies the notification stage. That is declared, never +//! inferred. +//! +//! [`FilingChain::deadlines`] reports each stage as met, late, pending, +//! overdue, or with its clock not yet started; see [`DeadlineState`]. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Months, TimeDelta, TimeZone, Utc}; +use jouleclaw_transparency_log::{ + ConsistencyProof, InclusionProof, LogError, MerkleTree, TreeHead, +}; +use serde::{Deserialize, Serialize}; + +// ── The periods ───────────────────────────────────────────────────── + +/// Hours after awareness within which the early warning is due +/// (Regulation (EU) 2024/2847, Art. 14(2)(a) and Art. 14(4)(a)). +pub const EARLY_WARNING_WITHIN_HOURS: i64 = 24; + +/// Hours after awareness within which the notification is due +/// (Regulation (EU) 2024/2847, Art. 14(2)(b) and Art. 14(4)(b)). +pub const NOTIFICATION_WITHIN_HOURS: i64 = 72; + +/// Days after a corrective or mitigating measure is available within which +/// the final report on an actively exploited vulnerability is due +/// (Regulation (EU) 2024/2847, Art. 14(2)(c)). +pub const VULNERABILITY_FINAL_REPORT_WITHIN_DAYS: i64 = 14; + +/// Calendar months after the incident notification is filed within which the +/// final report on a severe incident is due +/// (Regulation (EU) 2024/2847, Art. 14(4)(c)). +pub const INCIDENT_FINAL_REPORT_WITHIN_MONTHS: u32 = 1; + +/// The day Article 14 applies from (Regulation (EU) 2024/2847, Art. 71(2)). +/// +/// The regulation names the day, 11 September 2026, and no time zone; this +/// returns 00:00 UTC on that day. The chain does not consult it: how the +/// obligations attach to an awareness that predates it is left to the caller. +pub fn article_14_applies_from() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 11, 0, 0, 0) + .single() + .expect("11 September 2026 00:00 is a single instant in UTC") +} + +// ── What a filing says ───────────────────────────────────────────── + +/// What an Article 14 case reports on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Art14Subject { + /// An actively exploited vulnerability in the product + /// (Regulation (EU) 2024/2847, Art. 14(2)). + ActivelyExploitedVulnerability, + /// A severe incident having an impact on the security of the product + /// (Regulation (EU) 2024/2847, Art. 14(4)). + SevereIncident, +} + +/// The three reporting stages, ordered `EarlyWarning < Notification < FinalReport`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum FilingStage { + /// Point (a): the early warning, due within 24 hours of becoming aware. + EarlyWarning, + /// Point (b): the vulnerability notification or the incident + /// notification, due within 72 hours of becoming aware. + Notification, + /// Point (c): the final report. + FinalReport, +} + +impl FilingStage { + /// Every stage, in order. + pub const ALL: [FilingStage; 3] = [ + FilingStage::EarlyWarning, + FilingStage::Notification, + FilingStage::FinalReport, + ]; +} + +/// What a filing states about one product's exposure to the case. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ProductStatus { + /// The product is affected. + Affected, + /// The product is not affected, for the reason given. + NotAffected { + /// Why the product is not affected. + justification: String, + }, + /// The product was affected and now carries the fix. + Fixed, + /// Whether the product is affected has not yet been determined. + UnderInvestigation, +} + +/// One product named in a filing, and its status as of that filing. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ProductStatement { + /// The product's identifier, as the manufacturer names it. + pub product: String, + /// The product's status as of the filing. + pub status: ProductStatus, +} + +/// One Article 14 filing: an early warning, a notification or a final report. +/// +/// Unknown fields are refused on deserialisation, so the content id always +/// covers everything a filing's JSON carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Filing { + /// The case this filing belongs to. + pub case_id: String, + /// What the case reports on. + pub subject: Art14Subject, + /// The stage this filing is filed as. + pub stage: FilingStage, + /// When the manufacturer became aware of the vulnerability or incident, + /// as this filing states it. + pub became_aware_at: DateTime, + /// When this filing was submitted. + pub filed_at: DateTime, + /// When a corrective or mitigating measure became available, if this + /// filing states one. + pub corrective_measure_available_at: Option>, + /// Each affected, unaffected, fixed or still-investigated product. + pub statements: Vec, + /// Later stages whose information this filing already carries (the + /// "unless the relevant information has already been provided" clause). + /// Earlier stages need no entry: a filing always carries theirs. + pub also_provides: Vec, + /// What this filing reports, in prose. + pub summary: String, + /// Hex content id of the filing before this one in the chain; `None` only + /// for a case's first filing. + pub supersedes: Option, +} + +impl Filing { + /// The bytes the content id is taken over: the filing's JSON + /// serialisation, fields in declaration order, timestamps in one fixed + /// form. + /// + /// Taken from the typed value, not from bytes it was parsed from, so two + /// encodings of the same filing share one content id. + pub fn canonical_bytes(&self) -> Vec { + serde_json::to_vec(self).expect("a filing is plain data and always serialises") + } + + /// BLAKE3 over [`Filing::canonical_bytes`], as 32 raw bytes: the leaf the + /// chain appends to its log. + pub fn content_hash(&self) -> [u8; 32] { + *blake3::hash(&self.canonical_bytes()).as_bytes() + } + + /// BLAKE3 over [`Filing::canonical_bytes`], as lowercase hex: the id the + /// next filing names in `supersedes`. + pub fn content_id(&self) -> String { + blake3::hash(&self.canonical_bytes()).to_hex().to_string() + } + + /// `true` iff this filing carries `stage`'s information: its own stage and + /// every earlier one (each stage's content contains the earlier stages'), + /// and any later stage it lists in `also_provides`. + pub fn provides(&self, stage: FilingStage) -> bool { + self.stage >= stage || self.also_provides.contains(&stage) + } +} + +// ── Errors ───────────────────────────────────────────────────────── + +/// Why a [`FilingChain`] refused a filing. A refused filing leaves the chain +/// unchanged. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ChainError { + /// The filing names another case. + #[error("filing is for case {got:?}, but this chain holds case {expected:?}")] + CaseMismatch { + /// The chain's case id. + expected: String, + /// The case id the filing names. + got: String, + }, + /// The filing reports on another subject than the chain's case. + #[error("filing reports {got:?}, but this chain's case is {expected:?}")] + SubjectMismatch { + /// The chain's subject. + expected: Art14Subject, + /// The subject the filing names. + got: Art14Subject, + }, + /// The filing's `supersedes` is not the content id of the chain's head. + #[error("filing supersedes {got:?}, but the chain's head is {expected:?}")] + SupersedesMismatch { + /// The head's content id (`None` while the chain is empty). + expected: Option, + /// What the filing names. + got: Option, + }, + /// The filing is dated earlier than the filing before it. + #[error("filing is dated {got}, earlier than the previous filing's {previous}")] + FiledAtBackwards { + /// The previous filing's `filed_at`. + previous: DateTime, + /// This filing's `filed_at`. + got: DateTime, + }, + /// The filing's stage is behind the furthest stage already filed. + #[error("filing is at stage {got:?}, behind the {furthest:?} already filed")] + StageRegressed { + /// The furthest stage already filed. + furthest: FilingStage, + /// This filing's stage. + got: FilingStage, + }, + /// The filing states a later awareness time than the chain records. + #[error("filing states awareness at {got}, later than the {current} already on record")] + AwarenessMovedLater { + /// The chain's awareness: the earliest any filing has stated. + current: DateTime, + /// The awareness this filing states. + got: DateTime, + }, + /// The filing is dated before the awareness it states. + #[error("filing is dated {filed_at}, before the awareness it states ({became_aware_at})")] + FiledBeforeAwareness { + /// The awareness the filing states. + became_aware_at: DateTime, + /// The filing's `filed_at`. + filed_at: DateTime, + }, + /// The filing names the same product in more than one statement. + #[error("filing states product {0:?} more than once")] + DuplicateProduct(String), +} + +/// Why a [`FiledReceipt`] did not verify a filing. +#[derive(Debug, thiserror::Error)] +pub enum ReceiptError { + /// The filing's content id is not the one the receipt was issued for. + #[error("filing's content id {filing} is not the receipt's {receipt}")] + ContentIdMismatch { + /// The content id on the receipt. + receipt: String, + /// The content id of the filing offered. + filing: String, + }, + /// The receipt's leaf index and its proof's leaf index differ. + #[error("receipt names leaf {receipt}, but its proof is for leaf {proof}")] + LeafIndexMismatch { + /// The leaf index on the receipt. + receipt: u64, + /// The leaf index in the receipt's inclusion proof. + proof: u64, + }, + /// The inclusion proof did not lead to the receipt's root. + #[error(transparent)] + Inclusion(#[from] LogError), +} + +// ── What filing returns, and what the chain reads back ───────────── + +/// What [`FilingChain::file`] returns for an accepted filing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FiledReceipt { + /// The filing's position in the chain, and its leaf index in the log. + pub leaf_index: u64, + /// The filing's hex content id. + pub content_id: String, + /// Proof that the filing's content hash is the leaf at `leaf_index` of + /// the log whose root is `root`. + pub inclusion_proof: InclusionProof, + /// The log root just after the filing was appended. + pub root: [u8; 32], +} + +impl FiledReceipt { + /// The receipt's root as lowercase hex, the form a [`TreeHead`] carries. + pub fn root_hex(&self) -> String { + blake3::Hash::from(self.root).to_hex().to_string() + } + + /// Check that `filing` is exactly the filing this receipt was issued for, + /// and that it sits at the receipt's leaf under the receipt's root. + pub fn verify(&self, filing: &Filing) -> Result<(), ReceiptError> { + let id = filing.content_id(); + if id != self.content_id { + return Err(ReceiptError::ContentIdMismatch { + receipt: self.content_id.clone(), + filing: id, + }); + } + if self.inclusion_proof.leaf_index != self.leaf_index { + return Err(ReceiptError::LeafIndexMismatch { + receipt: self.leaf_index, + proof: self.inclusion_proof.leaf_index, + }); + } + self.inclusion_proof.verify(&filing.content_hash(), &self.root)?; + Ok(()) + } +} + +/// One change of one product's stated status between two filings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Revision { + /// The product whose status changed. + pub product: String, + /// The status the product had before. + pub from: ProductStatus, + /// The status the product has from this filing on. + pub to: ProductStatus, + /// When the filing that changed it was filed. + pub filed_at: DateTime, + /// The index of that filing in the chain. + pub filing_index: usize, +} + +/// Where one stage's deadline stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DeadlineState { + /// Satisfied by a filing filed at or before the due time. + Met, + /// Satisfied by a filing filed after the due time. + Late, + /// Not yet satisfied, and the due time has not passed. + Pending, + /// Not yet satisfied, and the due time has passed. + Overdue, + /// The due time is not yet known: a vulnerability's final report before + /// any filing states a corrective or mitigating measure, an incident's + /// final report before its notification is filed, or any stage of an + /// empty chain. + ClockNotStarted, +} + +/// One stage's due time, the filing that satisfied it, and where it stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct StageDeadline { + /// The stage. + pub stage: FilingStage, + /// When the stage is due, once its clock has started. + pub due: Option>, + /// Index of the first filing that carries the stage's information. + pub satisfied_by: Option, + /// Where the deadline stands. + pub state: DeadlineState, +} + +// ── The chain ────────────────────────────────────────────────────── + +/// One Article 14 case's filings: hash-linked, Merkle-logged, append-only. +/// +/// Not deserialised directly: persist [`FilingChain::filings`] and rebuild +/// with [`FilingChain::replay`], which checks every rule again. +#[derive(Debug, Clone)] +pub struct FilingChain { + case_id: String, + subject: Art14Subject, + filings: Vec, + log: MerkleTree, +} + +impl FilingChain { + /// An empty chain for one case. + pub fn new(case_id: impl Into, subject: Art14Subject) -> Self { + Self { + case_id: case_id.into(), + subject, + filings: Vec::new(), + log: MerkleTree::new(), + } + } + + /// Rebuild a chain by filing `filings` in order, refusing at the first + /// filing the rules refuse. + /// + /// The rebuilt chain's root equals the original's exactly when the same + /// filings were replayed, so an auditor holding the filings and a signed + /// tree head can check one against the other. + pub fn replay( + case_id: impl Into, + subject: Art14Subject, + filings: impl IntoIterator, + ) -> Result { + let mut chain = Self::new(case_id, subject); + for filing in filings { + chain.file(filing)?; + } + Ok(chain) + } + + /// The case id every filing must carry. + pub fn case_id(&self) -> &str { + &self.case_id + } + + /// The subject every filing must carry. + pub fn subject(&self) -> Art14Subject { + self.subject + } + + /// Every accepted filing, in filing order. + pub fn filings(&self) -> &[Filing] { + &self.filings + } + + /// Number of accepted filings. + pub fn len(&self) -> usize { + self.filings.len() + } + + /// `true` iff nothing has been filed. + pub fn is_empty(&self) -> bool { + self.filings.is_empty() + } + + /// The latest filing. + pub fn head(&self) -> Option<&Filing> { + self.filings.last() + } + + /// The latest filing's content id: what the next filing must name in + /// `supersedes`. + pub fn head_content_id(&self) -> Option { + self.head().map(Filing::content_id) + } + + /// The chain's awareness: the earliest `became_aware_at` any filing + /// states. Deadlines measured from awareness use this. + pub fn awareness(&self) -> Option> { + self.filings.iter().map(|f| f.became_aware_at).min() + } + + /// The earliest `corrective_measure_available_at` any filing states. + pub fn corrective_measure_available_at(&self) -> Option> { + self.filings + .iter() + .filter_map(|f| f.corrective_measure_available_at) + .min() + } + + /// The furthest stage any filing has been filed as. + pub fn furthest_stage(&self) -> Option { + self.filings.iter().map(|f| f.stage).max() + } + + /// Check `filing` against every append rule without appending it. + pub fn check(&self, filing: &Filing) -> Result<(), ChainError> { + if filing.case_id != self.case_id { + return Err(ChainError::CaseMismatch { + expected: self.case_id.clone(), + got: filing.case_id.clone(), + }); + } + if filing.subject != self.subject { + return Err(ChainError::SubjectMismatch { + expected: self.subject, + got: filing.subject, + }); + } + let head = self.head_content_id(); + if filing.supersedes != head { + return Err(ChainError::SupersedesMismatch { + expected: head, + got: filing.supersedes.clone(), + }); + } + if let Some(previous) = self.head() + && filing.filed_at < previous.filed_at + { + return Err(ChainError::FiledAtBackwards { + previous: previous.filed_at, + got: filing.filed_at, + }); + } + if let Some(furthest) = self.furthest_stage() + && filing.stage < furthest + { + return Err(ChainError::StageRegressed { + furthest, + got: filing.stage, + }); + } + if let Some(current) = self.awareness() + && filing.became_aware_at > current + { + return Err(ChainError::AwarenessMovedLater { + current, + got: filing.became_aware_at, + }); + } + if filing.filed_at < filing.became_aware_at { + return Err(ChainError::FiledBeforeAwareness { + became_aware_at: filing.became_aware_at, + filed_at: filing.filed_at, + }); + } + let mut seen = HashSet::new(); + for statement in &filing.statements { + if !seen.insert(statement.product.as_str()) { + return Err(ChainError::DuplicateProduct(statement.product.clone())); + } + } + Ok(()) + } + + /// Append `filing` if every rule allows it (see [`FilingChain::check`]), + /// and return its receipt. + pub fn file(&mut self, filing: Filing) -> Result { + self.check(&filing)?; + let hash = blake3::hash(&filing.canonical_bytes()); + let leaf_index = self.log.append(hash.as_bytes().to_vec()); + let inclusion_proof = self + .log + .inclusion_proof(leaf_index) + .expect("the leaf just appended is inside the log"); + let root = self.log.root(); + self.filings.push(filing); + Ok(FiledReceipt { + leaf_index, + content_id: hash.to_hex().to_string(), + inclusion_proof, + root, + }) + } + + /// The filings that had been filed by `t` (`filed_at <= t`), in order: + /// what the record held at that moment. + pub fn known_at(&self, t: DateTime) -> Vec<&Filing> { + self.filings.iter().filter(|f| f.filed_at <= t).collect() + } + + /// Every change of a product's stated status across the chain, in filing + /// order and, within a filing, statement order. + /// + /// A product's first statement is not a revision, and a filing that does + /// not name a product leaves its last stated status in place. A changed + /// justification for `NotAffected` is a revision: the justification is + /// part of what was stated. + pub fn revisions(&self) -> Vec { + let mut last: HashMap = HashMap::new(); + let mut out = Vec::new(); + for (filing_index, filing) in self.filings.iter().enumerate() { + for statement in &filing.statements { + let previous = last.insert(statement.product.clone(), statement.status.clone()); + if let Some(from) = previous + && from != statement.status + { + out.push(Revision { + product: statement.product.clone(), + from, + to: statement.status.clone(), + filed_at: filing.filed_at, + filing_index, + }); + } + } + } + out + } + + /// Each stage's deadline as of `now`, in stage order. + /// + /// Due times: the early warning 24 hours and the notification 72 hours + /// after the chain's awareness ([`FilingChain::awareness`], the earliest + /// any filing states). The final report on an actively exploited + /// vulnerability is due 14 days after the earliest corrective-measure + /// time any filing states; on a severe incident, one calendar month after + /// the filing time of the filing that satisfied the notification stage. + /// + /// A stage is satisfied by the first filing that carries its information + /// ([`Filing::provides`]): one at that stage or a later one, or one whose + /// `also_provides` lists it. + /// + /// The state is decided by the due time first: with no due time there is + /// nothing to be met or late against, so the stage is + /// [`DeadlineState::ClockNotStarted`] even if a filing already satisfies + /// it. Every filing in the chain counts; `now` only separates + /// [`DeadlineState::Pending`] from [`DeadlineState::Overdue`]. + pub fn deadlines(&self, now: DateTime) -> Vec { + let aware = self.awareness(); + let early_warning_due = aware.and_then(|a| { + a.checked_add_signed(TimeDelta::hours(EARLY_WARNING_WITHIN_HOURS)) + }); + let notification_due = aware.and_then(|a| { + a.checked_add_signed(TimeDelta::hours(NOTIFICATION_WITHIN_HOURS)) + }); + let final_report_due = match self.subject { + Art14Subject::ActivelyExploitedVulnerability => { + self.corrective_measure_available_at().and_then(|m| { + m.checked_add_signed(TimeDelta::days(VULNERABILITY_FINAL_REPORT_WITHIN_DAYS)) + }) + } + Art14Subject::SevereIncident => self + .first_providing(FilingStage::Notification) + .and_then(|i| { + self.filings[i] + .filed_at + .checked_add_months(Months::new(INCIDENT_FINAL_REPORT_WITHIN_MONTHS)) + }), + }; + vec![ + self.stage_deadline(FilingStage::EarlyWarning, early_warning_due, now), + self.stage_deadline(FilingStage::Notification, notification_due, now), + self.stage_deadline(FilingStage::FinalReport, final_report_due, now), + ] + } + + /// The log's current root. + pub fn root(&self) -> [u8; 32] { + self.log.root() + } + + /// The log's current head, for the caller to sign with + /// [`TreeHead::sign`]. + pub fn tree_head(&self, timestamp_unix: u64) -> TreeHead { + TreeHead::of(&self.log, timestamp_unix) + } + + /// A proof that filing `index` is in the log under its current root. + pub fn inclusion_proof(&self, index: u64) -> Result { + self.log.inclusion_proof(index) + } + + /// A proof that the log as it stood with `old_size` filings is a prefix + /// of the log now: nothing filed then has since been changed or removed. + pub fn consistency_proof(&self, old_size: u64) -> Result { + self.log.consistency_proof(old_size) + } + + fn first_providing(&self, stage: FilingStage) -> Option { + self.filings.iter().position(|f| f.provides(stage)) + } + + fn stage_deadline( + &self, + stage: FilingStage, + due: Option>, + now: DateTime, + ) -> StageDeadline { + let satisfied_by = self.first_providing(stage); + let state = match (due, satisfied_by) { + (None, _) => DeadlineState::ClockNotStarted, + (Some(due), Some(i)) if self.filings[i].filed_at <= due => DeadlineState::Met, + (Some(_), Some(_)) => DeadlineState::Late, + (Some(due), None) if now <= due => DeadlineState::Pending, + (Some(_), None) => DeadlineState::Overdue, + }; + StageDeadline { + stage, + due, + satisfied_by, + state, + } + } +} + +#[cfg(test)] +mod tests { + use super::DeadlineState::{ClockNotStarted, Late, Met, Overdue, Pending}; + use super::FilingStage::{EarlyWarning, FinalReport, Notification}; + use super::*; + use ed25519_dalek::SigningKey; + + fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, mo, d, h, mi, 0) + .single() + .expect("a valid test time") + } + + /// `hours` after hour zero of the test cases, 1 October 2026 08:00 UTC. + fn h(hours: i64) -> DateTime { + utc(2026, 10, 1, 8, 0) + TimeDelta::hours(hours) + } + + fn vulnerability_case() -> FilingChain { + FilingChain::new("case-vuln-1", Art14Subject::ActivelyExploitedVulnerability) + } + + fn incident_case() -> FilingChain { + FilingChain::new("case-incident-1", Art14Subject::SevereIncident) + } + + /// A filing linked to `chain`'s current head. + fn next( + chain: &FilingChain, + stage: FilingStage, + aware: DateTime, + filed: DateTime, + ) -> Filing { + Filing { + case_id: chain.case_id().to_string(), + subject: chain.subject(), + stage, + became_aware_at: aware, + filed_at: filed, + corrective_measure_available_at: None, + statements: vec![ProductStatement { + product: "edge-router".to_string(), + status: ProductStatus::UnderInvestigation, + }], + also_provides: Vec::new(), + summary: format!("{stage:?} for {}", chain.case_id()), + supersedes: chain.head_content_id(), + } + } + + fn deadline(chain: &FilingChain, now: DateTime, stage: FilingStage) -> StageDeadline { + chain + .deadlines(now) + .into_iter() + .find(|d| d.stage == stage) + .expect("every stage has a deadline row") + } + + fn not_affected(why: &str) -> ProductStatus { + ProductStatus::NotAffected { + justification: why.to_string(), + } + } + + #[test] + fn vulnerability_stages_met_with_final_clock_starting_at_the_measure() { + let mut c = vulnerability_case(); + // Filed exactly at hour 24: at the due time counts as met. + c.file(next(&c, EarlyWarning, h(0), h(24))).unwrap(); + c.file(next(&c, Notification, h(0), h(50))).unwrap(); + let mut fr = next(&c, FinalReport, h(0), h(24 * 18)); + fr.corrective_measure_available_at = Some(h(24 * 5)); + c.file(fr).unwrap(); + + let ds = c.deadlines(h(24 * 40)); + let stages: Vec = ds.iter().map(|d| d.stage).collect(); + assert_eq!(stages, FilingStage::ALL.to_vec()); + assert_eq!( + ds[0], + StageDeadline { stage: EarlyWarning, due: Some(h(24)), satisfied_by: Some(0), state: Met } + ); + assert_eq!( + ds[1], + StageDeadline { stage: Notification, due: Some(h(72)), satisfied_by: Some(1), state: Met } + ); + // 14 days after the measure (day 5) is day 19. Counted from awareness + // it would be day 14, and the day-18 report would be late. + assert_eq!( + ds[2], + StageDeadline { stage: FinalReport, due: Some(h(24 * 19)), satisfied_by: Some(2), state: Met } + ); + assert_ne!(ds[2].due, Some(h(24 * 14))); + } + + #[test] + fn vulnerability_late_pending_overdue_and_clock_not_started() { + let mut c = vulnerability_case(); + c.file(next(&c, EarlyWarning, h(0), h(30))).unwrap(); + + let ds = c.deadlines(h(40)); + assert_eq!( + ds[0], + StageDeadline { stage: EarlyWarning, due: Some(h(24)), satisfied_by: Some(0), state: Late } + ); + assert_eq!( + ds[1], + StageDeadline { stage: Notification, due: Some(h(72)), satisfied_by: None, state: Pending } + ); + // No filing states a corrective measure: the final report's clock has + // not started. + assert_eq!( + ds[2], + StageDeadline { stage: FinalReport, due: None, satisfied_by: None, state: ClockNotStarted } + ); + // Pending through the due time itself, overdue one second after. + assert_eq!(deadline(&c, h(72), Notification).state, Pending); + assert_eq!( + deadline(&c, h(72) + TimeDelta::seconds(1), Notification).state, + Overdue + ); + + let mut n = next(&c, Notification, h(0), h(80)); + n.corrective_measure_available_at = Some(h(100)); + c.file(n).unwrap(); + assert_eq!(deadline(&c, h(90), Notification).state, Late); + let fr = deadline(&c, h(200), FinalReport); + assert_eq!(fr.due, Some(h(100) + TimeDelta::days(14))); + assert_eq!(fr.satisfied_by, None); + assert_eq!(fr.state, Pending); + assert_eq!(deadline(&c, h(100 + 24 * 14 + 1), FinalReport).state, Overdue); + } + + #[test] + fn earliest_stated_corrective_measure_starts_the_vulnerability_final_clock() { + let mut c = vulnerability_case(); + let mut ew = next(&c, EarlyWarning, h(0), h(10)); + ew.corrective_measure_available_at = Some(h(48)); + c.file(ew).unwrap(); + let mut n = next(&c, Notification, h(0), h(60)); + n.corrective_measure_available_at = Some(h(96)); + c.file(n).unwrap(); + assert_eq!(c.corrective_measure_available_at(), Some(h(48))); + assert_eq!( + deadline(&c, h(60), FinalReport).due, + Some(h(48) + TimeDelta::days(14)) + ); + } + + #[test] + fn incident_deadlines_follow_the_notification_not_the_measure() { + let mut c = incident_case(); + let mut ew = next(&c, EarlyWarning, h(0), h(6)); + // A corrective measure does not start an incident's final-report clock. + ew.corrective_measure_available_at = Some(h(1)); + c.file(ew).unwrap(); + + let ds = c.deadlines(h(30)); + assert_eq!(ds[0].state, Met); + assert_eq!(ds[1].state, Pending); + assert_eq!( + ds[2], + StageDeadline { stage: FinalReport, due: None, satisfied_by: None, state: ClockNotStarted } + ); + + // Hour 60 is 3 October 2026, 20:00 UTC; one month on is 3 November. + c.file(next(&c, Notification, h(0), h(60))).unwrap(); + assert_eq!(h(60), utc(2026, 10, 3, 20, 0)); + let fr = deadline(&c, h(100), FinalReport); + assert_eq!(fr.due, Some(utc(2026, 11, 3, 20, 0))); + assert_eq!(fr.state, Pending); + assert_eq!( + deadline(&c, utc(2026, 11, 3, 20, 1), FinalReport).state, + Overdue + ); + + c.file(next(&c, FinalReport, h(0), utc(2026, 11, 4, 9, 0))).unwrap(); + assert_eq!( + deadline(&c, utc(2026, 11, 5, 0, 0), FinalReport), + StageDeadline { + stage: FinalReport, + due: Some(utc(2026, 11, 3, 20, 0)), + satisfied_by: Some(2), + state: Late, + } + ); + } + + fn incident_final_due(notified: DateTime) -> Option> { + let mut c = incident_case(); + c.file(next(&c, Notification, notified - TimeDelta::hours(30), notified)) + .unwrap(); + deadline(&c, notified, FinalReport).due + } + + #[test] + fn incident_final_report_is_one_calendar_month_after_the_notification() { + // February has no 31st: the due time falls on its last day. + assert_eq!(incident_final_due(utc(2027, 1, 31, 9, 0)), Some(utc(2027, 2, 28, 9, 0))); + assert_eq!(incident_final_due(utc(2028, 1, 31, 9, 0)), Some(utc(2028, 2, 29, 9, 0))); + assert_eq!(incident_final_due(utc(2027, 3, 31, 9, 0)), Some(utc(2027, 4, 30, 9, 0))); + // A month is not a fixed number of days: 28 days here, 31 there. + assert_eq!(incident_final_due(utc(2027, 2, 10, 9, 0)), Some(utc(2027, 3, 10, 9, 0))); + assert_eq!(incident_final_due(utc(2027, 7, 10, 9, 0)), Some(utc(2027, 8, 10, 9, 0))); + + // A final report filed at that time on 28 February is met. + let mut c = incident_case(); + c.file(next(&c, Notification, utc(2027, 1, 30, 12, 0), utc(2027, 1, 31, 9, 0))) + .unwrap(); + c.file(next(&c, FinalReport, utc(2027, 1, 30, 12, 0), utc(2027, 2, 28, 9, 0))) + .unwrap(); + let fr = deadline(&c, utc(2027, 3, 15, 0, 0), FinalReport); + assert_eq!(fr.satisfied_by, Some(1)); + assert_eq!(fr.state, Met); + } + + /// A final report filed without a separate notification carries the + /// notification's information, so it satisfies that stage, on time or + /// late, instead of leaving it overdue for good. + #[test] + fn a_later_stage_carries_the_earlier_ones() { + let mut on_time = vulnerability_case(); + on_time.file(next(&on_time, EarlyWarning, h(0), h(20))).unwrap(); + let mut fr = next(&on_time, FinalReport, h(0), h(60)); + fr.corrective_measure_available_at = Some(h(50)); + on_time.file(fr).unwrap(); + let n = deadline(&on_time, h(100), Notification); + assert_eq!((n.satisfied_by, n.state), (Some(1), Met)); + + let mut late = vulnerability_case(); + late.file(next(&late, EarlyWarning, h(0), h(20))).unwrap(); + let mut fr = next(&late, FinalReport, h(0), h(100)); + fr.corrective_measure_available_at = Some(h(90)); + late.file(fr).unwrap(); + let n = deadline(&late, h(200), Notification); + assert_eq!((n.satisfied_by, n.state), (Some(1), Late)); + } + + #[test] + fn also_provides_satisfies_a_later_stage_early() { + // Without the declaration, an early warning leaves the notification open. + let mut plain = vulnerability_case(); + plain.file(next(&plain, EarlyWarning, h(0), h(20))).unwrap(); + assert_eq!(deadline(&plain, h(100), Notification).satisfied_by, None); + assert_eq!(deadline(&plain, h(100), Notification).state, Overdue); + + let mut c = vulnerability_case(); + let mut ew = next(&c, EarlyWarning, h(0), h(20)); + ew.also_provides = vec![Notification]; + c.file(ew).unwrap(); + let n = deadline(&c, h(100), Notification); + assert_eq!(n.satisfied_by, Some(0)); + assert_eq!(n.state, Met); + assert_eq!(deadline(&c, h(100), FinalReport).satisfied_by, None); + + // A notification that already carries the final report's information. + let mut n2 = next(&c, Notification, h(0), h(30)); + n2.corrective_measure_available_at = Some(h(25)); + n2.also_provides = vec![FinalReport]; + c.file(n2).unwrap(); + assert_eq!( + deadline(&c, h(100), FinalReport), + StageDeadline { + stage: FinalReport, + due: Some(h(25) + TimeDelta::days(14)), + satisfied_by: Some(1), + state: Met, + } + ); + + // For an incident, the final-report clock starts at the filing that + // provided the notification. + let mut inc = incident_case(); + let mut ew = next(&inc, EarlyWarning, h(0), h(10)); + ew.also_provides = vec![Notification]; + inc.file(ew).unwrap(); + assert_eq!( + deadline(&inc, h(11), FinalReport).due, + h(10).checked_add_months(Months::new(1)) + ); + } + + #[test] + fn refuses_a_supersedes_that_is_not_the_head() { + let mut c = vulnerability_case(); + let mut first = next(&c, EarlyWarning, h(0), h(1)); + first.supersedes = Some("00".repeat(32)); + assert_eq!( + c.file(first.clone()).unwrap_err(), + ChainError::SupersedesMismatch { expected: None, got: Some("00".repeat(32)) } + ); + assert!(c.is_empty()); + + first.supersedes = None; + let r = c.file(first).unwrap(); + let root = c.root(); + + let mut unlinked = next(&c, Notification, h(0), h(2)); + unlinked.supersedes = None; + assert_eq!( + c.file(unlinked).unwrap_err(), + ChainError::SupersedesMismatch { expected: Some(r.content_id.clone()), got: None } + ); + let mut forged = next(&c, Notification, h(0), h(2)); + forged.supersedes = Some("ab".repeat(32)); + assert_eq!( + c.file(forged).unwrap_err(), + ChainError::SupersedesMismatch { + expected: Some(r.content_id.clone()), + got: Some("ab".repeat(32)), + } + ); + // Refusals leave the chain as it was. + assert_eq!(c.len(), 1); + assert_eq!(c.root(), root); + assert_eq!(c.head_content_id(), Some(r.content_id)); + } + + #[test] + fn refuses_a_stage_that_goes_backwards() { + let mut c = vulnerability_case(); + c.file(next(&c, EarlyWarning, h(0), h(1))).unwrap(); + c.file(next(&c, Notification, h(0), h(2))).unwrap(); + // Another filing at the furthest stage is an update, and is accepted. + c.file(next(&c, Notification, h(0), h(3))).unwrap(); + assert_eq!( + c.file(next(&c, EarlyWarning, h(0), h(4))).unwrap_err(), + ChainError::StageRegressed { furthest: Notification, got: EarlyWarning } + ); + c.file(next(&c, FinalReport, h(0), h(5))).unwrap(); + assert_eq!( + c.file(next(&c, Notification, h(0), h(6))).unwrap_err(), + ChainError::StageRegressed { furthest: FinalReport, got: Notification } + ); + assert_eq!(c.len(), 4); + assert_eq!(c.furthest_stage(), Some(FinalReport)); + } + + #[test] + fn refuses_an_awareness_moved_later() { + let mut c = vulnerability_case(); + c.file(next(&c, EarlyWarning, h(0), h(10))).unwrap(); + assert_eq!( + c.file(next(&c, Notification, h(5), h(20))).unwrap_err(), + ChainError::AwarenessMovedLater { current: h(0), got: h(5) } + ); + assert_eq!(c.len(), 1); + assert_eq!(c.awareness(), Some(h(0))); + assert_eq!(deadline(&c, h(20), EarlyWarning).due, Some(h(24))); + assert_eq!(deadline(&c, h(20), Notification).due, Some(h(72))); + } + + #[test] + fn accepts_an_earlier_awareness_and_moves_deadlines_earlier() { + let mut c = vulnerability_case(); + c.file(next(&c, EarlyWarning, h(0), h(20))).unwrap(); + assert_eq!( + deadline(&c, h(20), EarlyWarning), + StageDeadline { stage: EarlyWarning, due: Some(h(24)), satisfied_by: Some(0), state: Met } + ); + + // The notification states the manufacturer was aware ten hours earlier. + c.file(next(&c, Notification, h(-10), h(40))).unwrap(); + assert_eq!(c.awareness(), Some(h(-10))); + // The early warning, filed at hour 20, is now four hours late. + assert_eq!( + deadline(&c, h(40), EarlyWarning), + StageDeadline { stage: EarlyWarning, due: Some(h(14)), satisfied_by: Some(0), state: Late } + ); + assert_eq!( + deadline(&c, h(40), Notification), + StageDeadline { stage: Notification, due: Some(h(62)), satisfied_by: Some(1), state: Met } + ); + + // Restating the original, later awareness is now moving it later. + assert_eq!( + c.file(next(&c, Notification, h(0), h(50))).unwrap_err(), + ChainError::AwarenessMovedLater { current: h(-10), got: h(0) } + ); + } + + #[test] + fn refuses_a_filed_at_that_goes_backwards() { + let mut c = vulnerability_case(); + c.file(next(&c, EarlyWarning, h(0), h(10))).unwrap(); + assert_eq!( + c.file(next(&c, Notification, h(0), h(9))).unwrap_err(), + ChainError::FiledAtBackwards { previous: h(10), got: h(9) } + ); + // The same instant is not earlier. + c.file(next(&c, Notification, h(0), h(10))).unwrap(); + assert_eq!(c.len(), 2); + } + + #[test] + fn refuses_another_case_or_subject() { + let mut c = vulnerability_case(); + let mut other_case = next(&c, EarlyWarning, h(0), h(1)); + other_case.case_id = "case-vuln-2".to_string(); + assert_eq!( + c.file(other_case).unwrap_err(), + ChainError::CaseMismatch { + expected: "case-vuln-1".to_string(), + got: "case-vuln-2".to_string(), + } + ); + let mut other_subject = next(&c, EarlyWarning, h(0), h(1)); + other_subject.subject = Art14Subject::SevereIncident; + assert_eq!( + c.file(other_subject).unwrap_err(), + ChainError::SubjectMismatch { + expected: Art14Subject::ActivelyExploitedVulnerability, + got: Art14Subject::SevereIncident, + } + ); + assert!(c.is_empty()); + } + + #[test] + fn refuses_a_filing_before_its_awareness_or_naming_a_product_twice() { + let mut c = vulnerability_case(); + assert_eq!( + c.file(next(&c, EarlyWarning, h(5), h(4))).unwrap_err(), + ChainError::FiledBeforeAwareness { became_aware_at: h(5), filed_at: h(4) } + ); + let mut twice = next(&c, EarlyWarning, h(0), h(4)); + twice.statements.push(ProductStatement { + product: "edge-router".to_string(), + status: ProductStatus::Affected, + }); + assert_eq!( + c.file(twice).unwrap_err(), + ChainError::DuplicateProduct("edge-router".to_string()) + ); + assert!(c.is_empty()); + } + + #[test] + fn known_at_hour_24_is_what_had_been_filed_by_then() { + let mut c = vulnerability_case(); + for (stage, at) in [(EarlyWarning, h(10)), (Notification, h(30)), (Notification, h(50))] { + c.file(next(&c, stage, h(0), at)).unwrap(); + } + let by_24 = c.known_at(h(24)); + assert_eq!(by_24.len(), 1); + assert_eq!(by_24[0].stage, EarlyWarning); + assert_eq!(by_24[0].filed_at, h(10)); + assert!(c.known_at(h(9)).is_empty()); + // Inclusive of a filing made at exactly that time. + assert_eq!(c.known_at(h(30)).len(), 2); + assert_eq!(c.known_at(h(1000)).len(), 3); + } + + #[test] + fn revisions_record_not_affected_becoming_affected() { + let mut c = vulnerability_case(); + let mut f0 = next(&c, EarlyWarning, h(0), h(5)); + f0.statements = vec![ + ProductStatement { + product: "edge-router".to_string(), + status: not_affected("the vulnerable parser is not built into this product"), + }, + ProductStatement { + product: "mesh-node".to_string(), + status: ProductStatus::UnderInvestigation, + }, + ]; + c.file(f0).unwrap(); + assert!(c.revisions().is_empty()); + + let mut f1 = next(&c, Notification, h(0), h(40)); + f1.statements = vec![ + ProductStatement { + product: "edge-router".to_string(), + status: ProductStatus::Affected, + }, + ProductStatement { + product: "mesh-node".to_string(), + status: ProductStatus::UnderInvestigation, + }, + ]; + c.file(f1).unwrap(); + // Silent on edge-router: its last statement stands. + let mut f2 = next(&c, Notification, h(0), h(60)); + f2.statements = vec![ProductStatement { + product: "mesh-node".to_string(), + status: not_affected("ships without the affected component"), + }]; + c.file(f2).unwrap(); + let mut f3 = next(&c, FinalReport, h(0), h(300)); + f3.statements = vec![ProductStatement { + product: "edge-router".to_string(), + status: ProductStatus::Fixed, + }]; + c.file(f3).unwrap(); + + assert_eq!( + c.revisions(), + vec![ + Revision { + product: "edge-router".to_string(), + from: not_affected("the vulnerable parser is not built into this product"), + to: ProductStatus::Affected, + filed_at: h(40), + filing_index: 1, + }, + Revision { + product: "mesh-node".to_string(), + from: ProductStatus::UnderInvestigation, + to: not_affected("ships without the affected component"), + filed_at: h(60), + filing_index: 2, + }, + Revision { + product: "edge-router".to_string(), + from: ProductStatus::Affected, + to: ProductStatus::Fixed, + filed_at: h(300), + filing_index: 3, + }, + ] + ); + } + + #[test] + fn each_receipt_proves_its_filing_against_its_root_and_the_signed_head() { + let mut c = vulnerability_case(); + let mut filed = Vec::new(); + let mut receipts = Vec::new(); + for (stage, at) in [(EarlyWarning, h(10)), (Notification, h(40)), (FinalReport, h(300))] { + let f = next(&c, stage, h(0), at); + filed.push(f.clone()); + receipts.push(c.file(f).unwrap()); + } + for (i, (r, f)) in receipts.iter().zip(&filed).enumerate() { + assert_eq!(r.leaf_index, i as u64); + assert_eq!(r.inclusion_proof.tree_size, i as u64 + 1); + assert_eq!(r.content_id, f.content_id()); + r.inclusion_proof.verify(&f.content_hash(), &r.root).unwrap(); + r.verify(f).unwrap(); + } + // Each filing names the one before it. + assert_eq!(filed[0].supersedes, None); + assert_eq!(filed[1].supersedes.as_deref(), Some(receipts[0].content_id.as_str())); + assert_eq!(filed[2].supersedes.as_deref(), Some(receipts[1].content_id.as_str())); + // Roots move with every filing; the latest is the chain's. + assert_ne!(receipts[0].root, receipts[1].root); + assert_eq!(receipts[2].root, c.root()); + // The log as of the first receipt is a prefix of the log now. + c.consistency_proof(1) + .unwrap() + .verify(&receipts[0].root, &c.root()) + .unwrap(); + + // The caller signs the head; a proof for the first filing verifies + // against the signed root. + let head = c.tree_head(1_790_000_000); + assert_eq!(head.size, 3); + assert_eq!(head.root_hex, receipts[2].root_hex()); + let sk = SigningKey::from_bytes(&[7u8; 32]); + let signed = head.sign(&sk).unwrap(); + let root = signed.verify(&sk.verifying_key()).unwrap(); + assert_eq!(root, c.root()); + c.inclusion_proof(0) + .unwrap() + .verify(&filed[0].content_hash(), &root) + .unwrap(); + } + + #[test] + fn a_filing_altered_after_filing_does_not_verify_against_its_receipt() { + let mut c = vulnerability_case(); + let mut f = next(&c, EarlyWarning, h(0), h(10)); + f.statements = vec![ProductStatement { + product: "edge-router".to_string(), + status: not_affected("the vulnerable parser is not built into this product"), + }]; + let r = c.file(f.clone()).unwrap(); + r.verify(&f).unwrap(); + + let mut altered = f.clone(); + altered.statements[0].status = ProductStatus::Affected; + let altered_id = altered.content_id(); + assert_ne!(altered_id, f.content_id()); + assert_ne!(altered_id, r.content_id); + assert!(matches!( + r.verify(&altered), + Err(ReceiptError::ContentIdMismatch { .. }) + )); + // The bare proof does not lead from the altered filing to the root either. + assert!(r.inclusion_proof.verify(&altered.content_hash(), &r.root).is_err()); + + let mut backdated = f.clone(); + backdated.filed_at = h(9); + assert!(r.verify(&backdated).is_err()); + + // A receipt whose leaf index disagrees with its proof is refused. + let mut misnumbered = r.clone(); + misnumbered.leaf_index = 5; + assert!(matches!( + misnumbered.verify(&f), + Err(ReceiptError::LeafIndexMismatch { receipt: 5, proof: 0 }) + )); + + // Swapping the altered filing into the record breaks the link from + // the filing after it. + let n = next(&c, Notification, h(0), h(40)); + c.file(n.clone()).unwrap(); + assert_eq!( + FilingChain::replay(c.case_id(), c.subject(), [altered, n]).unwrap_err(), + ChainError::SupersedesMismatch { + expected: Some(altered_id), + got: Some(f.content_id()), + } + ); + } + + #[test] + fn replay_reproduces_the_root_and_the_deadlines() { + let mut c = incident_case(); + c.file(next(&c, EarlyWarning, h(0), h(12))).unwrap(); + c.file(next(&c, Notification, h(-2), h(60))).unwrap(); + c.file(next(&c, FinalReport, h(-2), h(24 * 20))).unwrap(); + let again = + FilingChain::replay(c.case_id(), c.subject(), c.filings().to_vec()).unwrap(); + assert_eq!(again.root(), c.root()); + assert_eq!(again.deadlines(h(24 * 40)), c.deadlines(h(24 * 40))); + assert_eq!(again.head_content_id(), c.head_content_id()); + } + + #[test] + fn content_id_is_hex_blake3_and_survives_a_json_round_trip() { + let c = vulnerability_case(); + let mut f = next(&c, EarlyWarning, h(0), h(3)); + f.corrective_measure_available_at = Some(h(2)); + f.also_provides = vec![Notification]; + let id = f.content_id(); + assert_eq!(id.len(), 64); + assert!(id.chars().all(|ch| ch.is_ascii_hexdigit())); + assert_eq!(id, blake3::hash(&f.canonical_bytes()).to_hex().to_string()); + assert_eq!(f.content_hash(), *blake3::hash(&f.canonical_bytes()).as_bytes()); + + let json = serde_json::to_string(&f).unwrap(); + let back: Filing = serde_json::from_str(&json).unwrap(); + assert_eq!(back, f); + assert_eq!(back.content_id(), id); + // The same instants written with an explicit offset are the same filing. + let offset_json = json.replace("Z\"", "+00:00\""); + assert_ne!(offset_json, json); + let offset_back: Filing = serde_json::from_str(&offset_json).unwrap(); + assert_eq!(offset_back.content_id(), id); + // A field outside the typed form is refused, not silently dropped. + let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); + value["unlisted"] = serde_json::json!("not covered by the content id"); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn article_14_applies_from_11_september_2026() { + assert_eq!(article_14_applies_from(), utc(2026, 9, 11, 0, 0)); + } + + #[test] + fn an_empty_chain_has_no_clock_running() { + let c = vulnerability_case(); + assert_eq!(c.awareness(), None); + assert_eq!(c.head_content_id(), None); + for d in c.deadlines(h(1000)) { + assert_eq!(d.due, None); + assert_eq!(d.satisfied_by, None); + assert_eq!(d.state, ClockNotStarted); + } + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs index 653e9950c..edb4b8dd4 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs @@ -13,6 +13,10 @@ //! - **EU compliance reports** — EED (Energy Efficiency Directive), //! CSRD Scope 3, EU AI Act energy disclosure, ESPR Digital Product //! Passport. See [`eu_compliance`]. +//! - **EU Cyber Resilience Act, Article 14 reporting** — one case's early +//! warning, notification and final report as a hash-linked filing chain, +//! each filing a leaf in a Merkle log with an inclusion-proof receipt, and +//! each stage's statutory deadline read off the chain. See [`cra`]. //! //! Every passport links a [`passport::MeasurementMethod`] to a //! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels @@ -40,7 +44,13 @@ pub mod sci; pub mod banding; pub mod passport; pub mod eu_compliance; +pub mod cra; +pub use cra::{ + Art14Subject, ChainError, DeadlineState, FiledReceipt, Filing, FilingChain, FilingStage, + ProductStatement, ProductStatus, ReceiptError, Revision, StageDeadline, + article_14_applies_from, +}; pub use eu_compliance::{ AiActReport, CsrdScope3Report, CustomerAllocation, EedReport, EsprReport, generate_ai_act_report, generate_csrd_scope3, generate_eed_report,