Skip to main content

krabka_remote_storage/worm/
archiver.rs

1//! Sealing a segment copy into a signed, chained manifest.
2//!
3//! The archiver is the cryptographic half of a WORM copy. The backend does
4//! the IO and reports what the store gave back for each object; the archiver
5//! turns those observations into the manifest bytes and the chain receipt.
6
7use {
8    crate::{
9        metadata::RemoteLogSegmentMetadata,
10        worm::{
11            chain::WormChainRecord,
12            config::WormConfig,
13            error::WormError,
14            manifest::{
15                ChainStamp, HexBytes, MANIFEST_FORMAT_VERSION, ManifestBody, ManifestSignature,
16                ObjectEntry, SegmentIdentity, SegmentManifest, manifest_head,
17                manifest_signing_bytes,
18            },
19        },
20    },
21    bytes::Bytes,
22    krabka_audit::signing::FileEd25519Signer,
23    std::sync::Arc,
24};
25
26/// Builds and signs the per-segment integrity manifest that backs WORM mode.
27///
28/// The archiver owns no store handle: the backend uploads the objects, hands
29/// the archiver what it observed for each, and gets back manifest bytes plus a
30/// receipt. Keeping the IO in the backend and the cryptography here makes the
31/// manifest logic testable without an object store.
32///
33/// `Debug` shows the key id and never the key. The archiver holds live
34/// private-key material, and a `Debug` that walked into it would put that key
35/// in every log line that formats a backend.
36#[derive(derive_more::Debug)]
37pub struct WormArchiver {
38    #[debug("{:?}", signer.as_ref().map(|signer| signer.key_id()))]
39    signer: Option<Arc<FileEd25519Signer>>,
40}
41
42/// One sealed manifest: the value, the bytes to write, and the receipt.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SealedManifest {
45    /// The manifest, body and signature together.
46    pub manifest: SegmentManifest,
47    /// The exact bytes to PUT at the `.manifest` key.
48    pub bytes: Bytes,
49    /// Receipt for `CopySegmentFinished`; `manifest_version_id` is `None`
50    /// until the PUT reports one.
51    pub receipt: WormChainRecord,
52}
53
54impl WormArchiver {
55    /// An archiver that signs with `signer`, or leaves manifests unsigned when
56    /// `signer` is `None`.
57    #[must_use]
58    pub fn new(signer: Option<Arc<FileEd25519Signer>>) -> Self { Self { signer } }
59
60    /// Loads the signing key that `cfg` names, if it names one.
61    ///
62    /// # Errors
63    ///
64    /// [`WormError::SigningKey`] when a key path is set without a key id (or
65    /// vice versa), or the file is not a PKCS#8 Ed25519 key.
66    pub fn from_config(cfg: &WormConfig) -> Result<Self, WormError> {
67        let signer = match (cfg.signing_key_path.as_ref(), cfg.signing_key_id.as_ref()) {
68            (None, None) => None,
69            (Some(path), Some(key_id)) => {
70                let signer = FileEd25519Signer::from_pkcs8_file(path, key_id.clone())
71                    .map_err(|e| WormError::SigningKey(e.to_string()))?;
72                Some(Arc::new(signer))
73            }
74            // A half-configured key is a misconfiguration and not a request
75            // for an unsigned archive. Unsigned is what an empty config asks
76            // for, and it must stay hard to reach by accident.
77            (Some(_), None) => {
78                return Err(WormError::SigningKey(
79                    "signing key path is set without a key id".to_owned(),
80                ));
81            }
82            (None, Some(_)) => {
83                return Err(WormError::SigningKey(
84                    "signing key id is set without a key path".to_owned(),
85                ));
86            }
87        };
88        Ok(Self::new(signer))
89    }
90
91    /// Seals one segment copy: builds the manifest, chains it, and signs it.
92    ///
93    /// `objects` must list every object the copy wrote, in the order it wrote
94    /// them.
95    ///
96    /// # Errors
97    ///
98    /// [`WormError::MissingChainStamp`] when the broker did not stamp a chain
99    /// position on `metadata` — a WORM backend refuses to write an unchained,
100    /// and therefore unverifiable, manifest.
101    ///
102    /// [`WormError::MalformedChainRecord`] when the stamp is present but does
103    /// not decode, and [`WormError::Codec`] when the manifest does not
104    /// serialise.
105    pub fn seal(
106        &self,
107        metadata: &RemoteLogSegmentMetadata,
108        objects: Vec<ObjectEntry>,
109    ) -> Result<SealedManifest, WormError> {
110        let stamped = metadata
111            .custom_metadata()
112            .ok_or(WormError::MissingChainStamp)
113            .and_then(WormChainRecord::from_custom_metadata)?;
114        // Only the position is taken from the incoming record. Any head it
115        // already carries belongs to an earlier manifest, never to this one.
116        let chain = ChainStamp {
117            epoch_id: stamped.epoch_id,
118            seq: stamped.seq,
119            prev_head: stamped.prev_head,
120        };
121
122        let body = ManifestBody {
123            format_version: MANIFEST_FORMAT_VERSION,
124            segment: SegmentIdentity::from_metadata(metadata),
125            objects,
126            chain,
127        };
128        let head = manifest_head(&body);
129        let signature = self.signer.as_ref().map(|signer| {
130            let message = manifest_signing_bytes(signer.key_id(), chain.epoch_id, chain.seq, head);
131            ManifestSignature {
132                key_id: signer.key_id().to_owned(),
133                public_key: HexBytes(signer.public_key()),
134                signature: HexBytes(signer.sign(&message)),
135            }
136        });
137
138        let manifest = SegmentManifest { body, signature };
139        // The chain covers `canonical_manifest_bytes`, not this JSON, so the
140        // JSON layout is free to be whatever reads best. Compact keeps the
141        // object small; a reader that wants it indented can re-serialise it.
142        let bytes = serde_json::to_vec(&manifest).map_err(|e| WormError::Codec(e.to_string()))?;
143        Ok(SealedManifest {
144            manifest,
145            bytes: Bytes::from(bytes),
146            receipt: WormChainRecord::request(chain).with_head(head),
147        })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use {
154        super::*,
155        crate::{
156            metadata::{
157                CustomMetadata, RemoteLogSegmentDetails, RemoteLogSegmentId, RemoteLogSegmentState,
158                TopicIdPartition,
159            },
160            worm::manifest::{
161                ChainHead, EpochId, ManifestSeq, Sha256Digest, verify_manifest_signature,
162            },
163        },
164        assert2::{assert, check},
165        krabka_ids::LeaderEpoch,
166        ring::{rand::SystemRandom, signature::Ed25519KeyPair},
167        std::{fs, path::PathBuf},
168        tempfile::TempDir,
169        uuid::Uuid,
170    };
171
172    const KEY_ID: &str = "worm-archiver-key";
173
174    fn epoch() -> EpochId { EpochId(Uuid::from_u128(0xabcd)) }
175
176    fn metadata(segment_id: u128, stamp: Option<ChainStamp>) -> RemoteLogSegmentMetadata {
177        let md = RemoteLogSegmentMetadata::new(
178            RemoteLogSegmentId::new(
179                TopicIdPartition::new(Uuid::from_u128(1), "orders", 0),
180                Uuid::from_u128(segment_id),
181            ),
182            0,
183            99,
184            1_713_000_000_000,
185            1,
186            1_713_000_001_000,
187            RemoteLogSegmentDetails::new(
188                4096,
189                RemoteLogSegmentState::CopySegmentStarted,
190                maplit::btreemap! {LeaderEpoch(0) => 0},
191            ),
192        )
193        .unwrap();
194        match stamp {
195            Some(stamp) => md.with_custom_metadata(
196                WormChainRecord::request(stamp)
197                    .to_custom_metadata()
198                    .unwrap(),
199            ),
200            None => md,
201        }
202    }
203
204    fn objects() -> Vec<ObjectEntry> {
205        vec![ObjectEntry {
206            suffix: ".log".to_owned(),
207            key: "orders-0/00000000000000000000.log".to_owned(),
208            size_bytes: 10,
209            sha256: Sha256Digest::of(b"0123456789"),
210            e_tag: Some("0".to_owned()),
211            version_id: None,
212            create_precondition: true,
213        }]
214    }
215
216    fn signer() -> Arc<FileEd25519Signer> {
217        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
218        let signer =
219            FileEd25519Signer::from_pkcs8_bytes(pkcs8.as_ref(), KEY_ID.to_owned()).unwrap();
220        Arc::new(signer)
221    }
222
223    /// Writes a throwaway PKCS#8 Ed25519 key, for the `from_config` path that
224    /// only takes a path.
225    fn key_file(dir: &TempDir) -> PathBuf {
226        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
227        let path = dir.path().join("worm.pk8");
228        fs::write(&path, pkcs8.as_ref()).unwrap();
229        path
230    }
231
232    #[test]
233    fn seal_head_chains_from_the_stamp() {
234        let stamp = ChainStamp {
235            epoch_id: epoch(),
236            seq: ManifestSeq(7),
237            prev_head: ChainHead([3u8; 32]),
238        };
239        let sealed = WormArchiver::new(Some(signer()))
240            .seal(&metadata(10, Some(stamp)), objects())
241            .unwrap();
242
243        check!(sealed.manifest.body.chain == stamp);
244        check!(sealed.manifest.body.objects == objects());
245        check!(sealed.manifest.body.format_version == MANIFEST_FORMAT_VERSION);
246        // The receipt's head is the head of the manifest it accompanies, and
247        // the receipt repeats the position it was sealed at.
248        check!(
249            sealed.receipt
250                == WormChainRecord {
251                    epoch_id: stamp.epoch_id,
252                    seq: stamp.seq,
253                    prev_head: stamp.prev_head,
254                    head: Some(manifest_head(&sealed.manifest.body)),
255                    manifest_version_id: None,
256                }
257        );
258        // The bytes are the manifest, byte for byte.
259        let decoded: SegmentManifest = serde_json::from_slice(&sealed.bytes).unwrap();
260        check!(decoded == sealed.manifest);
261    }
262
263    #[test]
264    fn seal_without_signer_leaves_manifest_unsigned_but_chained() {
265        let stamp = ChainStamp {
266            epoch_id: epoch(),
267            seq: ManifestSeq(0),
268            prev_head: ChainHead::GENESIS,
269        };
270        let sealed = WormArchiver::new(None)
271            .seal(&metadata(11, Some(stamp)), objects())
272            .unwrap();
273
274        check!(sealed.manifest.signature.is_none());
275        check!(sealed.manifest.body.chain == stamp);
276        check!(sealed.receipt.head == Some(manifest_head(&sealed.manifest.body)));
277        // An unsigned manifest still fails signature verification: it proves
278        // nothing about who wrote it.
279        check!(!verify_manifest_signature(&sealed.manifest, &[]));
280    }
281
282    #[test]
283    fn seal_refuses_metadata_with_no_chain_stamp() {
284        let err = WormArchiver::new(Some(signer()))
285            .seal(&metadata(12, None), objects())
286            .unwrap_err();
287
288        check!(matches!(err, WormError::MissingChainStamp));
289    }
290
291    #[test]
292    fn seal_refuses_metadata_with_an_undecodable_chain_stamp() {
293        let md = metadata(13, None).with_custom_metadata(CustomMetadata(b"not json".to_vec()));
294
295        let err = WormArchiver::new(None).seal(&md, objects()).unwrap_err();
296
297        check!(matches!(err, WormError::MalformedChainRecord(_)));
298    }
299
300    #[test]
301    fn two_sealed_manifests_chain() {
302        let archiver = WormArchiver::new(Some(signer()));
303        let first = ChainStamp {
304            epoch_id: epoch(),
305            seq: ManifestSeq(0),
306            prev_head: ChainHead::GENESIS,
307        };
308
309        let a = archiver
310            .seal(&metadata(20, Some(first)), objects())
311            .unwrap();
312        assert!(let Some(second) = a.receipt.next_stamp());
313        let b = archiver
314            .seal(&metadata(21, Some(second)), objects())
315            .unwrap();
316
317        let a_head = manifest_head(&a.manifest.body);
318        check!(b.manifest.body.chain.prev_head == a_head);
319        check!(b.manifest.body.chain.seq == ManifestSeq(1));
320        check!(b.manifest.body.chain.epoch_id == first.epoch_id);
321        // Two distinct segments at distinct positions must not produce the
322        // same head, or the chain would say nothing.
323        check!(manifest_head(&b.manifest.body) != a_head);
324    }
325
326    #[test]
327    fn sealed_manifest_signature_verifies_with_the_signing_key() {
328        let signer = signer();
329        let public_key = signer.public_key();
330        let sealed = WormArchiver::new(Some(signer))
331            .seal(
332                &metadata(
333                    30,
334                    Some(ChainStamp {
335                        epoch_id: epoch(),
336                        seq: ManifestSeq(2),
337                        prev_head: ChainHead([9u8; 32]),
338                    }),
339                ),
340                objects(),
341            )
342            .unwrap();
343
344        assert!(let Some(signature) = sealed.manifest.signature.as_ref());
345        check!(signature.key_id == KEY_ID);
346        check!(signature.public_key == HexBytes(public_key.clone()));
347        check!(verify_manifest_signature(&sealed.manifest, &public_key));
348
349        // The signature covers the body: an edited object list breaks it.
350        let mut tampered = sealed.manifest.clone();
351        tampered.body.objects[0].size_bytes += 1;
352        check!(!verify_manifest_signature(&tampered, &public_key));
353    }
354
355    #[test]
356    fn from_config_loads_a_key_only_when_both_halves_are_set() {
357        let dir = TempDir::new().unwrap();
358        let path = key_file(&dir);
359
360        check!(
361            WormArchiver::from_config(&WormConfig::default())
362                .unwrap()
363                .signer
364                .is_none(),
365            "empty config"
366        );
367        let both = WormConfig {
368            signing_key_path: Some(path),
369            signing_key_id: Some(KEY_ID.to_owned()),
370            write_only: false,
371        };
372        assert!(let Ok(archiver) = WormArchiver::from_config(&both));
373        assert!(let Some(signer) = archiver.signer.as_ref());
374        check!(signer.key_id() == KEY_ID);
375    }
376
377    #[yare::parameterized(
378        path_without_id = { true, false, true },
379        id_without_path = { false, true, true },
380        missing_key_file = { true, true, false },
381    )]
382    fn from_config_rejects_incomplete_or_missing_signing_keys(
383        has_path: bool,
384        has_id: bool,
385        key_file_exists: bool,
386    ) {
387        let dir = TempDir::new().unwrap();
388        let path = if key_file_exists {
389            key_file(&dir)
390        } else {
391            dir.path().join("absent.pk8")
392        };
393        let cfg = WormConfig {
394            signing_key_path: has_path.then_some(path),
395            signing_key_id: has_id.then(|| KEY_ID.to_owned()),
396            write_only: false,
397        };
398        check!(matches!(
399            WormArchiver::from_config(&cfg),
400            Err(WormError::SigningKey(_))
401        ));
402    }
403
404    #[test]
405    fn archiver_debug_names_the_key_and_shows_no_key_material() {
406        let signer = signer();
407        let public_key = hex::encode(signer.public_key());
408        let rendered = format!("{:?}", WormArchiver::new(Some(signer)));
409
410        check!(rendered.contains(KEY_ID));
411        check!(!rendered.contains(&public_key));
412        check!(format!("{:?}", WormArchiver::new(None)).contains("None"));
413    }
414}