Skip to main content

krabka_remote_storage/worm/
verify.rs

1//! Standalone verification of a WORM archive.
2//!
3//! The verifier reads an archive with nothing but a listing credential and the
4//! public half of the signing key. It needs no broker, no metadata manager, and
5//! no cluster, because everything it checks is written into the archive:
6//! [`crate::SegmentManifest`] carries the object digests, the chain position,
7//! and the signature. An auditor who holds a read-only role can therefore
8//! confirm the archive years after the broker that wrote it stopped running.
9//!
10//! # What each depth proves
11//!
12//! [`VerifyDepth::Shallow`] recomputes the chain, checks every signature, and
13//! confirms that every object a manifest names exists with the recorded size.
14//! It never downloads a segment body, so it is cheap enough to run continuously
15//! against a large archive. It cannot see a body that was replaced with
16//! different bytes of the same length.
17//!
18//! [`VerifyDepth::Deep`] downloads every object and recomputes its `SHA-256`.
19//! It is the only depth that catches a same-size substitution, and it reads the
20//! whole archive to do so.
21//!
22//! # What no depth proves
23//!
24//! Tail truncation. An attacker who removes the newest manifests, and every
25//! object they name, leaves a shorter chain that verifies perfectly. Give
26//! [`VerifyRequest::expect_head`] a head obtained outside the archive to close
27//! that gap.
28//!
29//! # Hostile input
30//!
31//! The archive is attacker-controlled. Every manifest read goes through
32//! [`read_capped`](krabka_object_store::read_capped) with the
33//! [`MAX_MANIFEST_BYTES`] cap, so an oversized object is refused by a `HEAD`
34//! before a byte is buffered, and no allocation is ever sized from a count a
35//! manifest supplies. A damaged or hostile archive produces a report, never a
36//! panic.
37
38use {
39    object_store::ObjectStore,
40    std::{
41        collections::{BTreeMap, BTreeSet, HashMap},
42        sync::Arc,
43    },
44};
45
46mod listing;
47mod manifest_read;
48mod objects;
49mod partition;
50mod report;
51mod signature;
52mod walk;
53
54#[cfg(test)]
55mod test_support;
56#[cfg(test)]
57mod tests;
58
59pub use self::{
60    manifest_read::MAX_MANIFEST_BYTES,
61    report::{
62        ArchiveVerifyReport, EpochSpan, ObjectProtectionReport, OffsetGap, PartitionVerifyReport,
63        VerifyBreak,
64    },
65};
66use {
67    self::{listing::list_archive, partition::verify_partition, report::VerifiedPartition},
68    crate::{
69        storage_manager::parse_partition_dir_name,
70        worm::{
71            error::WormError,
72            manifest::{ChainHead, ObjectEntry},
73        },
74    },
75};
76
77/// A deep WORM verification and the signed object claims it authenticated.
78///
79/// A reader must still hash bytes fetched after this run against [`Self::objects`]:
80/// the object store can replace a key between verification and consumption.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct AuthenticatedArchive {
83    report: ArchiveVerifyReport,
84    objects: BTreeMap<String, ObjectEntry>,
85}
86
87impl AuthenticatedArchive {
88    /// The ordinary WORM verification report.
89    #[must_use]
90    pub fn report(&self) -> &ArchiveVerifyReport { &self.report }
91
92    /// Signed size and digest claims, keyed by full object-store key.
93    #[must_use]
94    pub fn objects(&self) -> &BTreeMap<String, ObjectEntry> { &self.objects }
95}
96
97/// Public keys the verifier accepts, keyed by `key_id`.
98///
99/// A manifest names the key that signed it. The verifier checks the signature
100/// against the key this set holds under that name and never against the key the
101/// manifest carries, because an attacker who can rewrite a manifest can rewrite
102/// the key beside it.
103#[derive(Debug, Default)]
104pub struct TrustedManifestKeys {
105    keys: HashMap<String, Vec<u8>>,
106}
107
108impl TrustedManifestKeys {
109    /// A set that trusts one raw Ed25519 public key under `key_id`.
110    #[must_use]
111    pub fn single(key_id: String, public_key: Vec<u8>) -> Self {
112        Self::from_pairs([(key_id, public_key)])
113    }
114
115    /// A set built from every `(key_id, public_key)` pair.
116    ///
117    /// An archive that spans a key rotation carries manifests signed under
118    /// each key it was written with, so verifying it in one run means
119    /// trusting all of them at once. A later pair wins over an earlier one
120    /// naming the same `key_id`.
121    #[must_use]
122    pub fn from_pairs(pairs: impl IntoIterator<Item = (String, Vec<u8>)>) -> Self {
123        Self {
124            keys: pairs.into_iter().collect(),
125        }
126    }
127
128    /// The raw Ed25519 public key registered under `key_id`.
129    #[must_use]
130    pub fn get(&self, key_id: &str) -> Option<&[u8]> { self.keys.get(key_id).map(Vec::as_slice) }
131
132    /// `true` when no key is trusted.
133    ///
134    /// A run against an empty set still recomputes the chain and checks every
135    /// object, and it counts every signed manifest as untrusted. It proves
136    /// internal consistency and says nothing about who wrote the archive.
137    #[must_use]
138    pub fn is_empty(&self) -> bool { self.keys.is_empty() }
139}
140
141/// How hard the verifier works.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum VerifyDepth {
144    /// Read manifests, recompute the chain, check signatures, and `HEAD` every
145    /// referenced object for existence and size. No segment body is downloaded.
146    #[default]
147    Shallow,
148    /// Additionally download every object and recompute its `SHA-256`. The only
149    /// check that catches a same-size body substitution.
150    Deep,
151}
152
153/// What to verify, and against what expectation.
154#[derive(Debug, Clone, Default)]
155pub struct VerifyRequest {
156    /// Key prefix inside the store. `None` verifies the whole store.
157    pub prefix: Option<String>,
158    /// Verify only the partitions of this topic.
159    pub topic: Option<String>,
160    /// Verify only this partition index.
161    pub partition: Option<i32>,
162    /// How much of each object to read.
163    pub depth: VerifyDepth,
164    /// Expected head of the newest manifest, obtained independently of the
165    /// archive. Tail truncation leaves a shorter but internally perfect chain;
166    /// nothing inside the archive detects it.
167    ///
168    /// A partition whose tip differs from this head is reported as a break, so
169    /// a programmatic caller gets one `ok` to test. An archive with **no**
170    /// partition left to check is the one case this cannot report, because the
171    /// report has no partition to carry the break: check
172    /// [`ArchiveVerifyReport::partitions`] for emptiness as well. The
173    /// `krabka-worm-verify` binary leaves this `None` and compares the tips
174    /// itself, because it grades a tip mismatch as its own outcome and not as
175    /// tampering, and because it must also catch the emptied archive.
176    pub expect_head: Option<ChainHead>,
177    /// Treat an epoch restart as accepted rather than an attestation hole.
178    ///
179    /// Read by whoever grades the report. The chain walk always records every
180    /// epoch it finds, and an epoch restart on its own never clears
181    /// [`PartitionVerifyReport::ok`]: a restart is a hole in the attestation,
182    /// not evidence of a rewrite.
183    pub allow_epoch_restarts: bool,
184    /// Exact object keys authenticated by another signed boundary.
185    ///
186    /// Restore uses this for diskless WAL objects whose claims live in the
187    /// signed capture rather than a classic partition manifest. Every other
188    /// unreferenced object remains an orphan.
189    pub externally_authenticated_objects: BTreeSet<String>,
190}
191
192/// Verifies every partition the request selects.
193///
194/// The walk stops at the first break **per partition** and keeps going with the
195/// other partitions, so one damaged partition does not hide the state of the
196/// rest. It does no recovery and no truncation, so tail damage stays visible.
197/// The report is deterministic: two runs against an unchanged archive produce
198/// equal values.
199///
200/// # Errors
201///
202/// [`WormError`] **only** when the archive cannot be listed, a manifest object
203/// cannot be read, or — under [`VerifyDepth::Deep`] — a listed object body
204/// cannot be fetched. A *tampered* archive is a successful call with `ok ==
205/// false`: "I could not look" and "I looked and it is broken" are different
206/// outcomes, and the exit-code grading depends on the difference.
207#[tracing::instrument(
208    level = "info",
209    skip_all,
210    fields(
211        prefix = request.prefix.as_deref().unwrap_or(""),
212        depth = ?request.depth,
213    )
214)]
215pub async fn verify_archive(
216    store: &Arc<dyn ObjectStore>,
217    request: &VerifyRequest,
218    trusted: &TrustedManifestKeys,
219) -> Result<ArchiveVerifyReport, WormError> {
220    let AuthenticatedArchive { report, .. } = verify_archive_inner(store, request, trusted).await?;
221    Ok(report)
222}
223
224/// Deeply verifies an archive and returns the signed claims for every object
225/// accepted by the chain walk.
226///
227/// This always uses [`VerifyDepth::Deep`], regardless of `request.depth`.
228/// Callers must grade [`AuthenticatedArchive::report`] before consuming the
229/// claims, then hash the bytes they consume against [`AuthenticatedArchive::objects`].
230///
231/// # Errors
232///
233/// Returns [`WormError`] under the same conditions as [`verify_archive`].
234pub async fn authenticate_archive(
235    store: &Arc<dyn ObjectStore>,
236    request: &VerifyRequest,
237    trusted: &TrustedManifestKeys,
238) -> Result<AuthenticatedArchive, WormError> {
239    let mut request = request.clone();
240    request.depth = VerifyDepth::Deep;
241    verify_archive_inner(store, &request, trusted).await
242}
243
244async fn verify_archive_inner(
245    store: &Arc<dyn ObjectStore>,
246    request: &VerifyRequest,
247    trusted: &TrustedManifestKeys,
248) -> Result<AuthenticatedArchive, WormError> {
249    let mut listing = list_archive(store, request.prefix.as_deref()).await?;
250    let global_orphan_objects = listing
251        .iter()
252        .filter(|(dir, _)| {
253            dir.rsplit('/')
254                .next()
255                .and_then(parse_partition_dir_name)
256                .is_none()
257        })
258        .flat_map(|(_, entries)| entries.keys())
259        .filter(|key| {
260            !request.externally_authenticated_objects.contains(*key)
261                && !capture_namespace(key, request.prefix.as_deref())
262        })
263        .cloned()
264        .collect();
265    listing.retain(|dir, _| {
266        dir.rsplit('/')
267            .next()
268            .and_then(parse_partition_dir_name)
269            .is_some()
270    });
271    for entries in listing.values_mut() {
272        entries.retain(|key, _| !request.externally_authenticated_objects.contains(key));
273    }
274    listing.retain(|_, entries| !entries.is_empty());
275    let mut partitions = Vec::new();
276    let mut objects = BTreeMap::new();
277    for (dir, entries) in &listing {
278        if let Some(VerifiedPartition {
279            report,
280            objects: verified,
281        }) = verify_partition(store, dir, entries, request, trusted).await?
282        {
283            partitions.push(report);
284            objects.extend(verified);
285        }
286    }
287    // The listing is a `BTreeMap` keyed by directory, so the partitions are
288    // already in directory order. Sorting again states the guarantee locally.
289    partitions.sort_by(|a, b| a.partition_dir.cmp(&b.partition_dir));
290    Ok(AuthenticatedArchive {
291        report: ArchiveVerifyReport {
292            partitions,
293            global_orphan_objects,
294        },
295        objects,
296    })
297}
298
299fn capture_namespace(key: &str, prefix: Option<&str>) -> bool {
300    let relative = prefix
301        .and_then(|prefix| key.strip_prefix(prefix)?.strip_prefix('/'))
302        .unwrap_or(key);
303    relative
304        .split('/')
305        .any(|component| component == "restore-inputs")
306}