From 5cd445a9ae5d8f143dede5866c52f8a698cc726e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:04:50 -0400 Subject: [PATCH 1/2] jouleclaw-compliance: EU Cyber Resilience Act Article 14 reporting as a filing chain A new `cra` module records one Article 14 case (an actively exploited vulnerability, Regulation (EU) 2024/2847, Art. 14(2), or a severe incident, Art. 14(4)) as a hash-linked chain of filings. Every accepted filing's BLAKE3 content hash is a leaf in a Merkle log, and filing returns a receipt carrying the leaf index, content id, inclusion proof and root. A filing altered after it was filed no longer verifies against its receipt. The chain refuses a filing for another case or subject, one that does not supersede the current head, one dated before the previous filing, one whose stage is behind the furthest filed, one that moves awareness later, one dated before its own awareness, and one naming a product twice. An earlier awareness is accepted and moves the deadlines earlier. Deadlines: early warning 24 h and notification 72 h after the chain's earliest awareness; the vulnerability final report 14 days after the earliest stated corrective measure; the incident final report one calendar month after the filing that satisfied the notification. The early warning is satisfied by any filing; the later stages by a filing at that stage or one declaring the stage in `also_provides`. Also: known_at(t), per-product status revisions, tree heads for signing, consistency and inclusion proofs, and replay from stored filings. Co-Authored-By: Claude Opus 5.5 --- jouleclaw/jouleclaw-rs/Cargo.lock | 2 + .../crates/jouleclaw-compliance/Cargo.toml | 6 +- .../crates/jouleclaw-compliance/src/cra.rs | 1381 +++++++++++++++++ .../crates/jouleclaw-compliance/src/lib.rs | 10 + 4 files changed, 1398 insertions(+), 1 deletion(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs 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..f6320f70f --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs @@ -0,0 +1,1381 @@ +//! 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: +//! +//! - the early warning is satisfied by the chain's first filing, whatever its +//! stage: every filing carries at least the early warning's information; +//! - the notification and the final report are each satisfied by the first +//! filing whose stage is that stage, or whose `also_provides` lists it. The +//! manufacturer declares in `also_provides` which later stages' information +//! a filing already carries, and nothing is inferred: a final report that +//! also carries the notification's information says so there. +//! +//! [`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). + 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. + /// + /// Every filing carries the early warning's. A filing carries the + /// notification's or the final report's when that is its own stage, or + /// when it lists that stage in `also_provides`. + pub fn provides(&self, stage: FilingStage) -> bool { + stage == FilingStage::EarlyWarning + || 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`]): any filing for the early warning; for the + /// notification and the final report, a filing at that stage 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); + } + + #[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, -- 2.54.0 (Apple Git-157) From ebeed2de38b657b7300953bb5dfa9719047a168d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:10:47 -0400 Subject: [PATCH 2/2] jouleclaw-compliance: a later-stage CRA filing carries the earlier stages' information As first built, a stage was satisfied only by a filing at exactly that stage, or one that listed it in `also_provides`. A final report filed without a separate notification therefore left the notification stage unsatisfied. Since a stage cannot be filed after a later one, it would have stayed overdue for good, although the final report carries everything the notification does. `Filing::provides(stage)` is now `self.stage >= stage` or a stage listed in `also_provides`. Each stage's content contains the earlier stages', and `also_provides` keeps its real job: declaring that a filing already carries a LATER stage's information early. The module and method docs say so. `a_later_stage_carries_the_earlier_ones` covers a final report filed on time and one filed late with no separate notification. Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-compliance/src/cra.rs | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs index f6320f70f..6628243ab 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/cra.rs @@ -61,13 +61,18 @@ //! information has already been provided"; the early warnings do not. This //! module reads that as follows: //! -//! - the early warning is satisfied by the chain's first filing, whatever its -//! stage: every filing carries at least the early warning's information; -//! - the notification and the final report are each satisfied by the first -//! filing whose stage is that stage, or whose `also_provides` lists it. The -//! manufacturer declares in `also_provides` which later stages' information -//! a filing already carries, and nothing is inferred: a final report that -//! also carries the notification's information says so there. +//! - 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`]. @@ -195,6 +200,7 @@ pub struct Filing { 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, @@ -226,15 +232,11 @@ impl Filing { blake3::hash(&self.canonical_bytes()).to_hex().to_string() } - /// `true` iff this filing carries `stage`'s information. - /// - /// Every filing carries the early warning's. A filing carries the - /// notification's or the final report's when that is its own stage, or - /// when it lists that stage in `also_provides`. + /// `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 { - stage == FilingStage::EarlyWarning - || self.stage == stage - || self.also_provides.contains(&stage) + self.stage >= stage || self.also_provides.contains(&stage) } } @@ -641,8 +643,7 @@ impl FilingChain { /// 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`]): any filing for the early warning; for the - /// notification and the final report, a filing at that stage or one whose + /// ([`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 @@ -943,6 +944,28 @@ mod tests { 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. -- 2.54.0 (Apple Git-157)