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#[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 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
79pub(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 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 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 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 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
290pub(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 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 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 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 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
475pub(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 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 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 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 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#[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#[cfg(test)]
870type PublicationRow = (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>);
871
872#[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#[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;