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}