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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct LocalSharedStatePackageContext {
38 pub vault_id: [u8; 32],
39 pub generation_id: [u8; 32],
40}
41
42pub 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 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#[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 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#[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 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
148pub(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 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 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 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 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
353pub(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 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 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 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 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
538pub(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 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 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 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 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#[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#[cfg(test)]
1032type PublicationRow = (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>);
1033
1034#[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#[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;