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