Skip to main content

aven_core/sync/shared_state/package/
publication.rs

1//! Bounded publication-package codec and keyless completeness checks.
2//!
3//! Persistence belongs to the never-dispatched database package owner. The capture
4//! candidate ID is the bootstrap ID, not a freshly minted specimen identity.
5//! This codec provides no authorization: membership predecessor bytes are context
6//! only. `seed_claim::Publication` authenticates that context and the descriptor;
7//! `bootstrap_staging` owns server publication.
8//!
9//! # Profile 1 byte contract
10//!
11//! Integers are unsigned big-endian. Byte strings use a U64 length. IDs in
12//! descriptor/chunk context are raw 32 bytes; catalog domain IDs are nonempty
13//! UTF-8 byte strings, at most 256 bytes. Flags are exactly 0 or 1. No compression.
14//! All parsers reject trailing bytes. SHA-256 commits exact bytes, not decoded JSON.
15//!
16//! * Descriptor: `AVBP || U16(2) || U8(1 suite)`, vault, stream, generation,
17//!   capture bootstrap, predecessor membership commitment, U64 prefix count,
18//!   three catalog declarations in class order, then manifest artifact descriptor.
19//!   At most `MAX_DESCRIPTOR_BYTES`. Other versions are refused as unsupported,
20//!   never reinterpreted.
21//! * Declaration: U8 class, U64 record count, U64 byte length, aggregate SHA-256,
22//!   then one SHA-256 per transfer slice. Slices are contiguous 1 MiB byte ranges
23//!   with a final remainder; their count follows from the length. A hash-valid
24//!   slice proves nothing about its catalog until the complete bytes match the
25//!   aggregate and decode canonically.
26//! * Catalog: `AVBC || U16(1) || U8(class) || U64(record count)`, followed by
27//!   length-prefixed records. Class 1 has one U8(1 state) + artifact descriptor;
28//!   class 2 records are U64 rank + ID, ordered densely from 1. Class 3 groups
29//!   object, parent, reference records, with U8 discriminators 1, 2, 3.
30//! * Artifact: U64 plaintext total, aggregate SHA-256, U64 chunk count, followed
31//!   by U64 index, U64 record length, record SHA-256, raw nonce[24] per chunk.
32//!   The recipe reconstructs the existing 198-byte authenticated chunk header.
33//! * Object: ID[32], U8 selection (1 current, 2 extra), artifact. Both selections
34//!   require bytes. Sort by raw ID. Context is inherited from the descriptor.
35//! * Parent: workspace ID, task ID, deleted flag, protected flag, version-present
36//!   flag and optional version ID. Sort by (workspace, task) UTF-8 bytes.
37//! * Reference: workspace ID, task ID, reference ID, deleted flag, mapped flag,
38//!   optional object[32]. Sort by (workspace, reference). Unmapped references
39//!   preserve unavailable metadata and create no byte or download obligation.
40//! * Encrypted state: `AVBD || U16(1)`, then every section in `domain.rs` order.
41//!   Each section has U16 type, U64 count and length-prefixed strict JSON rows,
42//!   sorted by complete encoded payload bytes. Struct field order, explicit nulls,
43//!   UTF-8, and serde_json escaping are canonical. Unknown/duplicate/missing
44//!   fields, alternate encodings and duplicate rows are rejected. Rows can span
45//!   transport chunks. Export versions/defaults and device inventory flags are
46//!   not wire fields; internal conversions only adapt shared domain validators.
47//! * Encrypted manifest: `AVBM || U16(1)`, descriptor through catalog declarations
48//!   (excluding its own artifact), U16 section count, then U16 section, U64 row
49//!   count, U64 encoded section length. This binds domain totals and catalogs
50//!   without a circular ciphertext commitment.
51//!
52//! Hard codec resource caps: 256 MiB state, 1 MiB manifest, 16 MiB per catalog,
53//! 1,000,000 total domain rows, 1,000,000 records per catalog, 1,024 selected
54//! images, 25 MiB per image and 256 MiB image plaintext in aggregate. Ciphertext
55//! includes exactly 222 bytes overhead per chunk. Prefix counts also leave room
56//! for a positive signed-64-bit tail position. These are refusal limits, not
57//! domain validity rules or production transport/reservation policy. This
58//! in-memory codec has bounded multiple-copy overhead and is not a streaming
59//! installer or a measured mobile resource profile.
60
61pub(crate) use aven_protocol::artifact::catalog;
62pub(crate) use aven_protocol::artifact::codec;
63mod domain;
64#[cfg(feature = "test-support")]
65pub(crate) mod fuzz;
66
67pub use codec::MAX_DESCRIPTOR_BYTES;
68
69/// Version of the encrypted AVBD domain, independent of plaintext sync/export.
70pub const DOMAIN_VERSION: u32 = 1;
71pub mod download;
72mod projection;
73pub(crate) mod staging;
74
75use super::super::{NeverDispatchedLocalSharedCapture, SharedStateCapture};
76use super::{self as crypto, LocalSharedStatePackageKey};
77use catalog::{Artifact, Declaration, Image, Images};
78use codec::*;
79use zeroize::Zeroizing;
80
81pub use aven_protocol::artifact::Error;
82use aven_protocol::artifact::Result;
83
84/// Exact upload components. Catalog array order is data, prefix, images.
85/// Records within artifacts are in canonical index order, images in object order.
86/// Clear bytes contain only the permitted metadata; encrypted bytes are opaque.
87#[derive(Clone, PartialEq, Eq)]
88pub struct Package {
89    pub descriptor: Vec<u8>,
90    pub catalogs: [Vec<u8>; 3],
91    pub state: Vec<Vec<u8>>,
92    pub manifest: Vec<Vec<u8>>,
93    pub images: Vec<ImageRecords>,
94}
95
96#[derive(Clone, PartialEq, Eq)]
97pub struct ImageRecords {
98    pub object_id: [u8; 32],
99    pub records: Vec<Vec<u8>>,
100}
101
102/// Structural completeness only. It does not authenticate membership or prove
103/// domain correctness, durable storage, quota admission, or permission to publish.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct Completeness {
106    pub prefix_count: u64,
107    pub image_count: u64,
108    pub ciphertext_bytes: u64,
109}
110
111use aven_protocol::artifact::{Descriptor, decode_state_catalog, state_catalog};
112
113fn manifest_plaintext(d: &Descriptor, stats: &domain::Stats) -> Vec<u8> {
114    let mut out = b"AVBM\0\x01".to_vec();
115    out.extend_from_slice(&d.binding());
116    out.extend_from_slice(&(domain::SECTIONS as u16).to_be_bytes());
117    for (index, (count, length)) in stats.iter().enumerate() {
118        out.extend_from_slice(&(index as u16 + 1).to_be_bytes());
119        u64_bytes(&mut out, *count);
120        u64_bytes(&mut out, *length);
121    }
122    out
123}
124
125/// Encrypts the domain encoding as it is produced, hashing the plaintext so
126/// the finished ciphertext can be checked against exactly these bytes.
127struct StateOut {
128    writer: crypto::ArtifactWriter,
129    digest: sha2::Sha256,
130    len: usize,
131    failed: bool,
132}
133
134impl domain::Out for StateOut {
135    fn begin(&mut self, total: usize) {
136        self.failed |= self.writer.begin(total).is_err();
137    }
138    fn len(&self) -> usize {
139        self.len
140    }
141    fn put(&mut self, bytes: &[u8]) {
142        self.len += bytes.len();
143        sha2::Digest::update(&mut self.digest, bytes);
144        self.failed = self.failed || self.writer.write(bytes).is_err();
145    }
146}
147
148/// One authenticated selected image without ownership of its ciphertext records.
149pub(super) struct ImageSummary {
150    pub(super) sha256: String,
151    pub(super) object_id: [u8; 32],
152    pub(super) artifact: Artifact,
153}
154
155impl ImageSummary {
156    pub(super) fn authenticated(
157        image: &crypto::EncryptedImage,
158        context: crypto::LocalSharedStatePackageContext,
159        stream: [u8; 32],
160        key: &LocalSharedStatePackageKey,
161    ) -> Result<Self> {
162        let image_key = crypto::derive_image_key(key, context, image.object_id)
163            .map_err(|_| Error::Authentication)?;
164        let reader = crypto::ArtifactReader::new(
165            &image.artifact,
166            context,
167            stream,
168            image.object_id,
169            1,
170            0,
171            &image_key,
172            size(IMAGE_LIMIT)?,
173        )
174        .map_err(|_| Error::Authentication)?;
175        valid(hex::encode(hash_plaintext(reader)?) == image.sha256)?;
176        Ok(Self {
177            sha256: image.sha256.clone(),
178            object_id: image.object_id,
179            artifact: Artifact::from_encrypted(&image.artifact)?,
180        })
181    }
182}
183
184/// Constructs tentative metadata only for the durable owner's first freeze.
185/// Image ciphertext has already been authenticated while each image was resident.
186pub(super) fn build_metadata(
187    capture: &NeverDispatchedLocalSharedCapture,
188    context: crypto::LocalSharedStatePackageContext,
189    images: &[ImageSummary],
190    key: &LocalSharedStatePackageKey,
191    membership_predecessor: [u8; 32],
192) -> Result<(Package, AttachmentIndex)> {
193    let stream_id =
194        crypto::decode_context_id(capture.stream_id(), "stream").map_err(|_| Error::Invalid)?;
195    let bootstrap = crypto::decode_context_id(capture.candidate_id(), "candidate")
196        .map_err(|_| Error::Invalid)?;
197    let objects = images
198        .iter()
199        .map(|image| (image.sha256.as_str(), image.object_id))
200        .collect::<std::collections::HashMap<_, _>>();
201    let mappings = capture
202        .images
203        .iter()
204        .map(|r| domain::Mapping {
205            sha256: r.sha256.clone(),
206            classification: r.classification.clone(),
207            object: objects.get(r.sha256.as_str()).copied(),
208        })
209        .collect::<Vec<_>>();
210    let state_key = crypto::derive_bootstrap_class_key(key, context, stream_id, bootstrap, 1)
211        .map_err(|_| Error::Authentication)?;
212    let mut out = StateOut {
213        writer: crypto::ArtifactWriter::new(context, stream_id, bootstrap, 2, 1, &state_key)
214            .map_err(|_| Error::Authentication)?,
215        digest: sha2::Digest::new(),
216        len: 0,
217        failed: false,
218    };
219    let stats = domain::encode_into(&mut out, &capture.capture.snapshot.tables, &mappings)?;
220    valid(!out.failed)?;
221    let encoded = <[u8; 32]>::from(sha2::Digest::finalize(out.digest));
222    let state = out.writer.finish().map_err(|_| Error::Authentication)?;
223    let objects = images
224        .iter()
225        .map(|image| {
226            let mapping = mappings
227                .iter()
228                .find(|m| m.object == Some(image.object_id))
229                .ok_or(Error::Invalid)?;
230            Ok(Image {
231                id: image.object_id,
232                selection: if mapping.classification == "current_selected" {
233                    1
234                } else {
235                    2
236                },
237                artifact: image.artifact.clone(),
238            })
239        })
240        .collect::<Result<Vec<_>>>()?;
241    let image_catalog = projection::images(
242        &capture.capture.snapshot.tables,
243        &mappings,
244        Images {
245            objects,
246            parents: Vec::new(),
247            references: Vec::new(),
248        },
249    )?;
250    let prefix = projection::prefix(&capture.capture.snapshot.tables)?;
251    let catalogs = [
252        state_catalog(&Artifact::from_encrypted(&state)?)?,
253        catalog::prefix_encode(&prefix)?,
254        image_catalog.encode()?,
255    ];
256    let mut d = Descriptor {
257        vault: context.vault_id,
258        stream: stream_id,
259        generation: context.generation_id,
260        bootstrap,
261        membership: membership_predecessor,
262        prefix: number(prefix.len())?,
263        catalogs: [
264            Declaration::new(&catalogs[0], 1)?,
265            Declaration::new(&catalogs[1], 2)?,
266            Declaration::new(&catalogs[2], 3)?,
267        ],
268        manifest: Artifact {
269            total: 0,
270            aggregate: [0; 32],
271            chunks: Vec::new(),
272        },
273    };
274    let manifest_key = crypto::derive_bootstrap_class_key(key, context, stream_id, bootstrap, 2)
275        .map_err(|_| Error::Authentication)?;
276    let manifest = crypto::encrypt_artifact(
277        &manifest_plaintext(&d, &stats),
278        context,
279        stream_id,
280        bootstrap,
281        2,
282        2,
283        &manifest_key,
284    )
285    .map_err(|_| Error::Authentication)?;
286    d.manifest = Artifact::from_encrypted(&manifest)?;
287    let package = Package {
288        descriptor: d.encode()?,
289        catalogs,
290        state: state.into_records(),
291        manifest: manifest.into_records(),
292        images: Vec::new(),
293    };
294    let index = check_capture(
295        &package,
296        capture,
297        key,
298        membership_predecessor,
299        StateCheck::Encoded {
300            digest: encoded,
301            mappings: &mappings,
302            stats: &stats,
303        },
304        false,
305    )?;
306    Ok((package, index))
307}
308
309/// Full-package adapter used by codec and persistence tests.
310#[cfg(test)]
311pub(super) fn build(
312    capture: &NeverDispatchedLocalSharedCapture,
313    context: crypto::LocalSharedStatePackageContext,
314    images: Vec<crypto::EncryptedImage>,
315    key: &LocalSharedStatePackageKey,
316    membership_predecessor: [u8; 32],
317) -> Result<(Package, AttachmentIndex)> {
318    let stream =
319        crypto::decode_context_id(capture.stream_id(), "stream").map_err(|_| Error::Invalid)?;
320    let summaries = images
321        .iter()
322        .map(|image| ImageSummary::authenticated(image, context, stream, key))
323        .collect::<Result<Vec<_>>>()?;
324    let (mut package, _) =
325        build_metadata(capture, context, &summaries, key, membership_predecessor)?;
326    package.images = images
327        .into_iter()
328        .map(|image| ImageRecords {
329            object_id: image.object_id,
330            records: image.artifact.into_records(),
331        })
332        .collect();
333    package.images.sort_by_key(|image| image.object_id);
334    let index = authenticate_capture(&package, capture, key, membership_predecessor)?;
335    Ok((package, index))
336}
337
338/// Authenticates all domain, manifest and image bytes under the supplied key and
339/// expected context. This does not establish membership authorization.
340pub fn authenticate(
341    package: &Package,
342    key: &LocalSharedStatePackageKey,
343    context: crypto::LocalSharedStatePackageContext,
344    stream_id: [u8; 32],
345    bootstrap_id: [u8; 32],
346    membership_predecessor: [u8; 32],
347) -> Result<()> {
348    let d = Descriptor::decode(&package.descriptor)?;
349    valid(
350        d.context() == context
351            && d.stream == stream_id
352            && d.bootstrap == bootstrap_id
353            && d.membership == membership_predecessor,
354    )?;
355    decrypt_domain(package, key)?;
356    Ok(())
357}
358
359pub(super) fn context_and_membership(
360    descriptor: &[u8],
361) -> Result<(crypto::LocalSharedStatePackageContext, [u8; 32])> {
362    let d = Descriptor::decode(descriptor)?;
363    Ok((d.context(), d.membership))
364}
365
366/// Checks exact clear commitments, framing, canonical coverage and all selected
367/// image-byte obligations without a key. Catalogs are aggregate-verified before
368/// any records are trusted. No partial catalog can establish completeness.
369pub fn validate_keyless(package: &Package) -> Result<Completeness> {
370    keyless(package).map(|(completeness, _, _)| completeness)
371}
372
373/// [`validate_keyless`], also returning the verified descriptor and image
374/// catalog.
375fn keyless(package: &Package) -> Result<(Completeness, Descriptor, Images)> {
376    let metadata = download::MetadataView::from(package);
377    let (d, images) = validate_metadata(&metadata)?;
378    valid(images.objects.len() == package.images.len())?;
379    for (object, records) in images.objects.iter().zip(&package.images) {
380        valid(object.id == records.object_id)?;
381        object
382            .artifact
383            .verify(&records.records, d.context(), d.stream, object.id, 1, 0)?;
384    }
385    let ciphertext_bytes = std::iter::once(&package.state)
386        .chain(std::iter::once(&package.manifest))
387        .chain(package.images.iter().map(|i| &i.records))
388        .flatten()
389        .try_fold(0, |total, record| add(total, number(record.len())?))?;
390    let completeness = Completeness {
391        prefix_count: d.prefix,
392        image_count: number(images.objects.len())?,
393        ciphertext_bytes,
394    };
395    Ok((completeness, d, images))
396}
397
398fn validate_metadata(metadata: &download::MetadataView<'_>) -> Result<(Descriptor, Images)> {
399    let d = Descriptor::decode(metadata.descriptor)?;
400    for (i, bytes) in metadata.catalogs.iter().enumerate() {
401        d.catalogs[i].verify(bytes, i as u8 + 1)?;
402    }
403    let state = decode_state_catalog(&metadata.catalogs[0])?;
404    catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?;
405    let images = Images::decode(&metadata.catalogs[2])?;
406    state.verify(metadata.state, d.context(), d.stream, d.bootstrap, 2, 1)?;
407    d.manifest
408        .verify(metadata.manifest, d.context(), d.stream, d.bootstrap, 2, 2)?;
409    Ok((d, images))
410}
411
412fn decrypt_domain(
413    package: &Package,
414    key: &LocalSharedStatePackageKey,
415) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
416    decrypt_domain_images(package, key, |_, _| {})
417}
418
419fn decrypt_domain_images(
420    package: &Package,
421    key: &LocalSharedStatePackageKey,
422    mut accept_image: impl FnMut(&str, Zeroizing<Vec<u8>>),
423) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
424    validate_keyless(package)?;
425    let metadata = download::MetadataView::from(package);
426    let (capture, mappings) = decrypt_metadata(&metadata, key)?;
427    let (d, images) = validate_metadata(&metadata)?;
428    for (object, records) in images.objects.iter().zip(&package.images) {
429        let mapping = mappings
430            .iter()
431            .find(|m| m.object == Some(object.id))
432            .ok_or(Error::Invalid)?;
433        let image_key = crypto::derive_image_key(key, d.context(), object.id)
434            .map_err(|_| Error::Authentication)?;
435        let bytes = Zeroizing::new(
436            crypto::decrypt_artifact(
437                &object.artifact.encrypted(&records.records),
438                d.context(),
439                d.stream,
440                object.id,
441                1,
442                0,
443                &image_key,
444                size(IMAGE_LIMIT)?,
445            )
446            .map_err(|_| Error::Authentication)?,
447        );
448        valid(hex::encode(crate::sync::codec::hash(&bytes)) == mapping.sha256)?;
449        accept_image(&mapping.sha256, bytes);
450    }
451    Ok((capture, mappings))
452}
453
454fn decrypt_metadata(
455    metadata: &download::MetadataView<'_>,
456    key: &LocalSharedStatePackageKey,
457) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
458    validate_metadata(metadata)?;
459    #[cfg(test)]
460    crate::sync::shared_state::counters::keyed_pass();
461    let d = Descriptor::decode(metadata.descriptor)?;
462    let state = decode_state_catalog(&metadata.catalogs[0])?;
463    let state_key = crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 1)
464        .map_err(|_| Error::Authentication)?;
465    let plaintext = Zeroizing::new(
466        crypto::decrypt_artifact(
467            &state.encrypted(metadata.state),
468            d.context(),
469            d.stream,
470            d.bootstrap,
471            2,
472            1,
473            &state_key,
474            size(STATE_LIMIT)?,
475        )
476        .map_err(|_| Error::Authentication)?,
477    );
478    let (tables, mappings, stats) = domain::decode(&plaintext)?;
479    let manifest_key =
480        crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 2)
481            .map_err(|_| Error::Authentication)?;
482    let manifest = Zeroizing::new(
483        crypto::decrypt_artifact(
484            &d.manifest.encrypted(metadata.manifest),
485            d.context(),
486            d.stream,
487            d.bootstrap,
488            2,
489            2,
490            &manifest_key,
491            size(CHUNK)?,
492        )
493        .map_err(|_| Error::Authentication)?,
494    );
495    valid(*manifest == manifest_plaintext(&d, &stats))?;
496    let capture = capture(tables);
497    capture.validate().map_err(|_| Error::Invalid)?;
498    valid(
499        projection::prefix(&capture.snapshot.tables)?
500            == catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?,
501    )?;
502    let images = Images::decode(&metadata.catalogs[2])?;
503    let expected = projection::images(
504        &capture.snapshot.tables,
505        &mappings,
506        Images {
507            objects: images.objects.clone(),
508            parents: Vec::new(),
509            references: Vec::new(),
510        },
511    )?;
512    valid(images == expected)?;
513    Ok((capture, mappings))
514}
515
516/// Decoded domain tables as a capture for the shared domain validators.
517fn capture(tables: crate::data_safety::export_types::ExportTables) -> SharedStateCapture {
518    // Select the internal validation adapter version, not a wire version.
519    let version = if tables.shared_history_provenance.is_empty() {
520        3
521    } else {
522        4
523    };
524    SharedStateCapture {
525        snapshot: crate::data_safety::export_types::AvenExport {
526            format: "aven-export".into(),
527            version,
528            exported_at: String::new(),
529            schema_version: 0,
530            blobs_included: false,
531            tables,
532        },
533    }
534}
535
536/// Authenticates and decrypts the complete package into installable state.
537pub(super) fn decrypt_capture(
538    package: &Package,
539    key: &LocalSharedStatePackageKey,
540) -> Result<SharedStateCapture> {
541    Ok(decrypt_domain(package, key)?.0)
542}
543
544/// Returns every selected image's private hash and authenticated plaintext.
545#[cfg(test)]
546pub(super) fn decrypt_images(
547    package: &Package,
548    key: &LocalSharedStatePackageKey,
549) -> Result<Vec<(String, Zeroizing<Vec<u8>>)>> {
550    let mut images = Vec::new();
551    decrypt_domain_images(package, key, |sha256, bytes| {
552        images.push((sha256.to_string(), bytes));
553    })?;
554    Ok(images)
555}
556
557/// Client-side check against the immutable source, not live rows. Also checks
558/// encrypted manifest/catalog agreement and private image hashes. This is not
559/// join installation and does not establish any trusted membership anchor.
560pub fn validate_against_capture(
561    package: &Package,
562    capture: &NeverDispatchedLocalSharedCapture,
563    key: &LocalSharedStatePackageKey,
564    membership_predecessor: [u8; 32],
565) -> Result<()> {
566    authenticate_capture(package, capture, key, membership_predecessor).map(drop)
567}
568
569/// The one keyed pass over a frozen package. The decrypted state must equal
570/// the canonical encoding of the capture under the package's own private
571/// mappings, so the state is never decoded into a second copy of the tables.
572/// The state and images are decrypted one chunk at a time.
573pub(crate) fn authenticate_capture(
574    package: &Package,
575    capture: &NeverDispatchedLocalSharedCapture,
576    key: &LocalSharedStatePackageKey,
577    membership_predecessor: [u8; 32],
578) -> Result<AttachmentIndex> {
579    check_capture(
580        package,
581        capture,
582        key,
583        membership_predecessor,
584        StateCheck::Capture,
585        true,
586    )
587}
588
589/// How [`check_capture`] establishes that the decrypted state is the
590/// capture's canonical encoding.
591enum StateCheck<'a> {
592    /// Re-encode the capture under the mappings read from the state and
593    /// compare it with the state as both are produced.
594    Capture,
595    /// The state was encrypted from one encoding pass over this capture, which
596    /// wrote these mappings, gave these section stats and hashed to `digest`.
597    /// The state must decrypt to bytes with that hash.
598    Encoded {
599        digest: [u8; 32],
600        mappings: &'a [domain::Mapping],
601        stats: &'a domain::Stats,
602    },
603}
604
605/// Hands out a state reader's plaintext to the domain decoder.
606struct Plain<'r, 'a>(&'r mut crypto::ArtifactReader<'a>);
607
608impl domain::In for Plain<'_, '_> {
609    fn remaining(&self) -> u64 {
610        self.0.remaining()
611    }
612    fn read(&mut self, into: &mut [u8]) -> Result<()> {
613        let mut filled = 0;
614        while filled < into.len() {
615            let piece = self
616                .0
617                .take(into.len() - filled)
618                .map_err(|_| Error::Authentication)?;
619            valid(!piece.is_empty())?;
620            into[filled..filled + piece.len()].copy_from_slice(piece);
621            filled += piece.len();
622        }
623        Ok(())
624    }
625    fn skip(&mut self, n: u64) -> Result<()> {
626        let mut left = size(n)?;
627        while left > 0 {
628            let piece = self.0.take(left).map_err(|_| Error::Authentication)?;
629            valid(!piece.is_empty())?;
630            left -= piece.len();
631        }
632        Ok(())
633    }
634}
635
636/// Compares the encoding with a state reader's plaintext as both are
637/// produced, so neither is ever held whole.
638struct PlainMatcher<'r, 'a> {
639    reader: &'r mut crypto::ArtifactReader<'a>,
640    written: usize,
641    equal: bool,
642    failed: bool,
643}
644
645impl domain::Out for PlainMatcher<'_, '_> {
646    fn begin(&mut self, total: usize) {
647        self.equal &= total as u64 == self.reader.remaining();
648    }
649    fn len(&self) -> usize {
650        self.written
651    }
652    fn put(&mut self, mut bytes: &[u8]) {
653        self.written += bytes.len();
654        while self.equal && !bytes.is_empty() {
655            match self.reader.take(bytes.len()) {
656                Ok(piece) if !piece.is_empty() => {
657                    self.equal = piece == &bytes[..piece.len()];
658                    bytes = &bytes[piece.len()..];
659                }
660                Ok(_) => self.equal = false,
661                Err(_) => {
662                    self.failed = true;
663                    self.equal = false;
664                }
665            }
666        }
667    }
668}
669
670/// Encodes the capture tables under `mappings`, comparing the encoding with
671/// the whole of the reader's plaintext as both are produced.
672fn match_state(
673    mut reader: crypto::ArtifactReader<'_>,
674    tables: &crate::data_safety::export_types::ExportTables,
675    mappings: &[domain::Mapping],
676) -> Result<domain::Stats> {
677    let mut matcher = PlainMatcher {
678        reader: &mut reader,
679        written: 0,
680        equal: true,
681        failed: false,
682    };
683    let stats = domain::encode_into(&mut matcher, tables, mappings)?;
684    if matcher.failed {
685        return Err(Error::Authentication);
686    }
687    valid(matcher.equal && reader.remaining() == 0)?;
688    reader.finish().map_err(|_| Error::Invalid)?;
689    Ok(stats)
690}
691
692/// The SHA-256 of an artifact's complete plaintext.
693fn hash_plaintext(mut reader: crypto::ArtifactReader<'_>) -> Result<[u8; 32]> {
694    let mut hash = <sha2::Sha256 as sha2::Digest>::new();
695    loop {
696        let piece = reader.take(usize::MAX).map_err(|_| Error::Authentication)?;
697        if piece.is_empty() {
698            break;
699        }
700        sha2::Digest::update(&mut hash, piece);
701    }
702    reader.finish().map_err(|_| Error::Invalid)?;
703    Ok(sha2::Digest::finalize(hash).into())
704}
705
706fn state_reader<'a>(
707    d: &Descriptor,
708    state: &'a crypto::EncryptedArtifact<'a>,
709    key: &LocalSharedStatePackageKey,
710) -> Result<crypto::ArtifactReader<'a>> {
711    let state_key = crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 1)
712        .map_err(|_| Error::Authentication)?;
713    crypto::ArtifactReader::new(
714        state,
715        d.context(),
716        d.stream,
717        d.bootstrap,
718        2,
719        1,
720        &state_key,
721        size(STATE_LIMIT)?,
722    )
723    .map_err(|_| Error::Authentication)
724}
725
726/// Authenticates every byte of `package` against the independent capture:
727/// the state per `check`, then the manifest, the prefix and image catalogs,
728/// the private mappings, and each image's plaintext hash.
729fn check_capture(
730    package: &Package,
731    capture: &NeverDispatchedLocalSharedCapture,
732    key: &LocalSharedStatePackageKey,
733    membership_predecessor: [u8; 32],
734    check: StateCheck<'_>,
735    authenticate_images: bool,
736) -> Result<AttachmentIndex> {
737    #[cfg(test)]
738    crate::sync::shared_state::counters::keyed_pass();
739    let metadata = download::MetadataView::from(package);
740    let (d, images) = validate_metadata(&metadata)?;
741    valid(
742        d.membership == membership_predecessor
743            && capture.stream_id() == hex::encode(d.stream)
744            && capture.candidate_id() == hex::encode(d.bootstrap),
745    )?;
746    let state = decode_state_catalog(&metadata.catalogs[0])?;
747    let state = state.encrypted(metadata.state);
748    let tables = &capture.capture.snapshot.tables;
749    let decoded;
750    let computed;
751    let (mappings, stats) = match check {
752        StateCheck::Capture => {
753            decoded = domain::decode_mappings(&mut Plain(&mut state_reader(&d, &state, key)?))?;
754            computed = match_state(state_reader(&d, &state, key)?, tables, &decoded)?;
755            (&decoded[..], &computed)
756        }
757        StateCheck::Encoded {
758            digest,
759            mappings,
760            stats,
761        } => {
762            valid(hash_plaintext(state_reader(&d, &state, key)?)? == digest)?;
763            (mappings, stats)
764        }
765    };
766    let manifest_key =
767        crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 2)
768            .map_err(|_| Error::Authentication)?;
769    let manifest = Zeroizing::new(
770        crypto::decrypt_artifact(
771            &d.manifest.encrypted(metadata.manifest),
772            d.context(),
773            d.stream,
774            d.bootstrap,
775            2,
776            2,
777            &manifest_key,
778            size(CHUNK)?,
779        )
780        .map_err(|_| Error::Authentication)?,
781    );
782    valid(*manifest == manifest_plaintext(&d, stats))?;
783    valid(projection::prefix(tables)? == catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?)?;
784    let expected_images = projection::images(
785        tables,
786        mappings,
787        Images {
788            objects: images.objects.clone(),
789            parents: Vec::new(),
790            references: Vec::new(),
791        },
792    )?;
793    valid(images == expected_images)?;
794    valid(mappings.len() == capture.images.len())?;
795    let captured_images = capture
796        .images
797        .iter()
798        .map(|r| (r.sha256.as_str(), r.classification.as_str()))
799        .collect::<std::collections::HashMap<_, _>>();
800    for mapping in mappings {
801        valid(
802            captured_images.get(mapping.sha256.as_str()).copied()
803                == Some(mapping.classification.as_str()),
804        )?;
805    }
806    if authenticate_images {
807        valid(images.objects.len() == package.images.len())?;
808        for (object, records) in images.objects.iter().zip(&package.images) {
809            valid(object.id == records.object_id)?;
810            let mapping = mappings
811                .iter()
812                .find(|m| m.object == Some(object.id))
813                .ok_or(Error::Invalid)?;
814            authenticate_image(
815                d.context(),
816                d.stream,
817                object,
818                &mapping.sha256,
819                &records.records,
820                key,
821            )?;
822        }
823    }
824    index_from_mappings(&metadata, mappings).map_err(|_| Error::Invalid)
825}
826
827/// Authenticates metadata against the capture. Image records must be checked
828/// separately with [`authenticate_indexed_image`] in the same snapshot.
829pub(super) fn authenticate_metadata_capture(
830    package: &Package,
831    capture: &NeverDispatchedLocalSharedCapture,
832    key: &LocalSharedStatePackageKey,
833    membership_predecessor: [u8; 32],
834) -> Result<AttachmentIndex> {
835    check_capture(
836        package,
837        capture,
838        key,
839        membership_predecessor,
840        StateCheck::Capture,
841        false,
842    )
843}
844
845fn authenticate_image(
846    context: crypto::LocalSharedStatePackageContext,
847    stream: [u8; 32],
848    object: &Image,
849    sha256: &str,
850    records: &[Vec<u8>],
851    key: &LocalSharedStatePackageKey,
852) -> Result<()> {
853    let image_key =
854        crypto::derive_image_key(key, context, object.id).map_err(|_| Error::Authentication)?;
855    let artifact = object.artifact.encrypted(records);
856    let reader = crypto::ArtifactReader::new(
857        &artifact,
858        context,
859        stream,
860        object.id,
861        1,
862        0,
863        &image_key,
864        size(IMAGE_LIMIT)?,
865    )
866    .map_err(|_| Error::Authentication)?;
867    valid(hex::encode(hash_plaintext(reader)?) == sha256)
868}
869
870pub(super) fn authenticate_indexed_image(
871    index: &AttachmentIndex,
872    object_id: [u8; 32],
873    sha256: &str,
874    records: &[Vec<u8>],
875    key: &LocalSharedStatePackageKey,
876) -> Result<()> {
877    let (object, expected) = index
878        .objects
879        .iter()
880        .find(|(object, _)| object.id == object_id)
881        .ok_or(Error::Invalid)?;
882    valid(expected == sha256)?;
883    authenticate_image(index.context, index.stream, object, sha256, records, key)
884}
885
886/// The decoding validator [`authenticate_capture`] replaced, kept so tests can
887/// show both agree.
888#[cfg(test)]
889pub(crate) fn validate_against_capture_decoding(
890    package: &Package,
891    capture: &NeverDispatchedLocalSharedCapture,
892    key: &LocalSharedStatePackageKey,
893    membership_predecessor: [u8; 32],
894) -> Result<()> {
895    let d = Descriptor::decode(&package.descriptor)?;
896    valid(
897        d.membership == membership_predecessor
898            && capture.stream_id() == hex::encode(d.stream)
899            && capture.candidate_id() == hex::encode(d.bootstrap),
900    )?;
901    let (decoded, mappings) = decrypt_domain(package, key)?;
902    valid(
903        domain::encode(&decoded.snapshot.tables, &mappings)?.0
904            == domain::encode(&capture.capture.snapshot.tables, &mappings)?.0,
905    )?;
906    valid(mappings.len() == capture.images.len())?;
907    let captured_images = capture
908        .images
909        .iter()
910        .map(|r| (r.sha256.as_str(), r.classification.as_str()))
911        .collect::<std::collections::HashMap<_, _>>();
912    for mapping in &mappings {
913        valid(
914            captured_images.get(mapping.sha256.as_str()).copied()
915                == Some(mapping.classification.as_str()),
916        )?;
917    }
918    Ok(())
919}
920
921#[cfg(test)]
922mod tests;
923
924/// Rewrites one catalog commitment to match edited catalog bytes, so tests can
925/// exercise hash-valid catalogs that are otherwise wrong.
926#[cfg(test)]
927pub(crate) fn recommit_catalog(package: &mut Package, index: usize) {
928    let mut d = Descriptor::decode(&package.descriptor).unwrap();
929    d.catalogs[index] = Declaration::new(&package.catalogs[index], index as u8 + 1).unwrap();
930    package.descriptor = d.encode().unwrap();
931}
932
933/// Replaces the prefix catalog with `count` synthetic rows and recommits the
934/// descriptor. Keyless checks pass; keyed validation against the snapshot
935/// does not.
936#[cfg(test)]
937pub(crate) fn replace_prefix_catalog(package: &mut Package, count: u64) {
938    let rows = (1..=count)
939        .map(|rank| (rank, format!("{rank:026}")))
940        .collect::<Vec<_>>();
941    package.catalogs[1] = catalog::prefix_encode(&rows).unwrap();
942    let mut d = Descriptor::decode(&package.descriptor).unwrap();
943    d.prefix = count;
944    package.descriptor = d.encode().unwrap();
945    recommit_catalog(package, 1);
946}
947
948#[derive(Clone)]
949pub(crate) struct AttachmentIndex {
950    pub context: crypto::LocalSharedStatePackageContext,
951    pub stream: [u8; 32],
952    pub objects: Vec<(catalog::Image, String)>,
953    pub references: Vec<catalog::Reference>,
954}
955
956/// The caller receives mappings only after complete public/private authentication.
957#[cfg(any(test, feature = "test-support"))]
958pub(crate) fn attachment_index(
959    package: &Package,
960    key: &LocalSharedStatePackageKey,
961) -> anyhow::Result<AttachmentIndex> {
962    let (_, mappings) = decrypt_domain_images(package, key, |_, _| {})?;
963    index_from_mappings(&download::MetadataView::from(package), &mappings)
964}
965
966/// Rebuilds the index of an authenticated freeze from its public catalogs and
967/// its recorded `(object ID, sha256)` pairs, without decrypting. Every catalog
968/// object must be recorded exactly once, and nothing else.
969pub(crate) fn index_from_objects(
970    descriptor: &[u8],
971    catalogs: &[Vec<u8>; 3],
972    recorded: &[([u8; 32], String)],
973) -> anyhow::Result<AttachmentIndex> {
974    use anyhow::Context as _;
975    let declaration = staging::DeclarationView::decode(descriptor)?;
976    for (class, bytes) in catalogs.iter().enumerate() {
977        declaration.catalog(class, bytes)?;
978    }
979    let descriptor = Descriptor::decode(descriptor)?;
980    let images = Images::decode(&catalogs[2])?;
981    anyhow::ensure!(
982        recorded.len() == images.objects.len(),
983        "error seed-capture-changed"
984    );
985    let objects = images
986        .objects
987        .into_iter()
988        .map(|image| {
989            let sha256 = recorded
990                .iter()
991                .find(|(id, _)| *id == image.id)
992                .map(|(_, sha256)| sha256.clone())
993                .context("error seed-capture-changed")?;
994            Ok((image, sha256))
995        })
996        .collect::<anyhow::Result<Vec<_>>>()?;
997    Ok(AttachmentIndex {
998        context: descriptor.context(),
999        stream: descriptor.stream,
1000        objects,
1001        references: images.references,
1002    })
1003}
1004
1005fn index_from_mappings(
1006    metadata: &download::MetadataView<'_>,
1007    mappings: &[domain::Mapping],
1008) -> anyhow::Result<AttachmentIndex> {
1009    let descriptor = Descriptor::decode(metadata.descriptor)?;
1010    let images = Images::decode(&metadata.catalogs[2])?;
1011    let objects = images
1012        .objects
1013        .into_iter()
1014        .map(|image| {
1015            let mapping = mappings
1016                .iter()
1017                .find(|m| m.object == Some(image.id))
1018                .ok_or(Error::Invalid)?;
1019            Ok((image, mapping.sha256.clone()))
1020        })
1021        .collect::<Result<Vec<_>>>()?;
1022    Ok(AttachmentIndex {
1023        context: descriptor.context(),
1024        stream: descriptor.stream,
1025        objects,
1026        references: images.references,
1027    })
1028}