Skip to main content

aven_core/sync/shared_state/
package.rs

1mod freeze;
2pub mod publication;
3pub mod upload;
4
5use std::fmt;
6use std::path::Path;
7
8use anyhow::{Context, Result, ensure};
9use chacha20poly1305::aead::{AeadInOut, KeyInit};
10use chacha20poly1305::{XChaCha20Poly1305, XNonce};
11use hkdf::Hkdf;
12use sha2::{Digest, Sha256};
13use zeroize::{Zeroize, Zeroizing};
14
15use super::NeverDispatchedLocalSharedCapture;
16use super::validated::ValidatedSeed;
17use crate::data_safety::export_types::AvenExport;
18use crate::db::{self, Database};
19use crate::sync::codec;
20
21const CHUNK_PLAINTEXT_BYTES: usize = 1_048_576;
22const CHUNK_HEADER_BYTES: usize = 198;
23const CHUNK_RECORD_OVERHEAD: usize = 222;
24const TAG_BYTES: usize = 16;
25const MAX_STATE_CHUNKS: usize = 256;
26const MAX_IMAGE_PLAINTEXT_BYTES: usize = crate::attachments::validation::MAX_BLOB_BYTES;
27const MAX_PACKAGE_IMAGE_COUNT: usize = 1024;
28const MAX_PACKAGE_IMAGE_PLAINTEXT_BYTES: usize = 256 * 1024 * 1024;
29const IMAGE_FAMILY: u8 = 1;
30const IMAGE_CLASS: u8 = 0;
31const STATE_COMPONENT: &str = "state";
32const MANIFEST_COMPONENT: &str = "manifest";
33const IMAGE_COMPONENT: &str = "image";
34
35pub use aven_protocol::context::{LocalSharedStatePackageContext, LocalSharedStatePackageKey};
36
37/// Exact frozen upload components for the one never-dispatched local package.
38///
39/// The descriptor commits to the catalogs, and the catalogs commit to every
40/// encrypted record, so this value has no separately stored metadata. Upload
41/// components use the publication codec.
42#[derive(Clone, PartialEq, Eq)]
43pub struct EncryptedLocalSharedStatePackage {
44    candidate_id: String,
45    upload: publication::Package,
46}
47
48impl fmt::Debug for EncryptedLocalSharedStatePackage {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter
51            .debug_struct("EncryptedLocalSharedStatePackage")
52            .field("candidate_id", &self.candidate_id)
53            .field("state_records", &self.upload.state.len())
54            .field("manifest_records", &self.upload.manifest.len())
55            .field("image_count", &self.upload.images.len())
56            .finish()
57    }
58}
59
60impl EncryptedLocalSharedStatePackage {
61    /// Copies the exact frozen descriptor, catalogs and encrypted records.
62    /// Returning these bytes does not make this local package dispatchable.
63    pub fn upload_package(&self) -> publication::Package {
64        self.upload.clone()
65    }
66
67    #[cfg(test)]
68    pub(crate) fn upload(&self) -> &publication::Package {
69        &self.upload
70    }
71
72    pub fn candidate_id(&self) -> &str {
73        &self.candidate_id
74    }
75}
76
77pub(crate) use aven_protocol::artifact::{EncryptedArtifact, EncryptedChunk};
78
79/// One freshly encrypted selected image, before the package is assembled.
80pub(super) struct EncryptedImage {
81    pub(super) sha256: String,
82    pub(super) object_id: [u8; 32],
83    pub(super) artifact: EncryptedArtifact<'static>,
84}
85
86struct SelectedImagePlaintext {
87    source_sha256: String,
88    bytes: Zeroizing<Vec<u8>>,
89}
90
91impl Database {
92    /// Reports frozen package ownership, including missing-byte corruption.
93    /// A true result requires the host to load existing protected authority,
94    /// never generate a replacement key.
95    pub async fn has_local_shared_state_package_never_dispatched(&self) -> Result<bool> {
96        let mut conn = self.acquire_reader().await?;
97        let exists: i64 = sqlx::query_scalar(
98            "SELECT EXISTS(SELECT 1 FROM local_shared_capture_publication LIMIT 1)
99                 OR EXISTS(SELECT 1 FROM local_shared_capture_journal
100                           WHERE frozen_descriptor_commitment IS NOT NULL)",
101        )
102        .fetch_one(&mut *conn)
103        .await?;
104        Ok(exists != 0)
105    }
106
107    /// Encrypts the durable never-dispatched capture or returns its frozen package.
108    ///
109    /// The first successful call freezes the descriptor, catalogs and encrypted
110    /// records in one SQLite transaction under the capture candidate ID.
111    /// Tentative preparation before that commit can be retried. Later calls
112    /// authenticate and return the exact saved bytes without reading image files.
113    /// `membership_predecessor` is context only, not proof of authorization.
114    /// Changed keys/context, incomplete data and incompatible local-only packages
115    /// fail without replacement. Cancellation releases bytes and ownership together.
116    /// SQLite commit/reopen is not a claim of platform power-loss durability.
117    pub async fn package_local_shared_state_never_dispatched(
118        &self,
119        blob_dir: &Path,
120        context: LocalSharedStatePackageContext,
121        key: &LocalSharedStatePackageKey,
122        membership_predecessor: [u8; 32],
123    ) -> Result<EncryptedLocalSharedStatePackage> {
124        let proof = self
125            .package_and_validate(blob_dir, context, key, membership_predecessor, None)
126            .await
127            .map_err(|error| {
128                if error.to_string() == "error seed-freeze-unsupported" {
129                    anyhow::anyhow!(
130                        "error seed-freeze-unsupported hint=cancel-never-dispatched-capture-and-recapture"
131                    )
132                } else {
133                    error
134                }
135            })?;
136        let frozen = upload::FrozenUpload::new(
137            proof.identity().candidate().to_string(),
138            proof.descriptor().to_vec(),
139            proof.metadata().catalogs.clone(),
140        )?;
141        Ok(EncryptedLocalSharedStatePackage {
142            candidate_id: proof.identity().candidate().to_string(),
143            upload: frozen.package(self).await?,
144        })
145    }
146
147    /// Freezes the package and returns the proof made by its single keyed pass.
148    ///
149    /// `capture`, when given, is the value capture returned and stands in for
150    /// reading the stored snapshot again. It is bound to the stored document
151    /// by its digest before the freeze commits, so a stale or foreign value
152    /// fails closed.
153    pub(crate) async fn package_and_validate(
154        &self,
155        blob_dir: &Path,
156        context: LocalSharedStatePackageContext,
157        key: &LocalSharedStatePackageKey,
158        membership_predecessor: [u8; 32],
159        capture: Option<NeverDispatchedLocalSharedCapture>,
160    ) -> Result<ValidatedSeed> {
161        freeze::freeze_and_validate(
162            self,
163            blob_dir,
164            context,
165            key,
166            membership_predecessor,
167            capture,
168        )
169        .await
170    }
171
172    /// Authenticates, decrypts, validates, and atomically installs a package.
173    ///
174    /// All package bytes are checked before the target database transaction is
175    /// opened. Installation still enforces the existing empty-target boundary.
176    pub async fn decrypt_and_install_local_shared_state_package(
177        &self,
178        package: &EncryptedLocalSharedStatePackage,
179        key: &LocalSharedStatePackageKey,
180    ) -> Result<super::SharedStateInstallReport> {
181        let capture = publication::decrypt_capture(&package.upload, key)?;
182        self.install_shared_state(&capture).await
183    }
184}
185
186#[cfg(test)]
187fn encrypt_package(
188    capture: &NeverDispatchedLocalSharedCapture,
189    context: LocalSharedStatePackageContext,
190    selected_images: &[SelectedImagePlaintext],
191    key: &LocalSharedStatePackageKey,
192    membership_predecessor: [u8; 32],
193) -> Result<(
194    EncryptedLocalSharedStatePackage,
195    publication::AttachmentIndex,
196)> {
197    let stream_id = decode_context_id(capture.stream_id(), "stream")?;
198    let mut images = Vec::with_capacity(selected_images.len());
199    for image in selected_images {
200        let object_id = random_id()?;
201        let image_key = derive_image_key(key, context, object_id)?;
202        let artifact = encrypt_artifact(
203            &image.bytes,
204            context,
205            stream_id,
206            object_id,
207            IMAGE_FAMILY,
208            IMAGE_CLASS,
209            &image_key,
210        )?;
211        images.push(EncryptedImage {
212            sha256: image.source_sha256.clone(),
213            object_id,
214            artifact,
215        });
216    }
217    let (upload, attachments) =
218        publication::build(capture, context, images, key, membership_predecessor)?;
219    Ok((
220        EncryptedLocalSharedStatePackage {
221            candidate_id: capture.candidate_id().to_string(),
222            upload,
223        },
224        attachments,
225    ))
226}
227
228fn derive_bootstrap_class_key(
229    key: &LocalSharedStatePackageKey,
230    context: LocalSharedStatePackageContext,
231    stream_id: [u8; 32],
232    candidate: [u8; 32],
233    class: u8,
234) -> Result<Zeroizing<[u8; 32]>> {
235    let hkdf = Hkdf::<Sha256>::new(
236        Some(b"aven-e2ee/v1/generation"),
237        key.protected_storage_bytes().as_slice(),
238    );
239    let info = codec::cce(
240        "aven-e2ee/v1/key/bootstrap-artifact",
241        &[
242            &context.vault_id,
243            &context.generation_id,
244            &stream_id,
245            &candidate,
246            &[class],
247        ],
248    );
249    let mut output = [0_u8; 32];
250    hkdf.expand(&info, &mut output)
251        .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-key-derivation"))?;
252    Ok(Zeroizing::new(output))
253}
254
255pub(crate) fn derive_image_key(
256    key: &LocalSharedStatePackageKey,
257    context: LocalSharedStatePackageContext,
258    object_id: [u8; 32],
259) -> Result<Zeroizing<[u8; 32]>> {
260    let hkdf = Hkdf::<Sha256>::new(
261        Some(b"aven-e2ee/v1/generation"),
262        key.protected_storage_bytes().as_slice(),
263    );
264    let info = codec::cce(
265        "aven-e2ee/v1/key/image-object",
266        &[&context.vault_id, &context.generation_id, &object_id],
267    );
268    let mut output = [0_u8; 32];
269    hkdf.expand(&info, &mut output)
270        .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-key-derivation"))?;
271    Ok(Zeroizing::new(output))
272}
273
274#[allow(clippy::too_many_arguments)]
275pub(crate) fn encrypt_artifact(
276    plaintext: &[u8],
277    context: LocalSharedStatePackageContext,
278    stream_id: [u8; 32],
279    artifact_id: [u8; 32],
280    family: u8,
281    class: u8,
282    key: &[u8; 32],
283) -> Result<EncryptedArtifact<'static>> {
284    let mut writer = ArtifactWriter::new(context, stream_id, artifact_id, family, class, key)?;
285    writer.begin(plaintext.len())?;
286    writer.write(plaintext)?;
287    writer.finish()
288}
289
290/// Encrypts an artifact of known length as its plaintext arrives, directly
291/// into its chunk records, so the whole plaintext never has to exist at once.
292pub(crate) struct ArtifactWriter {
293    cipher: XChaCha20Poly1305,
294    context: LocalSharedStatePackageContext,
295    stream_id: [u8; 32],
296    artifact_id: [u8; 32],
297    family: u8,
298    class: u8,
299    total: Option<u64>,
300    count: usize,
301    chunks: Vec<EncryptedChunk<'static>>,
302    /// The open chunk: header and body length, then plaintext so far.
303    record: Vec<u8>,
304    body: usize,
305    chunk_len: usize,
306    nonce: [u8; 24],
307}
308
309impl ArtifactWriter {
310    pub(crate) fn new(
311        context: LocalSharedStatePackageContext,
312        stream_id: [u8; 32],
313        artifact_id: [u8; 32],
314        family: u8,
315        class: u8,
316        key: &[u8; 32],
317    ) -> Result<Self> {
318        let cipher = XChaCha20Poly1305::new_from_slice(key)
319            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-invalid-key"))?;
320        Ok(Self {
321            cipher,
322            context,
323            stream_id,
324            artifact_id,
325            family,
326            class,
327            total: None,
328            count: 0,
329            chunks: Vec::new(),
330            record: Vec::new(),
331            body: 0,
332            chunk_len: 0,
333            nonce: [0; 24],
334        })
335    }
336
337    /// Declares the plaintext length, once, before any plaintext.
338    pub(crate) fn begin(&mut self, total: usize) -> Result<()> {
339        ensure!(
340            self.total.is_none(),
341            "error encrypted-local-shared-package-artifact-length"
342        );
343        self.count = total.div_ceil(CHUNK_PLAINTEXT_BYTES).max(1);
344        u32::try_from(self.count)?;
345        self.chunks.reserve_exact(self.count);
346        self.total = Some(u64::try_from(total)?);
347        Ok(())
348    }
349
350    pub(crate) fn write(&mut self, mut bytes: &[u8]) -> Result<()> {
351        while !bytes.is_empty() {
352            if self.record.is_empty() {
353                self.start_chunk()?;
354            }
355            let room = self.chunk_len - (self.record.len() - self.body);
356            let taken = room.min(bytes.len());
357            self.record.extend_from_slice(&bytes[..taken]);
358            bytes = &bytes[taken..];
359            if self.record.len() - self.body == self.chunk_len {
360                self.seal_chunk()?;
361            }
362        }
363        Ok(())
364    }
365
366    pub(crate) fn finish(mut self) -> Result<EncryptedArtifact<'static>> {
367        let total = self
368            .total
369            .context("error encrypted-local-shared-package-artifact-length")?;
370        if total == 0 {
371            self.start_chunk()?;
372            self.seal_chunk()?;
373        }
374        ensure!(
375            self.record.is_empty() && self.chunks.len() == self.count,
376            "error encrypted-local-shared-package-artifact-length"
377        );
378        let chunks = std::mem::take(&mut self.chunks);
379        Ok(EncryptedArtifact {
380            total_plaintext_bytes: total,
381            aggregate_commitment: aggregate_commitment(&chunks),
382            chunks,
383        })
384    }
385
386    fn start_chunk(&mut self) -> Result<()> {
387        let total = self
388            .total
389            .context("error encrypted-local-shared-package-artifact-length")?;
390        let index = self.chunks.len();
391        ensure!(
392            index < self.count,
393            "error encrypted-local-shared-package-artifact-length"
394        );
395        self.chunk_len = expected_chunk_plaintext_len(index, self.count, usize::try_from(total)?)?;
396        getrandom::fill(&mut self.nonce).context("error encrypted-local-shared-package-rng")?;
397        let header = chunk_header(
398            self.context,
399            self.stream_id,
400            self.artifact_id,
401            self.family,
402            self.class,
403            u32::try_from(index)?,
404            u32::try_from(self.count)?,
405            total,
406            self.nonce,
407        )?;
408        self.record = Vec::with_capacity(CHUNK_RECORD_OVERHEAD + self.chunk_len);
409        codec::bytes(&mut self.record, &header);
410        self.record
411            .extend_from_slice(&u32::try_from(self.chunk_len + TAG_BYTES)?.to_be_bytes());
412        self.body = self.record.len();
413        Ok(())
414    }
415
416    /// Encrypts the open chunk in place and appends its tag.
417    fn seal_chunk(&mut self) -> Result<()> {
418        let nonce = XNonce::try_from(self.nonce.as_slice())
419            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-nonce-size"))?;
420        let (head, body) = self.record.split_at_mut(self.body);
421        let tag = self
422            .cipher
423            .encrypt_inout_detached(&nonce, &head[4..4 + CHUNK_HEADER_BYTES], body.into())
424            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-encryption"))?;
425        let mut record = std::mem::take(&mut self.record);
426        record.extend_from_slice(&tag);
427        self.chunks.push(EncryptedChunk {
428            record_commitment: codec::hash(&record),
429            record: record.into(),
430        });
431        Ok(())
432    }
433}
434
435impl Drop for ArtifactWriter {
436    fn drop(&mut self) {
437        // An unsealed chunk still holds plaintext.
438        self.record.zeroize();
439    }
440}
441
442#[allow(clippy::too_many_arguments)]
443pub(crate) fn decrypt_artifact(
444    artifact: &EncryptedArtifact<'_>,
445    context: LocalSharedStatePackageContext,
446    stream_id: [u8; 32],
447    artifact_id: [u8; 32],
448    family: u8,
449    class: u8,
450    key: &[u8; 32],
451    maximum_plaintext_bytes: usize,
452) -> Result<Vec<u8>> {
453    let mut reader = ArtifactReader::new(
454        artifact,
455        context,
456        stream_id,
457        artifact_id,
458        family,
459        class,
460        key,
461        maximum_plaintext_bytes,
462    )?;
463    let mut plaintext = Vec::with_capacity(usize::try_from(artifact.total_plaintext_bytes)?);
464    loop {
465        let piece = reader.take(usize::MAX)?;
466        if piece.is_empty() {
467            break;
468        }
469        plaintext.extend_from_slice(piece);
470    }
471    reader.finish()?;
472    Ok(plaintext)
473}
474
475/// Authenticates and decrypts an artifact one chunk at a time, so at most one
476/// chunk of plaintext exists at once. Every byte handed out has passed its
477/// chunk's authentication, but only [`Self::finish`] establishes that the
478/// artifact was complete.
479pub(crate) struct ArtifactReader<'a> {
480    artifact: &'a EncryptedArtifact<'a>,
481    cipher: XChaCha20Poly1305,
482    context: LocalSharedStatePackageContext,
483    stream_id: [u8; 32],
484    artifact_id: [u8; 32],
485    family: u8,
486    class: u8,
487    /// Chunks decrypted so far.
488    index: usize,
489    chunk: Zeroizing<Vec<u8>>,
490    position: usize,
491}
492
493impl<'a> ArtifactReader<'a> {
494    #[allow(clippy::too_many_arguments)]
495    pub(crate) fn new(
496        artifact: &'a EncryptedArtifact<'a>,
497        context: LocalSharedStatePackageContext,
498        stream_id: [u8; 32],
499        artifact_id: [u8; 32],
500        family: u8,
501        class: u8,
502        key: &[u8; 32],
503        maximum_plaintext_bytes: usize,
504    ) -> Result<Self> {
505        ensure!(
506            !artifact.chunks.is_empty()
507                && artifact.chunks.len() <= MAX_STATE_CHUNKS
508                && artifact.total_plaintext_bytes <= u64::try_from(maximum_plaintext_bytes)?,
509            "error encrypted-local-shared-package-artifact-bounds"
510        );
511        ensure!(
512            aggregate_commitment(&artifact.chunks) == artifact.aggregate_commitment
513                && artifact
514                    .chunks
515                    .iter()
516                    .all(|chunk| codec::hash(&chunk.record) == chunk.record_commitment),
517            "error encrypted-local-shared-package-aggregate-mismatch"
518        );
519        let cipher = XChaCha20Poly1305::new_from_slice(key)
520            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-invalid-key"))?;
521        Ok(Self {
522            artifact,
523            cipher,
524            context,
525            stream_id,
526            artifact_id,
527            family,
528            class,
529            index: 0,
530            chunk: Zeroizing::new(Vec::new()),
531            position: 0,
532        })
533    }
534
535    /// The plaintext bytes not yet taken.
536    pub(crate) fn remaining(&self) -> u64 {
537        let taken = self.index.saturating_sub(1) * CHUNK_PLAINTEXT_BYTES + self.position;
538        self.artifact.total_plaintext_bytes - taken as u64
539    }
540
541    /// Up to `n` further plaintext bytes, never crossing a chunk boundary.
542    /// Empty only at the end of the artifact.
543    pub(crate) fn take(&mut self, n: usize) -> Result<&[u8]> {
544        if self.position == self.chunk.len() {
545            if self.index == self.artifact.chunks.len() {
546                return Ok(&[]);
547            }
548            self.decrypt_next()?;
549        }
550        let end = self.position + n.min(self.chunk.len() - self.position);
551        let piece = &self.chunk[self.position..end];
552        self.position = end;
553        Ok(piece)
554    }
555
556    fn decrypt_next(&mut self) -> Result<()> {
557        let index = self.index;
558        let count = self.artifact.chunks.len();
559        let (header, ciphertext) = split_record(&self.artifact.chunks[index].record)?;
560        let nonce = validate_chunk_header(
561            header,
562            self.context,
563            self.stream_id,
564            self.artifact_id,
565            self.family,
566            self.class,
567            u32::try_from(index)?,
568            u32::try_from(count)?,
569            self.artifact.total_plaintext_bytes,
570        )?;
571        let nonce_value = XNonce::try_from(nonce.as_slice())
572            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-nonce-size"))?;
573        let (ciphertext, tag) = ciphertext.split_at(ciphertext.len() - TAG_BYTES);
574        self.chunk.clear();
575        self.chunk.extend_from_slice(ciphertext);
576        self.cipher
577            .decrypt_inout_detached(
578                &nonce_value,
579                header,
580                (&mut self.chunk[..]).into(),
581                tag.try_into()?,
582            )
583            .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-authentication"))?;
584        let expected = expected_chunk_plaintext_len(
585            index,
586            count,
587            usize::try_from(self.artifact.total_plaintext_bytes)?,
588        )?;
589        ensure!(
590            self.chunk.len() == expected,
591            "error encrypted-local-shared-package-chunk-size-mismatch"
592        );
593        self.index += 1;
594        self.position = 0;
595        Ok(())
596    }
597
598    /// Checks that every chunk was decrypted and every byte taken.
599    pub(crate) fn finish(self) -> Result<()> {
600        ensure!(
601            self.index == self.artifact.chunks.len() && self.position == self.chunk.len(),
602            "error encrypted-local-shared-package-total-size-mismatch"
603        );
604        Ok(())
605    }
606}
607
608fn expected_chunk_plaintext_len(index: usize, count: usize, total: usize) -> Result<usize> {
609    ensure!(index < count && count > 0, "invalid chunk index");
610    if index + 1 < count {
611        return Ok(CHUNK_PLAINTEXT_BYTES);
612    }
613    let preceding = index
614        .checked_mul(CHUNK_PLAINTEXT_BYTES)
615        .context("chunk length overflow")?;
616    total.checked_sub(preceding).context("invalid chunk total")
617}
618
619pub(crate) use aven_protocol::record::{chunk_header, split_record, validate_chunk_header};
620
621fn aggregate_commitment(chunks: &[EncryptedChunk<'_>]) -> [u8; 32] {
622    let mut digest = Sha256::new();
623    for chunk in chunks {
624        digest.update(&chunk.record);
625    }
626    digest.finalize().into()
627}
628
629async fn load_selected_image_plaintexts(
630    blob_dir: &Path,
631    selected: &[(String, String)],
632    snapshot: &AvenExport,
633) -> Result<(Vec<SelectedImagePlaintext>, Vec<String>)> {
634    ensure!(
635        selected.len() <= MAX_PACKAGE_IMAGE_COUNT,
636        "error encrypted-local-shared-package-too-many-images"
637    );
638    let mut total = 0_usize;
639    let mut output = Vec::with_capacity(selected.len());
640    let mut missing = Vec::new();
641    for (source_sha256, classification) in selected {
642        ensure!(
643            matches!(
644                classification.as_str(),
645                "current_selected" | "extra_selected"
646            ),
647            "error encrypted-local-shared-package-image-classification"
648        );
649        let inventory = snapshot
650            .tables
651            .blob_inventory
652            .iter()
653            .find(|row| row.sha256 == source_sha256.as_str())
654            .context("error encrypted-local-shared-package-image-inventory-missing")?;
655        let path = crate::attachments::storage::object_path(blob_dir, source_sha256)?;
656        let read =
657            crate::attachments::blocking::run(move || Ok::<_, anyhow::Error>(std::fs::read(path)?))
658                .await;
659        let bytes = match read {
660            Ok(bytes) => Zeroizing::new(bytes),
661            Err(error)
662                if error
663                    .downcast_ref::<std::io::Error>()
664                    .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
665            {
666                missing.push(source_sha256.clone());
667                continue;
668            }
669            Err(error) => {
670                let attachment = snapshot
671                    .tables
672                    .task_attachments
673                    .iter()
674                    .find(|attachment| attachment.sha256 == source_sha256.as_str());
675                let task = attachment.and_then(|attachment| {
676                    snapshot.tables.tasks.iter().find(|task| {
677                        task.id == attachment.task_id
678                            && task.workspace_id == attachment.workspace_id
679                    })
680                });
681                return Err(error).with_context(|| {
682                    format!(
683                        "error encrypted-local-shared-package-image-read task={:?} attachment={:?}",
684                        task.map(|task| task.title.as_str())
685                            .unwrap_or("unknown task"),
686                        attachment
687                            .and_then(|attachment| attachment.filename.as_deref())
688                            .unwrap_or("unknown attachment")
689                    )
690                });
691            }
692        };
693        ensure!(
694            !bytes.is_empty()
695                && bytes.len() <= MAX_IMAGE_PLAINTEXT_BYTES
696                && i64::try_from(bytes.len())? == inventory.byte_size,
697            "error encrypted-local-shared-package-selected-image-size-mismatch"
698        );
699        ensure!(
700            crate::attachments::storage::sha256_hex(&bytes) == source_sha256.as_str(),
701            "error encrypted-local-shared-package-selected-image-hash-mismatch"
702        );
703        let media_type = inventory.media_type.clone();
704        let decode_bytes = bytes.to_vec();
705        let facts = crate::attachments::blocking::run(move || {
706            crate::attachments::decode::validate_image_blocking(decode_bytes, Some(&media_type))
707        })
708        .await
709        .context("error encrypted-local-shared-package-selected-image-invalid")?
710        .facts;
711        for attachment in snapshot
712            .tables
713            .task_attachments
714            .iter()
715            .filter(|attachment| attachment.sha256 == source_sha256.as_str())
716        {
717            ensure!(
718                attachment.media_type == inventory.media_type
719                    && attachment.byte_size == inventory.byte_size
720                    && attachment.width == Some(facts.width)
721                    && attachment.height == Some(facts.height),
722                "error encrypted-local-shared-package-selected-image-metadata-mismatch"
723            );
724        }
725        total = total
726            .checked_add(bytes.len())
727            .context("image total overflow")?;
728        ensure!(
729            total <= MAX_PACKAGE_IMAGE_PLAINTEXT_BYTES,
730            "error encrypted-local-shared-package-images-too-large"
731        );
732        output.push(SelectedImagePlaintext {
733            source_sha256: source_sha256.clone(),
734            bytes,
735        });
736    }
737    Ok((output, missing))
738}
739
740async fn mark_capture_images_unavailable(
741    database: &Database,
742    candidate_id: &str,
743    hashes: &[String],
744) -> Result<()> {
745    let mut conn = database.acquire_writer().await?;
746    let mut tx = db::begin_immediate(&mut conn).await?;
747    super::adoption::ensure_no_intent(&mut tx).await?;
748    ensure!(
749        !freeze::frozen_exists(&mut tx, candidate_id).await?,
750        "error encrypted-local-shared-package-frozen"
751    );
752    let snapshot: Vec<u8> = sqlx::query_scalar(
753        "SELECT snapshot FROM local_shared_capture_documents WHERE candidate_id = ?",
754    )
755    .bind(candidate_id)
756    .fetch_one(&mut *tx)
757    .await?;
758    let mut persisted: super::PersistedLocalCapture =
759        serde_json::from_str(&super::unpack_document(&snapshot)?)?;
760    for hash in hashes {
761        let image = persisted
762            .images
763            .iter_mut()
764            .find(|image| image.sha256 == *hash)
765            .context("error local-shared-capture-image-set-mismatch")?;
766        image.classification = "unavailable".to_string();
767        sqlx::query(
768            "UPDATE local_shared_capture_images SET classification = 'unavailable'
769             WHERE candidate_id = ? AND sha256 = ?",
770        )
771        .bind(candidate_id)
772        .bind(hash)
773        .execute(&mut *tx)
774        .await?;
775        sqlx::query("DELETE FROM local_shared_capture_pins WHERE candidate_id = ? AND sha256 = ?")
776            .bind(candidate_id)
777            .bind(hash)
778            .execute(&mut *tx)
779            .await?;
780    }
781    sqlx::query("UPDATE local_shared_capture_documents SET snapshot = ? WHERE candidate_id = ?")
782        .bind(super::pack_document(&persisted)?)
783        .bind(candidate_id)
784        .execute(&mut *tx)
785        .await?;
786    tx.commit().await?;
787    Ok(())
788}
789
790fn random_id() -> Result<[u8; 32]> {
791    let mut bytes = [0_u8; 32];
792    getrandom::fill(&mut bytes).context("error encrypted-local-shared-package-rng")?;
793    Ok(bytes)
794}
795
796fn decode_context_id(value: &str, name: &str) -> Result<[u8; 32]> {
797    let decoded = hex::decode(value)
798        .with_context(|| format!("error encrypted-local-shared-package-{name}-id-invalid"))?;
799    decoded
800        .try_into()
801        .map_err(|_| anyhow::anyhow!("error encrypted-local-shared-package-{name}-id-invalid"))
802}
803
804/// Freezes `package`: its bytes, the object ID of each selected image, and
805/// the commitments to the capture it was built from, all under `conn`.
806#[cfg(test)]
807async fn persist_package(
808    conn: &mut sqlx::SqliteConnection,
809    package: &EncryptedLocalSharedStatePackage,
810    capture: &NeverDispatchedLocalSharedCapture,
811    attachments: &publication::AttachmentIndex,
812) -> Result<()> {
813    let upload = &package.upload;
814    sqlx::query(
815        "INSERT INTO local_shared_capture_publication(
816             candidate_id, descriptor, data_catalog, prefix_catalog, image_catalog
817         ) VALUES (?, ?, ?, ?, ?)",
818    )
819    .bind(&package.candidate_id)
820    .bind(&upload.descriptor)
821    .bind(&upload.catalogs[0])
822    .bind(&upload.catalogs[1])
823    .bind(&upload.catalogs[2])
824    .execute(&mut *conn)
825    .await?;
826    let components = [
827        (STATE_COMPONENT, &[][..], &upload.state),
828        (MANIFEST_COMPONENT, &[][..], &upload.manifest),
829    ]
830    .into_iter()
831    .chain(
832        upload
833            .images
834            .iter()
835            .map(|image| (IMAGE_COMPONENT, &image.object_id[..], &image.records)),
836    );
837    for (component, object_id, records) in components {
838        for (index, record) in records.iter().enumerate() {
839            sqlx::query(
840                "INSERT INTO local_shared_capture_package_records(
841                     candidate_id, component, object_id, chunk_index, record
842                 ) VALUES (?, ?, ?, ?, ?)",
843            )
844            .bind(&package.candidate_id)
845            .bind(component)
846            .bind(object_id)
847            .bind(i64::try_from(index)?)
848            .bind(record)
849            .execute(&mut *conn)
850            .await?;
851        }
852    }
853    let updated = sqlx::query(
854        "UPDATE local_shared_capture_journal SET frozen_descriptor_commitment = ?
855         WHERE candidate_id = ? AND frozen_descriptor_commitment IS NULL",
856    )
857    .bind(codec::hash(&upload.descriptor).as_slice())
858    .bind(&package.candidate_id)
859    .execute(&mut *conn)
860    .await?;
861    ensure!(
862        updated.rows_affected() == 1,
863        "error encrypted-local-shared-package-already-frozen"
864    );
865    super::validated::record_freeze(conn, capture, attachments).await
866}
867
868/// Descriptor and the data, prefix and image catalogs.
869#[cfg(test)]
870type PublicationRow = (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>);
871
872/// Whether the stored rows are exactly `package` under its freeze commitment,
873/// so [`load_package`] would return it. Records are read one at a time rather
874/// than as a second copy of the package.
875#[cfg(test)]
876async fn stored_package_matches(
877    conn: &mut sqlx::SqliteConnection,
878    package: &EncryptedLocalSharedStatePackage,
879) -> Result<bool> {
880    let upload = &package.upload;
881    let frozen: Option<Vec<u8>> = sqlx::query_scalar(
882        "SELECT frozen_descriptor_commitment FROM local_shared_capture_journal
883         WHERE candidate_id = ?",
884    )
885    .bind(&package.candidate_id)
886    .fetch_optional(&mut *conn)
887    .await?
888    .flatten();
889    if frozen.as_deref() != Some(codec::hash(&upload.descriptor).as_slice()) {
890        return Ok(false);
891    }
892    let publication: Option<PublicationRow> = sqlx::query_as(
893        "SELECT descriptor, data_catalog, prefix_catalog, image_catalog
894         FROM local_shared_capture_publication WHERE candidate_id = ?",
895    )
896    .bind(&package.candidate_id)
897    .fetch_optional(&mut *conn)
898    .await?;
899    if publication.is_none_or(|(descriptor, data, prefix, images)| {
900        descriptor != upload.descriptor || [data, prefix, images] != upload.catalogs
901    }) {
902        return Ok(false);
903    }
904    let components = [
905        (STATE_COMPONENT, &[][..], &upload.state),
906        (MANIFEST_COMPONENT, &[][..], &upload.manifest),
907    ]
908    .into_iter()
909    .chain(
910        upload
911            .images
912            .iter()
913            .map(|image| (IMAGE_COMPONENT, &image.object_id[..], &image.records)),
914    );
915    let mut expected = 0_i64;
916    for (component, object_id, records) in components {
917        for (index, record) in records.iter().enumerate() {
918            let stored: Option<Vec<u8>> = sqlx::query_scalar(
919                "SELECT record FROM local_shared_capture_package_records
920                 WHERE candidate_id = ? AND component = ? AND object_id = ?
921                   AND chunk_index = ?",
922            )
923            .bind(&package.candidate_id)
924            .bind(component)
925            .bind(object_id)
926            .bind(i64::try_from(index)?)
927            .fetch_optional(&mut *conn)
928            .await?;
929            if stored.as_ref() != Some(record) {
930                return Ok(false);
931            }
932            expected += 1;
933        }
934    }
935    let count: i64 = sqlx::query_scalar(
936        "SELECT count(*) FROM local_shared_capture_package_records WHERE candidate_id = ?",
937    )
938    .bind(&package.candidate_id)
939    .fetch_one(&mut *conn)
940    .await?;
941    Ok(count == expected)
942}
943
944/// Loads the frozen package and verifies every record against the committed
945/// descriptor and catalogs. Missing or corrupt bytes fail without replacement.
946#[cfg(test)]
947pub(super) async fn load_package(
948    conn: &mut sqlx::SqliteConnection,
949    candidate_id: &str,
950) -> Result<Option<EncryptedLocalSharedStatePackage>> {
951    let frozen: Option<Vec<u8>> = sqlx::query_scalar(
952        "SELECT frozen_descriptor_commitment FROM local_shared_capture_journal
953         WHERE candidate_id = ?",
954    )
955    .bind(candidate_id)
956    .fetch_optional(&mut *conn)
957    .await?
958    .flatten();
959    let publication: Option<PublicationRow> = sqlx::query_as(
960        "SELECT descriptor, data_catalog, prefix_catalog, image_catalog
961         FROM local_shared_capture_publication WHERE candidate_id = ?",
962    )
963    .bind(candidate_id)
964    .fetch_optional(&mut *conn)
965    .await?;
966    let Some((descriptor, data_catalog, prefix_catalog, image_catalog)) = publication else {
967        ensure!(
968            frozen.is_none(),
969            "error encrypted-local-shared-package-frozen-bytes-missing hint=cancel-never-dispatched-capture-and-recapture"
970        );
971        return Ok(None);
972    };
973    ensure!(
974        frozen.as_deref() == Some(codec::hash(&descriptor).as_slice()),
975        "error encrypted-local-shared-package-frozen-descriptor-mismatch"
976    );
977    let rows: Vec<(String, Vec<u8>, i64, Vec<u8>)> = sqlx::query_as(
978        "SELECT component, object_id, chunk_index, record
979         FROM local_shared_capture_package_records
980         WHERE candidate_id = ? ORDER BY component, object_id, chunk_index",
981    )
982    .bind(candidate_id)
983    .fetch_all(&mut *conn)
984    .await?;
985    let mut upload = publication::Package {
986        descriptor,
987        catalogs: [data_catalog, prefix_catalog, image_catalog],
988        state: Vec::new(),
989        manifest: Vec::new(),
990        images: Vec::new(),
991    };
992    for (component, object_id, index, record) in rows {
993        let records = match component.as_str() {
994            STATE_COMPONENT => &mut upload.state,
995            MANIFEST_COMPONENT => &mut upload.manifest,
996            IMAGE_COMPONENT => {
997                let object_id = <[u8; 32]>::try_from(object_id.as_slice()).map_err(|_| {
998                    anyhow::anyhow!("error encrypted-local-shared-package-invalid-image-object")
999                })?;
1000                if upload
1001                    .images
1002                    .last()
1003                    .is_none_or(|image| image.object_id != object_id)
1004                {
1005                    upload.images.push(publication::ImageRecords {
1006                        object_id,
1007                        records: Vec::new(),
1008                    });
1009                }
1010                &mut upload
1011                    .images
1012                    .last_mut()
1013                    .expect("image group was just ensured")
1014                    .records
1015            }
1016            _ => anyhow::bail!("error encrypted-local-shared-package-invalid-component"),
1017        };
1018        ensure!(
1019            usize::try_from(index)? == records.len(),
1020            "error encrypted-local-shared-package-chunk-order"
1021        );
1022        records.push(record);
1023    }
1024    publication::validate_keyless(&upload)
1025        .context("error encrypted-local-shared-package-frozen-records-invalid")?;
1026    Ok(Some(EncryptedLocalSharedStatePackage {
1027        candidate_id: candidate_id.to_string(),
1028        upload,
1029    }))
1030}
1031
1032#[cfg(test)]
1033#[path = "package_tests.rs"]
1034mod tests;
1035
1036#[cfg(test)]
1037pub(crate) mod test_support;
1038
1039#[cfg(test)]
1040mod durable_tests;