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) mod catalog;
62pub(crate) mod 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
81/// Privacy-safe failures. Resource refusal says nothing about domain validity.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Error {
84    Invalid,
85    ResourceLimit,
86    Authentication,
87    Unsupported,
88}
89impl std::fmt::Display for Error {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(match self {
92            Self::Invalid => "invalid bootstrap representation",
93            Self::ResourceLimit => "bootstrap codec resource limit exceeded",
94            Self::Authentication => "bootstrap authentication failed",
95            Self::Unsupported => "unsupported bootstrap format version",
96        })
97    }
98}
99impl std::error::Error for Error {}
100type Result<T> = std::result::Result<T, Error>;
101
102/// Exact upload components. Catalog array order is data, prefix, images.
103/// Records within artifacts are in canonical index order, images in object order.
104/// Clear bytes contain only the permitted metadata; encrypted bytes are opaque.
105#[derive(Clone, PartialEq, Eq)]
106pub struct Package {
107    pub descriptor: Vec<u8>,
108    pub catalogs: [Vec<u8>; 3],
109    pub state: Vec<Vec<u8>>,
110    pub manifest: Vec<Vec<u8>>,
111    pub images: Vec<ImageRecords>,
112}
113
114#[derive(Clone, PartialEq, Eq)]
115pub struct ImageRecords {
116    pub object_id: [u8; 32],
117    pub records: Vec<Vec<u8>>,
118}
119
120/// Structural completeness only. It does not authenticate membership or prove
121/// domain correctness, durable storage, quota admission, or permission to publish.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub struct Completeness {
124    pub prefix_count: u64,
125    pub image_count: u64,
126    pub ciphertext_bytes: u64,
127}
128
129#[derive(Clone, PartialEq, Eq)]
130struct Descriptor {
131    vault: [u8; 32],
132    stream: [u8; 32],
133    generation: [u8; 32],
134    bootstrap: [u8; 32],
135    // An already-known predecessor/genesis commitment, never PublishBootstrap.
136    membership: [u8; 32],
137    prefix: u64,
138    catalogs: [Declaration; 3],
139    manifest: Artifact,
140}
141
142impl Descriptor {
143    fn context(&self) -> crypto::LocalSharedStatePackageContext {
144        crypto::LocalSharedStatePackageContext {
145            vault_id: self.vault,
146            generation_id: self.generation,
147        }
148    }
149    fn binding(&self) -> Vec<u8> {
150        let mut out = b"AVBP\0\x02\x01".to_vec();
151        for id in [
152            self.vault,
153            self.stream,
154            self.generation,
155            self.bootstrap,
156            self.membership,
157        ] {
158            out.extend_from_slice(&id);
159        }
160        u64_bytes(&mut out, self.prefix);
161        for (index, declaration) in self.catalogs.iter().enumerate() {
162            out.push(index as u8 + 1);
163            declaration.write(&mut out);
164        }
165        out
166    }
167    fn encode(&self) -> Result<Vec<u8>> {
168        let mut out = self.binding();
169        self.manifest.write(&mut out)?;
170        Ok(out)
171    }
172    fn decode(bytes: &[u8]) -> Result<Self> {
173        bound(number(bytes.len())?, MAX_DESCRIPTOR_BYTES as u64)?;
174        let mut r = Reader(bytes);
175        valid(r.take(4)? == b"AVBP")?;
176        if r.take(2)? != [0, 2] {
177            return Err(Error::Unsupported);
178        }
179        valid(r.byte()? == 1)?;
180        let vault = r.array()?;
181        let stream = r.array()?;
182        let generation = r.array()?;
183        let bootstrap = r.array()?;
184        let membership = r.array()?;
185        let prefix = r.u64()?;
186        valid(prefix < i64::MAX as u64)?;
187        bound(prefix, RECORD_LIMIT)?;
188        valid(r.byte()? == 1)?;
189        let data = Declaration::read(&mut r)?;
190        valid(r.byte()? == 2)?;
191        let ids = Declaration::read(&mut r)?;
192        valid(r.byte()? == 3)?;
193        let images = Declaration::read(&mut r)?;
194        let manifest = Artifact::read(&mut r, CHUNK, false)?;
195        r.end()?;
196        Ok(Self {
197            vault,
198            stream,
199            generation,
200            bootstrap,
201            membership,
202            prefix,
203            catalogs: [data, ids, images],
204            manifest,
205        })
206    }
207}
208
209fn state_catalog(state: &Artifact) -> Result<Vec<u8>> {
210    let mut row = vec![1];
211    state.write(&mut row)?;
212    stream(1, &[row])
213}
214
215fn decode_state_catalog(bytes: &[u8]) -> Result<Artifact> {
216    let records = read_stream(bytes, 1)?;
217    valid(records.len() == 1)?;
218    let mut r = Reader(records[0]);
219    valid(r.byte()? == 1)?;
220    let state = Artifact::read(&mut r, STATE_LIMIT, false)?;
221    r.end()?;
222    Ok(state)
223}
224
225fn manifest_plaintext(d: &Descriptor, stats: &domain::Stats) -> Vec<u8> {
226    let mut out = b"AVBM\0\x01".to_vec();
227    out.extend_from_slice(&d.binding());
228    out.extend_from_slice(&(domain::SECTIONS as u16).to_be_bytes());
229    for (index, (count, length)) in stats.iter().enumerate() {
230        out.extend_from_slice(&(index as u16 + 1).to_be_bytes());
231        u64_bytes(&mut out, *count);
232        u64_bytes(&mut out, *length);
233    }
234    out
235}
236
237/// Encrypts the domain encoding as it is produced, hashing the plaintext so
238/// the finished ciphertext can be checked against exactly these bytes.
239struct StateOut {
240    writer: crypto::ArtifactWriter,
241    digest: sha2::Sha256,
242    len: usize,
243    failed: bool,
244}
245
246impl domain::Out for StateOut {
247    fn begin(&mut self, total: usize) {
248        self.failed |= self.writer.begin(total).is_err();
249    }
250    fn len(&self) -> usize {
251        self.len
252    }
253    fn put(&mut self, bytes: &[u8]) {
254        self.len += bytes.len();
255        sha2::Digest::update(&mut self.digest, bytes);
256        self.failed = self.failed || self.writer.write(bytes).is_err();
257    }
258}
259
260/// One authenticated selected image without ownership of its ciphertext records.
261pub(super) struct ImageSummary {
262    pub(super) sha256: String,
263    pub(super) object_id: [u8; 32],
264    pub(super) artifact: Artifact,
265}
266
267impl ImageSummary {
268    pub(super) fn authenticated(
269        image: &crypto::EncryptedImage,
270        context: crypto::LocalSharedStatePackageContext,
271        stream: [u8; 32],
272        key: &LocalSharedStatePackageKey,
273    ) -> Result<Self> {
274        let image_key = crypto::derive_image_key(key, context, image.object_id)
275            .map_err(|_| Error::Authentication)?;
276        let reader = crypto::ArtifactReader::new(
277            &image.artifact,
278            context,
279            stream,
280            image.object_id,
281            1,
282            0,
283            &image_key,
284            size(IMAGE_LIMIT)?,
285        )
286        .map_err(|_| Error::Authentication)?;
287        valid(hex::encode(hash_plaintext(reader)?) == image.sha256)?;
288        Ok(Self {
289            sha256: image.sha256.clone(),
290            object_id: image.object_id,
291            artifact: Artifact::from_encrypted(&image.artifact)?,
292        })
293    }
294}
295
296/// Constructs tentative metadata only for the durable owner's first freeze.
297/// Image ciphertext has already been authenticated while each image was resident.
298pub(super) fn build_metadata(
299    capture: &NeverDispatchedLocalSharedCapture,
300    context: crypto::LocalSharedStatePackageContext,
301    images: &[ImageSummary],
302    key: &LocalSharedStatePackageKey,
303    membership_predecessor: [u8; 32],
304) -> Result<(Package, AttachmentIndex)> {
305    let stream_id =
306        crypto::decode_context_id(capture.stream_id(), "stream").map_err(|_| Error::Invalid)?;
307    let bootstrap = crypto::decode_context_id(capture.candidate_id(), "candidate")
308        .map_err(|_| Error::Invalid)?;
309    let objects = images
310        .iter()
311        .map(|image| (image.sha256.as_str(), image.object_id))
312        .collect::<std::collections::HashMap<_, _>>();
313    let mappings = capture
314        .images
315        .iter()
316        .map(|r| domain::Mapping {
317            sha256: r.sha256.clone(),
318            classification: r.classification.clone(),
319            object: objects.get(r.sha256.as_str()).copied(),
320        })
321        .collect::<Vec<_>>();
322    let state_key = crypto::derive_bootstrap_class_key(key, context, stream_id, bootstrap, 1)
323        .map_err(|_| Error::Authentication)?;
324    let mut out = StateOut {
325        writer: crypto::ArtifactWriter::new(context, stream_id, bootstrap, 2, 1, &state_key)
326            .map_err(|_| Error::Authentication)?,
327        digest: sha2::Digest::new(),
328        len: 0,
329        failed: false,
330    };
331    let stats = domain::encode_into(&mut out, &capture.capture.snapshot.tables, &mappings)?;
332    valid(!out.failed)?;
333    let encoded = <[u8; 32]>::from(sha2::Digest::finalize(out.digest));
334    let state = out.writer.finish().map_err(|_| Error::Authentication)?;
335    let objects = images
336        .iter()
337        .map(|image| {
338            let mapping = mappings
339                .iter()
340                .find(|m| m.object == Some(image.object_id))
341                .ok_or(Error::Invalid)?;
342            Ok(Image {
343                id: image.object_id,
344                selection: if mapping.classification == "current_selected" {
345                    1
346                } else {
347                    2
348                },
349                artifact: image.artifact.clone(),
350            })
351        })
352        .collect::<Result<Vec<_>>>()?;
353    let image_catalog = projection::images(
354        &capture.capture.snapshot.tables,
355        &mappings,
356        Images {
357            objects,
358            parents: Vec::new(),
359            references: Vec::new(),
360        },
361    )?;
362    let prefix = projection::prefix(&capture.capture.snapshot.tables)?;
363    let catalogs = [
364        state_catalog(&Artifact::from_encrypted(&state)?)?,
365        catalog::prefix_encode(&prefix)?,
366        image_catalog.encode()?,
367    ];
368    let mut d = Descriptor {
369        vault: context.vault_id,
370        stream: stream_id,
371        generation: context.generation_id,
372        bootstrap,
373        membership: membership_predecessor,
374        prefix: number(prefix.len())?,
375        catalogs: [
376            Declaration::new(&catalogs[0], 1)?,
377            Declaration::new(&catalogs[1], 2)?,
378            Declaration::new(&catalogs[2], 3)?,
379        ],
380        manifest: Artifact {
381            total: 0,
382            aggregate: [0; 32],
383            chunks: Vec::new(),
384        },
385    };
386    let manifest_key = crypto::derive_bootstrap_class_key(key, context, stream_id, bootstrap, 2)
387        .map_err(|_| Error::Authentication)?;
388    let manifest = crypto::encrypt_artifact(
389        &manifest_plaintext(&d, &stats),
390        context,
391        stream_id,
392        bootstrap,
393        2,
394        2,
395        &manifest_key,
396    )
397    .map_err(|_| Error::Authentication)?;
398    d.manifest = Artifact::from_encrypted(&manifest)?;
399    let package = Package {
400        descriptor: d.encode()?,
401        catalogs,
402        state: state.into_records(),
403        manifest: manifest.into_records(),
404        images: Vec::new(),
405    };
406    let index = check_capture(
407        &package,
408        capture,
409        key,
410        membership_predecessor,
411        StateCheck::Encoded {
412            digest: encoded,
413            mappings: &mappings,
414            stats: &stats,
415        },
416        false,
417    )?;
418    Ok((package, index))
419}
420
421/// Full-package adapter used by codec and persistence tests.
422#[cfg(test)]
423pub(super) fn build(
424    capture: &NeverDispatchedLocalSharedCapture,
425    context: crypto::LocalSharedStatePackageContext,
426    images: Vec<crypto::EncryptedImage>,
427    key: &LocalSharedStatePackageKey,
428    membership_predecessor: [u8; 32],
429) -> Result<(Package, AttachmentIndex)> {
430    let stream =
431        crypto::decode_context_id(capture.stream_id(), "stream").map_err(|_| Error::Invalid)?;
432    let summaries = images
433        .iter()
434        .map(|image| ImageSummary::authenticated(image, context, stream, key))
435        .collect::<Result<Vec<_>>>()?;
436    let (mut package, _) =
437        build_metadata(capture, context, &summaries, key, membership_predecessor)?;
438    package.images = images
439        .into_iter()
440        .map(|image| ImageRecords {
441            object_id: image.object_id,
442            records: image.artifact.into_records(),
443        })
444        .collect();
445    package.images.sort_by_key(|image| image.object_id);
446    let index = authenticate_capture(&package, capture, key, membership_predecessor)?;
447    Ok((package, index))
448}
449
450/// Authenticates all domain, manifest and image bytes under the supplied key and
451/// expected context. This does not establish membership authorization.
452pub fn authenticate(
453    package: &Package,
454    key: &LocalSharedStatePackageKey,
455    context: crypto::LocalSharedStatePackageContext,
456    stream_id: [u8; 32],
457    bootstrap_id: [u8; 32],
458    membership_predecessor: [u8; 32],
459) -> Result<()> {
460    let d = Descriptor::decode(&package.descriptor)?;
461    valid(
462        d.context() == context
463            && d.stream == stream_id
464            && d.bootstrap == bootstrap_id
465            && d.membership == membership_predecessor,
466    )?;
467    decrypt_domain(package, key)?;
468    Ok(())
469}
470
471pub(super) fn context_and_membership(
472    descriptor: &[u8],
473) -> Result<(crypto::LocalSharedStatePackageContext, [u8; 32])> {
474    let d = Descriptor::decode(descriptor)?;
475    Ok((d.context(), d.membership))
476}
477
478/// Checks exact clear commitments, framing, canonical coverage and all selected
479/// image-byte obligations without a key. Catalogs are aggregate-verified before
480/// any records are trusted. No partial catalog can establish completeness.
481pub fn validate_keyless(package: &Package) -> Result<Completeness> {
482    keyless(package).map(|(completeness, _, _)| completeness)
483}
484
485/// [`validate_keyless`], also returning the verified descriptor and image
486/// catalog.
487fn keyless(package: &Package) -> Result<(Completeness, Descriptor, Images)> {
488    let metadata = download::MetadataView::from(package);
489    let (d, images) = validate_metadata(&metadata)?;
490    valid(images.objects.len() == package.images.len())?;
491    for (object, records) in images.objects.iter().zip(&package.images) {
492        valid(object.id == records.object_id)?;
493        object
494            .artifact
495            .verify(&records.records, d.context(), d.stream, object.id, 1, 0)?;
496    }
497    let ciphertext_bytes = std::iter::once(&package.state)
498        .chain(std::iter::once(&package.manifest))
499        .chain(package.images.iter().map(|i| &i.records))
500        .flatten()
501        .try_fold(0, |total, record| add(total, number(record.len())?))?;
502    let completeness = Completeness {
503        prefix_count: d.prefix,
504        image_count: number(images.objects.len())?,
505        ciphertext_bytes,
506    };
507    Ok((completeness, d, images))
508}
509
510fn validate_metadata(metadata: &download::MetadataView<'_>) -> Result<(Descriptor, Images)> {
511    let d = Descriptor::decode(metadata.descriptor)?;
512    for (i, bytes) in metadata.catalogs.iter().enumerate() {
513        d.catalogs[i].verify(bytes, i as u8 + 1)?;
514    }
515    let state = decode_state_catalog(&metadata.catalogs[0])?;
516    catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?;
517    let images = Images::decode(&metadata.catalogs[2])?;
518    state.verify(metadata.state, d.context(), d.stream, d.bootstrap, 2, 1)?;
519    d.manifest
520        .verify(metadata.manifest, d.context(), d.stream, d.bootstrap, 2, 2)?;
521    Ok((d, images))
522}
523
524fn decrypt_domain(
525    package: &Package,
526    key: &LocalSharedStatePackageKey,
527) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
528    decrypt_domain_images(package, key, |_, _| {})
529}
530
531fn decrypt_domain_images(
532    package: &Package,
533    key: &LocalSharedStatePackageKey,
534    mut accept_image: impl FnMut(&str, Zeroizing<Vec<u8>>),
535) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
536    validate_keyless(package)?;
537    let metadata = download::MetadataView::from(package);
538    let (capture, mappings) = decrypt_metadata(&metadata, key)?;
539    let (d, images) = validate_metadata(&metadata)?;
540    for (object, records) in images.objects.iter().zip(&package.images) {
541        let mapping = mappings
542            .iter()
543            .find(|m| m.object == Some(object.id))
544            .ok_or(Error::Invalid)?;
545        let image_key = crypto::derive_image_key(key, d.context(), object.id)
546            .map_err(|_| Error::Authentication)?;
547        let bytes = Zeroizing::new(
548            crypto::decrypt_artifact(
549                &object.artifact.encrypted(&records.records),
550                d.context(),
551                d.stream,
552                object.id,
553                1,
554                0,
555                &image_key,
556                size(IMAGE_LIMIT)?,
557            )
558            .map_err(|_| Error::Authentication)?,
559        );
560        valid(hex::encode(crate::sync::codec::hash(&bytes)) == mapping.sha256)?;
561        accept_image(&mapping.sha256, bytes);
562    }
563    Ok((capture, mappings))
564}
565
566fn decrypt_metadata(
567    metadata: &download::MetadataView<'_>,
568    key: &LocalSharedStatePackageKey,
569) -> Result<(SharedStateCapture, Vec<domain::Mapping>)> {
570    validate_metadata(metadata)?;
571    #[cfg(test)]
572    crate::sync::shared_state::counters::keyed_pass();
573    let d = Descriptor::decode(metadata.descriptor)?;
574    let state = decode_state_catalog(&metadata.catalogs[0])?;
575    let state_key = crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 1)
576        .map_err(|_| Error::Authentication)?;
577    let plaintext = Zeroizing::new(
578        crypto::decrypt_artifact(
579            &state.encrypted(metadata.state),
580            d.context(),
581            d.stream,
582            d.bootstrap,
583            2,
584            1,
585            &state_key,
586            size(STATE_LIMIT)?,
587        )
588        .map_err(|_| Error::Authentication)?,
589    );
590    let (tables, mappings, stats) = domain::decode(&plaintext)?;
591    let manifest_key =
592        crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 2)
593            .map_err(|_| Error::Authentication)?;
594    let manifest = Zeroizing::new(
595        crypto::decrypt_artifact(
596            &d.manifest.encrypted(metadata.manifest),
597            d.context(),
598            d.stream,
599            d.bootstrap,
600            2,
601            2,
602            &manifest_key,
603            size(CHUNK)?,
604        )
605        .map_err(|_| Error::Authentication)?,
606    );
607    valid(*manifest == manifest_plaintext(&d, &stats))?;
608    let capture = capture(tables);
609    capture.validate().map_err(|_| Error::Invalid)?;
610    valid(
611        projection::prefix(&capture.snapshot.tables)?
612            == catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?,
613    )?;
614    let images = Images::decode(&metadata.catalogs[2])?;
615    let expected = projection::images(
616        &capture.snapshot.tables,
617        &mappings,
618        Images {
619            objects: images.objects.clone(),
620            parents: Vec::new(),
621            references: Vec::new(),
622        },
623    )?;
624    valid(images == expected)?;
625    Ok((capture, mappings))
626}
627
628/// Decoded domain tables as a capture for the shared domain validators.
629fn capture(tables: crate::data_safety::export_types::ExportTables) -> SharedStateCapture {
630    // Select the internal validation adapter version, not a wire version.
631    let version = if tables.shared_history_provenance.is_empty() {
632        3
633    } else {
634        4
635    };
636    SharedStateCapture {
637        snapshot: crate::data_safety::export_types::AvenExport {
638            format: "aven-export".into(),
639            version,
640            exported_at: String::new(),
641            schema_version: 0,
642            blobs_included: false,
643            tables,
644        },
645    }
646}
647
648/// Authenticates and decrypts the complete package into installable state.
649pub(super) fn decrypt_capture(
650    package: &Package,
651    key: &LocalSharedStatePackageKey,
652) -> Result<SharedStateCapture> {
653    Ok(decrypt_domain(package, key)?.0)
654}
655
656/// Returns every selected image's private hash and authenticated plaintext.
657#[cfg(test)]
658pub(super) fn decrypt_images(
659    package: &Package,
660    key: &LocalSharedStatePackageKey,
661) -> Result<Vec<(String, Zeroizing<Vec<u8>>)>> {
662    let mut images = Vec::new();
663    decrypt_domain_images(package, key, |sha256, bytes| {
664        images.push((sha256.to_string(), bytes));
665    })?;
666    Ok(images)
667}
668
669/// Client-side check against the immutable source, not live rows. Also checks
670/// encrypted manifest/catalog agreement and private image hashes. This is not
671/// join installation and does not establish any trusted membership anchor.
672pub fn validate_against_capture(
673    package: &Package,
674    capture: &NeverDispatchedLocalSharedCapture,
675    key: &LocalSharedStatePackageKey,
676    membership_predecessor: [u8; 32],
677) -> Result<()> {
678    authenticate_capture(package, capture, key, membership_predecessor).map(drop)
679}
680
681/// The one keyed pass over a frozen package. The decrypted state must equal
682/// the canonical encoding of the capture under the package's own private
683/// mappings, so the state is never decoded into a second copy of the tables.
684/// The state and images are decrypted one chunk at a time.
685pub(crate) fn authenticate_capture(
686    package: &Package,
687    capture: &NeverDispatchedLocalSharedCapture,
688    key: &LocalSharedStatePackageKey,
689    membership_predecessor: [u8; 32],
690) -> Result<AttachmentIndex> {
691    check_capture(
692        package,
693        capture,
694        key,
695        membership_predecessor,
696        StateCheck::Capture,
697        true,
698    )
699}
700
701/// How [`check_capture`] establishes that the decrypted state is the
702/// capture's canonical encoding.
703enum StateCheck<'a> {
704    /// Re-encode the capture under the mappings read from the state and
705    /// compare it with the state as both are produced.
706    Capture,
707    /// The state was encrypted from one encoding pass over this capture, which
708    /// wrote these mappings, gave these section stats and hashed to `digest`.
709    /// The state must decrypt to bytes with that hash.
710    Encoded {
711        digest: [u8; 32],
712        mappings: &'a [domain::Mapping],
713        stats: &'a domain::Stats,
714    },
715}
716
717/// Hands out a state reader's plaintext to the domain decoder.
718struct Plain<'r, 'a>(&'r mut crypto::ArtifactReader<'a>);
719
720impl domain::In for Plain<'_, '_> {
721    fn remaining(&self) -> u64 {
722        self.0.remaining()
723    }
724    fn read(&mut self, into: &mut [u8]) -> Result<()> {
725        let mut filled = 0;
726        while filled < into.len() {
727            let piece = self
728                .0
729                .take(into.len() - filled)
730                .map_err(|_| Error::Authentication)?;
731            valid(!piece.is_empty())?;
732            into[filled..filled + piece.len()].copy_from_slice(piece);
733            filled += piece.len();
734        }
735        Ok(())
736    }
737    fn skip(&mut self, n: u64) -> Result<()> {
738        let mut left = size(n)?;
739        while left > 0 {
740            let piece = self.0.take(left).map_err(|_| Error::Authentication)?;
741            valid(!piece.is_empty())?;
742            left -= piece.len();
743        }
744        Ok(())
745    }
746}
747
748/// Compares the encoding with a state reader's plaintext as both are
749/// produced, so neither is ever held whole.
750struct PlainMatcher<'r, 'a> {
751    reader: &'r mut crypto::ArtifactReader<'a>,
752    written: usize,
753    equal: bool,
754    failed: bool,
755}
756
757impl domain::Out for PlainMatcher<'_, '_> {
758    fn begin(&mut self, total: usize) {
759        self.equal &= total as u64 == self.reader.remaining();
760    }
761    fn len(&self) -> usize {
762        self.written
763    }
764    fn put(&mut self, mut bytes: &[u8]) {
765        self.written += bytes.len();
766        while self.equal && !bytes.is_empty() {
767            match self.reader.take(bytes.len()) {
768                Ok(piece) if !piece.is_empty() => {
769                    self.equal = piece == &bytes[..piece.len()];
770                    bytes = &bytes[piece.len()..];
771                }
772                Ok(_) => self.equal = false,
773                Err(_) => {
774                    self.failed = true;
775                    self.equal = false;
776                }
777            }
778        }
779    }
780}
781
782/// Encodes the capture tables under `mappings`, comparing the encoding with
783/// the whole of the reader's plaintext as both are produced.
784fn match_state(
785    mut reader: crypto::ArtifactReader<'_>,
786    tables: &crate::data_safety::export_types::ExportTables,
787    mappings: &[domain::Mapping],
788) -> Result<domain::Stats> {
789    let mut matcher = PlainMatcher {
790        reader: &mut reader,
791        written: 0,
792        equal: true,
793        failed: false,
794    };
795    let stats = domain::encode_into(&mut matcher, tables, mappings)?;
796    if matcher.failed {
797        return Err(Error::Authentication);
798    }
799    valid(matcher.equal && reader.remaining() == 0)?;
800    reader.finish().map_err(|_| Error::Invalid)?;
801    Ok(stats)
802}
803
804/// The SHA-256 of an artifact's complete plaintext.
805fn hash_plaintext(mut reader: crypto::ArtifactReader<'_>) -> Result<[u8; 32]> {
806    let mut hash = <sha2::Sha256 as sha2::Digest>::new();
807    loop {
808        let piece = reader.take(usize::MAX).map_err(|_| Error::Authentication)?;
809        if piece.is_empty() {
810            break;
811        }
812        sha2::Digest::update(&mut hash, piece);
813    }
814    reader.finish().map_err(|_| Error::Invalid)?;
815    Ok(sha2::Digest::finalize(hash).into())
816}
817
818fn state_reader<'a>(
819    d: &Descriptor,
820    state: &'a crypto::EncryptedArtifact<'a>,
821    key: &LocalSharedStatePackageKey,
822) -> Result<crypto::ArtifactReader<'a>> {
823    let state_key = crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 1)
824        .map_err(|_| Error::Authentication)?;
825    crypto::ArtifactReader::new(
826        state,
827        d.context(),
828        d.stream,
829        d.bootstrap,
830        2,
831        1,
832        &state_key,
833        size(STATE_LIMIT)?,
834    )
835    .map_err(|_| Error::Authentication)
836}
837
838/// Authenticates every byte of `package` against the independent capture:
839/// the state per `check`, then the manifest, the prefix and image catalogs,
840/// the private mappings, and each image's plaintext hash.
841fn check_capture(
842    package: &Package,
843    capture: &NeverDispatchedLocalSharedCapture,
844    key: &LocalSharedStatePackageKey,
845    membership_predecessor: [u8; 32],
846    check: StateCheck<'_>,
847    authenticate_images: bool,
848) -> Result<AttachmentIndex> {
849    #[cfg(test)]
850    crate::sync::shared_state::counters::keyed_pass();
851    let metadata = download::MetadataView::from(package);
852    let (d, images) = validate_metadata(&metadata)?;
853    valid(
854        d.membership == membership_predecessor
855            && capture.stream_id() == hex::encode(d.stream)
856            && capture.candidate_id() == hex::encode(d.bootstrap),
857    )?;
858    let state = decode_state_catalog(&metadata.catalogs[0])?;
859    let state = state.encrypted(metadata.state);
860    let tables = &capture.capture.snapshot.tables;
861    let decoded;
862    let computed;
863    let (mappings, stats) = match check {
864        StateCheck::Capture => {
865            decoded = domain::decode_mappings(&mut Plain(&mut state_reader(&d, &state, key)?))?;
866            computed = match_state(state_reader(&d, &state, key)?, tables, &decoded)?;
867            (&decoded[..], &computed)
868        }
869        StateCheck::Encoded {
870            digest,
871            mappings,
872            stats,
873        } => {
874            valid(hash_plaintext(state_reader(&d, &state, key)?)? == digest)?;
875            (mappings, stats)
876        }
877    };
878    let manifest_key =
879        crypto::derive_bootstrap_class_key(key, d.context(), d.stream, d.bootstrap, 2)
880            .map_err(|_| Error::Authentication)?;
881    let manifest = Zeroizing::new(
882        crypto::decrypt_artifact(
883            &d.manifest.encrypted(metadata.manifest),
884            d.context(),
885            d.stream,
886            d.bootstrap,
887            2,
888            2,
889            &manifest_key,
890            size(CHUNK)?,
891        )
892        .map_err(|_| Error::Authentication)?,
893    );
894    valid(*manifest == manifest_plaintext(&d, stats))?;
895    valid(projection::prefix(tables)? == catalog::prefix_decode(&metadata.catalogs[1], d.prefix)?)?;
896    let expected_images = projection::images(
897        tables,
898        mappings,
899        Images {
900            objects: images.objects.clone(),
901            parents: Vec::new(),
902            references: Vec::new(),
903        },
904    )?;
905    valid(images == expected_images)?;
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    if authenticate_images {
919        valid(images.objects.len() == package.images.len())?;
920        for (object, records) in images.objects.iter().zip(&package.images) {
921            valid(object.id == records.object_id)?;
922            let mapping = mappings
923                .iter()
924                .find(|m| m.object == Some(object.id))
925                .ok_or(Error::Invalid)?;
926            authenticate_image(
927                d.context(),
928                d.stream,
929                object,
930                &mapping.sha256,
931                &records.records,
932                key,
933            )?;
934        }
935    }
936    index_from_mappings(&metadata, mappings).map_err(|_| Error::Invalid)
937}
938
939/// Authenticates metadata against the capture. Image records must be checked
940/// separately with [`authenticate_indexed_image`] in the same snapshot.
941pub(super) fn authenticate_metadata_capture(
942    package: &Package,
943    capture: &NeverDispatchedLocalSharedCapture,
944    key: &LocalSharedStatePackageKey,
945    membership_predecessor: [u8; 32],
946) -> Result<AttachmentIndex> {
947    check_capture(
948        package,
949        capture,
950        key,
951        membership_predecessor,
952        StateCheck::Capture,
953        false,
954    )
955}
956
957fn authenticate_image(
958    context: crypto::LocalSharedStatePackageContext,
959    stream: [u8; 32],
960    object: &Image,
961    sha256: &str,
962    records: &[Vec<u8>],
963    key: &LocalSharedStatePackageKey,
964) -> Result<()> {
965    let image_key =
966        crypto::derive_image_key(key, context, object.id).map_err(|_| Error::Authentication)?;
967    let artifact = object.artifact.encrypted(records);
968    let reader = crypto::ArtifactReader::new(
969        &artifact,
970        context,
971        stream,
972        object.id,
973        1,
974        0,
975        &image_key,
976        size(IMAGE_LIMIT)?,
977    )
978    .map_err(|_| Error::Authentication)?;
979    valid(hex::encode(hash_plaintext(reader)?) == sha256)
980}
981
982pub(super) fn authenticate_indexed_image(
983    index: &AttachmentIndex,
984    object_id: [u8; 32],
985    sha256: &str,
986    records: &[Vec<u8>],
987    key: &LocalSharedStatePackageKey,
988) -> Result<()> {
989    let (object, expected) = index
990        .objects
991        .iter()
992        .find(|(object, _)| object.id == object_id)
993        .ok_or(Error::Invalid)?;
994    valid(expected == sha256)?;
995    authenticate_image(index.context, index.stream, object, sha256, records, key)
996}
997
998/// The decoding validator [`authenticate_capture`] replaced, kept so tests can
999/// show both agree.
1000#[cfg(test)]
1001pub(crate) fn validate_against_capture_decoding(
1002    package: &Package,
1003    capture: &NeverDispatchedLocalSharedCapture,
1004    key: &LocalSharedStatePackageKey,
1005    membership_predecessor: [u8; 32],
1006) -> Result<()> {
1007    let d = Descriptor::decode(&package.descriptor)?;
1008    valid(
1009        d.membership == membership_predecessor
1010            && capture.stream_id() == hex::encode(d.stream)
1011            && capture.candidate_id() == hex::encode(d.bootstrap),
1012    )?;
1013    let (decoded, mappings) = decrypt_domain(package, key)?;
1014    valid(
1015        domain::encode(&decoded.snapshot.tables, &mappings)?.0
1016            == domain::encode(&capture.capture.snapshot.tables, &mappings)?.0,
1017    )?;
1018    valid(mappings.len() == capture.images.len())?;
1019    let captured_images = capture
1020        .images
1021        .iter()
1022        .map(|r| (r.sha256.as_str(), r.classification.as_str()))
1023        .collect::<std::collections::HashMap<_, _>>();
1024    for mapping in &mappings {
1025        valid(
1026            captured_images.get(mapping.sha256.as_str()).copied()
1027                == Some(mapping.classification.as_str()),
1028        )?;
1029    }
1030    Ok(())
1031}
1032
1033#[cfg(test)]
1034mod tests;
1035
1036/// Rewrites one catalog commitment to match edited catalog bytes, so tests can
1037/// exercise hash-valid catalogs that are otherwise wrong.
1038#[cfg(test)]
1039pub(crate) fn recommit_catalog(package: &mut Package, index: usize) {
1040    let mut d = Descriptor::decode(&package.descriptor).unwrap();
1041    d.catalogs[index] = Declaration::new(&package.catalogs[index], index as u8 + 1).unwrap();
1042    package.descriptor = d.encode().unwrap();
1043}
1044
1045/// Replaces the prefix catalog with `count` synthetic rows and recommits the
1046/// descriptor. Keyless checks pass; keyed validation against the snapshot
1047/// does not.
1048#[cfg(test)]
1049pub(crate) fn replace_prefix_catalog(package: &mut Package, count: u64) {
1050    let rows = (1..=count)
1051        .map(|rank| (rank, format!("{rank:026}")))
1052        .collect::<Vec<_>>();
1053    package.catalogs[1] = catalog::prefix_encode(&rows).unwrap();
1054    let mut d = Descriptor::decode(&package.descriptor).unwrap();
1055    d.prefix = count;
1056    package.descriptor = d.encode().unwrap();
1057    recommit_catalog(package, 1);
1058}
1059
1060#[derive(Clone)]
1061pub(crate) struct AttachmentIndex {
1062    pub context: crypto::LocalSharedStatePackageContext,
1063    pub stream: [u8; 32],
1064    pub objects: Vec<(catalog::Image, String)>,
1065    pub references: Vec<catalog::Reference>,
1066}
1067
1068/// The caller receives mappings only after complete public/private authentication.
1069#[cfg(any(test, feature = "test-support"))]
1070pub(crate) fn attachment_index(
1071    package: &Package,
1072    key: &LocalSharedStatePackageKey,
1073) -> anyhow::Result<AttachmentIndex> {
1074    let (_, mappings) = decrypt_domain_images(package, key, |_, _| {})?;
1075    index_from_mappings(&download::MetadataView::from(package), &mappings)
1076}
1077
1078/// Rebuilds the index of an authenticated freeze from its public catalogs and
1079/// its recorded `(object ID, sha256)` pairs, without decrypting. Every catalog
1080/// object must be recorded exactly once, and nothing else.
1081pub(crate) fn index_from_objects(
1082    descriptor: &[u8],
1083    catalogs: &[Vec<u8>; 3],
1084    recorded: &[([u8; 32], String)],
1085) -> anyhow::Result<AttachmentIndex> {
1086    use anyhow::Context as _;
1087    let declaration = staging::DeclarationView::decode(descriptor)?;
1088    for (class, bytes) in catalogs.iter().enumerate() {
1089        declaration.catalog(class, bytes)?;
1090    }
1091    let descriptor = Descriptor::decode(descriptor)?;
1092    let images = Images::decode(&catalogs[2])?;
1093    anyhow::ensure!(
1094        recorded.len() == images.objects.len(),
1095        "error seed-capture-changed"
1096    );
1097    let objects = images
1098        .objects
1099        .into_iter()
1100        .map(|image| {
1101            let sha256 = recorded
1102                .iter()
1103                .find(|(id, _)| *id == image.id)
1104                .map(|(_, sha256)| sha256.clone())
1105                .context("error seed-capture-changed")?;
1106            Ok((image, sha256))
1107        })
1108        .collect::<anyhow::Result<Vec<_>>>()?;
1109    Ok(AttachmentIndex {
1110        context: descriptor.context(),
1111        stream: descriptor.stream,
1112        objects,
1113        references: images.references,
1114    })
1115}
1116
1117fn index_from_mappings(
1118    metadata: &download::MetadataView<'_>,
1119    mappings: &[domain::Mapping],
1120) -> anyhow::Result<AttachmentIndex> {
1121    let descriptor = Descriptor::decode(metadata.descriptor)?;
1122    let images = Images::decode(&metadata.catalogs[2])?;
1123    let objects = images
1124        .objects
1125        .into_iter()
1126        .map(|image| {
1127            let mapping = mappings
1128                .iter()
1129                .find(|m| m.object == Some(image.id))
1130                .ok_or(Error::Invalid)?;
1131            Ok((image, mapping.sha256.clone()))
1132        })
1133        .collect::<Result<Vec<_>>>()?;
1134    Ok(AttachmentIndex {
1135        context: descriptor.context(),
1136        stream: descriptor.stream,
1137        objects,
1138        references: images.references,
1139    })
1140}