1use super::membership::{COVERAGE_LIMIT, EvidenceRef, FLOOR_LIMIT};
3use super::*;
4use crate::db::installation::InstallationGuard;
5use crate::sync::{
6 SeedPublicationIntent,
7 seed_claim::{
8 Secret, SeedAuthority,
9 membership::{
10 self, Declaration, Device, Evidence, Invitation, Joiner, Membership,
11 VerifiedEnrollment, VerifiedKeys,
12 },
13 },
14};
15use anyhow::{Context, Result, ensure};
16use serde::{Deserialize, Serialize};
17#[cfg(any(test, feature = "test-support"))]
18use std::sync::atomic::Ordering;
19type Hash = [u8; 32];
20const IDENTITY_LIMIT: usize = 8192;
21const JOURNAL_LIMIT: usize = 4096;
22const CANDIDATE_LIMIT: usize = 4 * membership::MAX_RECORD_BYTES + 1024;
25const RESPONSE_LIMIT: usize = CANDIDATE_LIMIT + 4096;
26const ATTEMPT_LIMIT: usize = 512;
27pub const MAX_JOIN_ATTEMPTS: usize = 16;
29#[derive(Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct Identity {
32 role: String,
33 client: String,
34 incarnation: Hash,
35 locator: String,
36 authority: Vec<u8>,
37}
38impl Drop for Identity {
39 fn drop(&mut self) {
40 self.authority.zeroize();
41 }
42}
43#[derive(Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Outbound {
46 pub handle: Hash,
47 invitation: Vec<u8>,
48 pub declaration: Vec<u8>,
49}
50impl Drop for Outbound {
51 fn drop(&mut self) {
52 self.invitation.zeroize();
53 }
54}
55#[derive(Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57struct Candidate {
58 predecessor: EvidenceRef,
59 record: Vec<u8>,
60}
61#[derive(Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63struct Verified {
64 evidence: EvidenceRef,
65 outcome: Hash,
66 key: Vec<u8>,
67}
68impl Drop for Verified {
69 fn drop(&mut self) {
70 self.key.zeroize();
71 }
72}
73
74#[derive(Debug, PartialEq, Eq)]
76pub enum EnrollmentReadiness {
77 NotSelected,
78 Pending,
79 Enrolled { head: Hash },
80}
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub struct OpenInvitation {
84 pub handle: Hash,
85 pub expires_at: u64,
86 pub keys_may_have_been_sent: bool,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum InvitationProgress {
92 Open,
93 Admitted,
94 Closed,
95}
96
97#[cfg(any(test, feature = "test-support"))]
99#[derive(Debug, PartialEq, Eq)]
100pub enum OutboundInvitation {
101 Pending,
103 Disclosed,
105}
106#[derive(Debug)]
109pub struct PublishingBlocked;
110impl std::fmt::Display for PublishingBlocked {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.write_str("error withdrawal-rotation-required")
113 }
114}
115impl std::error::Error for PublishingBlocked {}
116enum Keys {
117 Seed(Box<SeedAuthority>),
118 Peer(Box<Joiner>),
119}
120impl Keys {
121 fn authority(&self) -> Device<'_> {
122 match self {
123 Self::Seed(s) => Device::seed(s),
124 Self::Peer(p) => p.authority(),
125 }
126 }
127 fn device(&self) -> Hash {
128 match self {
129 Self::Seed(s) => s.genesis().device_id(),
130 Self::Peer(p) => p.device(),
131 }
132 }
133 fn bearer(&self) -> &Secret {
134 match self {
135 Self::Seed(s) => s.bearer(),
136 Self::Peer(p) => p.bearer(),
137 }
138 }
139}
140pub struct ActiveInputs {
141 id: Identity,
142 keys: Keys,
143 pub membership: Membership,
144 pub evidence: Evidence,
145 coverage: VerifiedKeys,
146 _installation: InstallationGuard,
147 _lock: StoreLock,
148}
149impl ActiveInputs {
150 pub(super) fn authority(&self) -> Device<'_> {
151 self.keys.authority()
152 }
153
154 pub fn generation_keys(&self) -> &VerifiedKeys {
155 &self.coverage
156 }
157 pub fn device(&self) -> Hash {
158 self.keys.device()
159 }
160 pub fn bearer(&self) -> &Secret {
161 self.keys.bearer()
162 }
163}
164impl ProtectedLocalKeyStore {
165 pub(super) fn peer_authority_exists(&self) -> StoreResult<bool> {
166 Ok(self.read_record("peer-identity-authority", 32)?.is_some()
167 || self.load_secret("peer-identity", IDENTITY_LIMIT)?.is_some())
168 }
169 pub(super) async fn phase(
170 &self,
171 db: &Database,
172 name: &str,
173 size: usize,
174 ) -> Result<Option<Zeroizing<Vec<u8>>>> {
175 let pin = self.enrollment_pin(db, name).await?;
176 let bytes = self.read_owned(name, size, pin.is_some())?;
177 if let (Some(pin), Some(bytes)) = (pin, &bytes) {
178 ensure!(
179 pin == Sha256::digest(bytes.as_slice()).as_slice(),
180 "error enrollment-phase-mismatch"
181 );
182 }
183 Ok(bytes)
184 }
185 async fn save_phase(
186 &self,
187 db: &Database,
188 id: &Identity,
189 name: &str,
190 size: usize,
191 bytes: &[u8],
192 ) -> Result<()> {
193 self.phase(db, name, size).await?;
194 self.write_owned(name, size, bytes)?;
195 let digest: Hash = Sha256::digest(bytes).into();
196 db.pin_enrollment_artifact(id.incarnation, name, digest)
197 .await?;
198 self.with_index(|index| {
199 if let Some(pins) = &mut index.pins {
200 pins.insert(name.to_string(), digest.to_vec());
201 }
202 });
203 Ok(())
204 }
205 async fn enrollment_pin(&self, db: &Database, name: &str) -> Result<Option<Vec<u8>>> {
208 match self.with_index(|index| index.pins.as_ref().map(|pins| pins.get(name).cloned())) {
209 None => db.enrollment_artifact(name).await,
210 Some(Some(pin)) => Ok(pin),
211 Some(None) => {
212 let pins = db.enrollment_artifacts().await?;
213 let pin = pins.get(name).cloned();
214 self.with_index(|index| index.pins = Some(pins));
215 Ok(pin)
216 }
217 }
218 }
219 pub(super) async fn save_management_phase(
220 &self,
221 db: &Database,
222 inputs: &ActiveInputs,
223 name: &str,
224 size: usize,
225 bytes: &[u8],
226 ) -> Result<()> {
227 self.save_phase(db, &inputs.id, name, size, bytes).await
228 }
229 async fn identity(&self, db: &Database, guard: &InstallationGuard) -> Result<Option<Identity>> {
230 let pin = db.enrollment_pin().await?;
231 let Some(bytes) = self.read_owned("peer-identity", IDENTITY_LIMIT, pin.is_some())? else {
232 ensure!(
233 db.membership_checkpoint_mirror().await?.is_none()
234 && self.journals(db).await?.is_empty(),
235 "error enrollment-protected-missing"
236 );
237 for (kind, size) in [
238 ("peer-sent", 128),
239 ("peer-response", RESPONSE_LIMIT),
240 ("peer-verified", 2048),
241 ("peer-ready", 128),
242 ("peer-installed", 128),
243 ] {
244 ensure!(
245 self.phase(db, kind, size).await?.is_none(),
246 "error enrollment-protected-missing"
247 );
248 }
249 for index in 1..MAX_JOIN_ATTEMPTS {
250 ensure!(
251 self.phase(db, &format!("peer-attempt-{index}"), ATTEMPT_LIMIT)
252 .await?
253 .is_none(),
254 "error enrollment-protected-missing"
255 );
256 }
257 for sequence in 1..=membership::MAX_TRANSITIONS + 1 {
258 ensure!(
259 self.read_owned(&format!("membership-floor-{sequence}"), FLOOR_LIMIT, false)?
260 .is_none(),
261 "error enrollment-protected-missing"
262 );
263 ensure!(
264 self.read_owned(
265 &format!("membership-coverage-{sequence}"),
266 COVERAGE_LIMIT,
267 false
268 )?
269 .is_none(),
270 "error enrollment-protected-missing"
271 );
272 }
273 return Ok(None);
274 };
275 let id: Identity = serde_json::from_slice(&bytes)
276 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
277 ensure!(
278 id.locator.len() <= 2048 && matches!(id.role.as_str(), "inviter" | "peer"),
279 "error enrollment-protected-framing"
280 );
281 if pin.is_none() {
282 ensure!(
283 self.read_owned("peer-ready", 128, false)?.is_none()
284 && self.read_owned("peer-sent", 128, false)?.is_none()
285 && self
286 .read_owned("outbound-0", JOURNAL_LIMIT, false)?
287 .is_none(),
288 "error enrollment-database-lost"
289 );
290 }
291 db.pin_enrollment(id.incarnation, &id.client, &id.role, guard)
292 .await?;
293 Ok(Some(id))
294 }
295 fn adopted_inputs(&self, seed: &SeedAuthority) -> Result<SeedPublicationIntent> {
296 let source = self.decode_source(
297 &self
298 .load_adoption_record("source", 104, true)?
299 .context("error enrollment-source-missing")?,
300 seed,
301 )?;
302 SeedPublicationIntent::from_protected_storage(
303 &self
304 .load_adoption_record("intent", 65536, true)?
305 .context("error enrollment-intent-missing")?,
306 &source,
307 seed.genesis(),
308 )
309 }
310 async fn seed_inputs(
311 &self,
312 db: &Database,
313 ) -> Result<(SeedAuthority, Evidence, LocalSharedStatePackageKey)> {
314 let package = self.load_required_locked()?;
315 let seed = self.required_seed(&package)?;
316 let intent = self.adopted_inputs(&seed)?;
317 let (saved, state) = db
318 .seed_publication_intent_bytes()
319 .await?
320 .context("error seed-intent-missing")?;
321 ensure!(
322 state == "adopted" && saved == intent.protected_storage_bytes(),
323 "error seed-intent-mismatch"
324 );
325 let source = self.decode_source(
326 &self
327 .load_adoption_record("source", 104, true)?
328 .context("error seed-source-missing")?,
329 &seed,
330 )?;
331 ensure!(
332 db.seed_source_pin().await?.as_deref() == Some(source.protected_storage_bytes()),
333 "error seed-source-mismatch"
334 );
335 let publication = intent.publication(seed.genesis())?;
336 let evidence = Evidence {
337 genesis: seed.genesis().record().to_vec(),
338 publication: publication.record().to_vec(),
339 descriptor: intent.descriptor().to_vec(),
340 transitions: vec![],
341 };
342 Ok((
343 seed,
344 evidence,
345 LocalSharedStatePackageKey::new(*package.package_key().protected_storage_bytes()),
346 ))
347 }
348 pub async fn active_inputs(&self, db: &Database, locator: &str) -> Result<ActiveInputs> {
351 Box::pin(self.load_active_inputs(db, locator)).await
352 }
353 async fn load_active_inputs(&self, db: &Database, locator: &str) -> Result<ActiveInputs> {
354 let installation = InstallationGuard::acquire(db.path())?;
355 self.validate_database(db).await?;
356 ensure!(locator.len() <= 2048, "error enrollment-locator-limit");
357 self.prepare()?;
358 let lock = self.lock()?;
359 let id = match self.identity(db, &installation).await? {
360 Some(id) => id,
361 None => {
362 let client = db.adopted_enrollment_client().await?;
363 self.seed_inputs(db).await?;
364 let id = Identity {
365 role: "inviter".into(),
366 client,
367 incarnation: *Secret::generate()?.expose(),
368 locator: locator.into(),
369 authority: vec![],
370 };
371 self.write_owned(
372 "peer-identity",
373 IDENTITY_LIMIT,
374 &Zeroizing::new(serde_json::to_vec(&id)?),
375 )?;
376 db.pin_enrollment(id.incarnation, &id.client, &id.role, &installation)
377 .await?;
378 id
379 }
380 };
381 ensure!(id.locator == locator, "error enrollment-context");
382 let (keys, original, original_membership, original_keys) = if id.role == "peer" {
383 ensure!(
384 self.phase(db, "peer-ready", 128).await?.is_some(),
385 "error enrollment-unresolved"
386 );
387 let (peer, verified, _, (evidence, current)) = self.verified_peer(db, &id).await?;
388 let installed = self
389 .phase(db, "peer-installed", 128)
390 .await?
391 .context("error snapshot-not-installed")?;
392 ensure!(
393 installed.as_slice() == verified.checkpoint()
394 && db
395 .peer_snapshot_receipt(&verified, id.incarnation, &id.client, &installation)
396 .await?
397 .is_some(),
398 "error snapshot-receipt-mismatch"
399 );
400 (
401 Keys::Peer(Box::new(peer)),
402 evidence,
403 current,
404 verified.keys().clone(),
405 )
406 } else {
407 ensure!(
408 db.adopted_enrollment_client().await? == id.client,
409 "error enrollment-client-mismatch"
410 );
411 let (seed, evidence, key) = self.seed_inputs(db).await?;
412 let m = evidence.verify()?;
413 let coverage = m.verify_initial_key(&key)?;
414 (Keys::Seed(Box::new(seed)), evidence, m, coverage)
415 };
416 let (membership, evidence, coverage) =
417 if let Some(floor) = self.membership_floor(db, id.incarnation).await? {
418 floor
419 } else {
420 ensure!(
421 id.role == "inviter" && self.journals(db).await?.is_empty(),
422 "error membership-floor-missing"
423 );
424 let m = self
425 .adopt_membership(
426 db,
427 id.incarnation,
428 &original,
429 original_membership.clone(),
430 &original_keys,
431 )
432 .await?;
433 (m, original, original_keys)
434 };
435 ensure!(
436 membership.extends(&original_membership),
437 "error membership-original-mismatch"
438 );
439 keys.authority().validate(&membership)?;
440 coverage.validate(&membership)?;
441 let now = self.enrollment_now()?;
442 for journal in self.journals(db).await? {
443 if let Some(ready) = self.phase(db, &journal.name("ready"), 128).await? {
444 ensure!(
445 membership.contains_head(&ready.as_slice().try_into()?),
446 "error enrollment-checkpoint-mismatch"
447 );
448 } else if !self.retired(db, &journal).await?
449 && !self.may_have_sent(db, &journal).await?
450 && now >= Declaration::from_record(&membership, &journal.declaration)?.expiry()
451 {
452 self.save_phase(db, &id, &journal.name("retired"), 128, &journal.handle)
456 .await?;
457 }
458 }
459 Ok(ActiveInputs {
460 id,
461 keys,
462 membership,
463 evidence,
464 coverage,
465 _installation: installation,
466 _lock: lock,
467 })
468 }
469 pub async fn adopt_refresh(
470 &self,
471 db: &Database,
472 inputs: &mut ActiveInputs,
473 evidence: Evidence,
474 ) -> Result<()> {
475 let target = evidence.verify()?;
476 self.adopt_verified_refresh(db, inputs, evidence, target)
477 .await
478 }
479 pub async fn adopt_verified_refresh(
481 &self,
482 db: &Database,
483 inputs: &mut ActiveInputs,
484 evidence: Evidence,
485 target: Membership,
486 ) -> Result<()> {
487 let (membership, coverage) = self
488 .refresh_membership(
489 db,
490 inputs.id.incarnation,
491 inputs.keys.authority(),
492 &evidence,
493 target,
494 super::membership::Verified {
495 membership: &inputs.membership,
496 keys: &inputs.coverage,
497 },
498 )
499 .await?;
500 inputs.membership = membership;
501 inputs.coverage = coverage;
502 inputs.evidence = evidence;
503 Ok(())
504 }
505 async fn journals(&self, db: &Database) -> Result<Vec<Outbound>> {
506 let mut journals = Vec::new();
507 let mut gap = false;
508 for index in 0..membership::MAX_INVITATIONS {
509 let Some(bytes) = self
510 .phase(db, &format!("outbound-{index}"), JOURNAL_LIMIT)
511 .await?
512 else {
513 gap = true;
514 continue;
515 };
516 ensure!(!gap, "error enrollment-journal-missing");
517 let journal: Outbound = serde_json::from_slice(&bytes)
518 .map_err(|_| anyhow::anyhow!("error enrollment-journal-corrupt"))?;
519 let invitation = Invitation::from_protected_storage(&journal.invitation)?;
520 ensure!(
521 journal.handle == invitation.handle(),
522 "error enrollment-journal-corrupt"
523 );
524 self.phase(db, &journal.name("registered"), 128).await?;
525 let bound = self.phase(db, &journal.name("bound"), 2048).await?;
526 let ready = self.phase(db, &journal.name("ready"), 128).await?;
527 let mut gap = false;
528 let mut resolved = ready.is_none();
529 for candidate in 0..membership::MAX_CANDIDATES {
530 let bytes = self
531 .phase(
532 db,
533 &journal.name(&format!("candidate-{candidate}")),
534 CANDIDATE_LIMIT,
535 )
536 .await?;
537 let sent = self
538 .phase(db, &journal.name(&format!("sent-{candidate}")), 128)
539 .await?;
540 if let Some(bytes) = bytes {
541 ensure!(!gap, "error enrollment-candidate-missing");
542 let saved: Candidate = serde_json::from_slice(&bytes)
543 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
544 let (_, before) = self.load_evidence(&saved.predecessor)?;
545 let after = before.append(
546 &journal.declaration,
547 bound.as_ref().context("error enrollment-binding-missing")?,
548 &saved.record,
549 )?;
550 if let Some(sent) = &sent {
551 ensure!(
552 sent.as_slice() == after.head(),
553 "error enrollment-sent-mismatch"
554 );
555 }
556 if ready.as_ref().is_some_and(|r| r.as_slice() == after.head()) {
557 ensure!(sent.is_some(), "error enrollment-sent-missing");
558 resolved = true;
559 }
560 } else {
561 gap = true;
562 ensure!(sent.is_none(), "error enrollment-candidate-missing");
563 }
564 }
565 ensure!(resolved, "error enrollment-candidate-missing");
566 journals.push(journal);
567 }
568 Ok(journals)
569 }
570 async fn retired(&self, db: &Database, journal: &Outbound) -> Result<bool> {
572 Ok(self
573 .phase(db, &journal.name("retired"), 128)
574 .await?
575 .is_some())
576 }
577 async fn closed(&self, db: &Database, journal: &Outbound) -> Result<bool> {
579 Ok(self.retired(db, journal).await?
580 || self
581 .phase(db, &journal.name("withdrawn"), 128)
582 .await?
583 .is_some())
584 }
585 async fn may_have_sent(&self, db: &Database, journal: &Outbound) -> Result<bool> {
586 for attempt in 0..membership::MAX_CANDIDATES {
587 if self
588 .phase(db, &journal.name(&format!("sent-{attempt}")), 128)
589 .await?
590 .is_some()
591 {
592 return Ok(true);
593 }
594 }
595 Ok(false)
596 }
597 async fn unresolved_disclosures(
600 &self,
601 db: &Database,
602 inputs: &ActiveInputs,
603 ) -> Result<Vec<(Outbound, u64)>> {
604 let mut unresolved = Vec::new();
605 for journal in self.journals(db).await? {
606 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
607 && !self.closed(db, &journal).await?
608 && self.may_have_sent(db, &journal).await?
609 {
610 let expiry =
611 Declaration::from_record(&inputs.membership, &journal.declaration)?.expiry();
612 unresolved.push((journal, expiry));
613 }
614 }
615 Ok(unresolved)
616 }
617 pub async fn open_invitation(
618 &self,
619 db: &Database,
620 inputs: &ActiveInputs,
621 ) -> Result<Option<OpenInvitation>> {
622 for journal in self.journals(db).await? {
623 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
624 && !self.closed(db, &journal).await?
625 {
626 let declaration =
627 Declaration::from_record(&inputs.membership, &journal.declaration)?;
628 return Ok(Some(OpenInvitation {
629 handle: journal.handle,
630 expires_at: declaration.expiry(),
631 keys_may_have_been_sent: self.may_have_sent(db, &journal).await?,
632 }));
633 }
634 }
635 Ok(None)
636 }
637
638 pub async fn invitation_progress(
641 &self,
642 db: &Database,
643 handle: &Hash,
644 ) -> Result<InvitationProgress> {
645 let recorded = async |name| {
646 Ok::<_, anyhow::Error>(
647 self.phase(db, &invitation_phase_name(handle, name), 128)
648 .await?
649 .is_some(),
650 )
651 };
652 if recorded("ready").await? {
653 Ok(InvitationProgress::Admitted)
654 } else if recorded("retired").await? || recorded("withdrawn").await? {
655 Ok(InvitationProgress::Closed)
656 } else {
657 Ok(InvitationProgress::Open)
658 }
659 }
660
661 pub async fn retire_open_invitation(
664 &self,
665 db: &Database,
666 inputs: &ActiveInputs,
667 ) -> Result<(Option<Outbound>, Option<OpenInvitation>)> {
668 let Some(state) = self.open_invitation(db, inputs).await? else {
669 return Ok((None, None));
670 };
671 if state.keys_may_have_been_sent {
672 return Ok((None, Some(state)));
673 }
674 let journal = self
675 .journals(db)
676 .await?
677 .into_iter()
678 .find(|journal| journal.handle == state.handle)
679 .context("error enrollment-invitation-missing")?;
680 self.save_phase(
681 db,
682 &inputs.id,
683 &journal.name("retired"),
684 128,
685 &journal.handle,
686 )
687 .await?;
688 Ok((Some(journal), Some(state)))
689 }
690
691 pub async fn prepare_invitation(
692 &self,
693 db: &Database,
694 inputs: &ActiveInputs,
695 expires: Option<u64>,
696 handle: Option<Hash>,
697 ) -> Result<Outbound> {
698 let mut journals = self.journals(db).await?;
699 if let Some(handle) = handle {
700 return journals
701 .into_iter()
702 .find(|j| j.handle == handle)
703 .context("error enrollment-invitation-missing");
704 }
705 if expires.is_none() {
706 return journals
707 .pop()
708 .context("error enrollment-invitation-missing");
709 }
710 for journal in &journals {
711 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
712 && !self.closed(db, journal).await?
713 {
714 ensure!(
716 self.phase(db, &journal.name("sent-0"), 128)
717 .await?
718 .is_none(),
719 "error withdrawal-required-unsupported"
720 );
721 return journals
722 .pop()
723 .context("error enrollment-invitation-missing");
724 }
725 }
726 ensure!(
727 journals.len() < membership::MAX_INVITATIONS,
728 "error membership-limit"
729 );
730 let (invitation, d) = inputs.keys.authority().prepare_invitation(
731 &inputs.membership,
732 expires.context("error enrollment-expiry")?,
733 )?;
734 let journal = Outbound {
735 handle: invitation.handle(),
736 invitation: invitation.protected_storage_bytes().to_vec(),
737 declaration: d.record().to_vec(),
738 };
739 self.save_phase(
740 db,
741 &inputs.id,
742 &format!("outbound-{}", journals.len()),
743 JOURNAL_LIMIT,
744 &Zeroizing::new(serde_json::to_vec(&journal)?),
745 )
746 .await?;
747 Ok(journal)
748 }
749 pub async fn registered_invitation(
750 &self,
751 db: &Database,
752 inputs: &ActiveInputs,
753 journal: &Outbound,
754 ) -> Result<Invitation> {
755 self.save_phase(
756 db,
757 &inputs.id,
758 &journal.name("registered"),
759 128,
760 &Sha256::digest(&journal.declaration),
761 )
762 .await?;
763 Invitation::from_protected_storage(&journal.invitation)
764 }
765 pub async fn prepare_admission(
766 &self,
767 db: &Database,
768 inputs: &ActiveInputs,
769 journal: &Outbound,
770 request: &[u8],
771 ) -> Result<Vec<u8>> {
772 ensure!(
773 self.phase(db, &journal.name("registered"), 128)
774 .await?
775 .is_some(),
776 "error enrollment-not-registered"
777 );
778 ensure!(
779 !self.retired(db, journal).await?,
780 "error enrollment-invitation-retired"
781 );
782 ensure!(
784 self.phase(db, &journal.name("withdrawing"), 128)
785 .await?
786 .is_none()
787 && !self.closed(db, journal).await?,
788 "error enrollment-invitation-withdrawing"
789 );
790 let d = Declaration::from_record(&inputs.membership, &journal.declaration)?;
791 let expired = self.enrollment_now()? >= d.expiry();
795 let mut index = 0;
796 for attempt in 0..membership::MAX_CANDIDATES {
797 let Some(bytes) = self
798 .phase(
799 db,
800 &journal.name(&format!("candidate-{attempt}")),
801 CANDIDATE_LIMIT,
802 )
803 .await?
804 else {
805 break;
806 };
807 self.save_phase(db, &inputs.id, &journal.name("bound"), 2048, request)
809 .await?;
810 let candidate: Candidate = serde_json::from_slice(&bytes)
811 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
812 let (_, predecessor) = self.load_evidence(&candidate.predecessor)?;
813 let result = predecessor.append(&journal.declaration, request, &candidate.record)?;
814 ensure!(
815 inputs.membership.extends(&predecessor),
816 "error enrollment-candidate-fork"
817 );
818 if inputs.membership.contains_head(&result.head())
819 || inputs.membership.head() == predecessor.head()
820 {
821 let sent = journal.name(&format!("sent-{attempt}"));
822 ensure!(
823 !expired || self.phase(db, &sent, 128).await?.is_some(),
824 "error enrollment-expired"
825 );
826 self.save_phase(
827 db,
828 &inputs.id,
829 &journal.name(&format!("sent-{attempt}")),
830 128,
831 &result.head(),
832 )
833 .await?;
834 return Ok(candidate.record);
835 }
836 ensure!(
838 inputs
839 .membership
840 .head_at(result.sequence())
841 .is_some_and(|head| head != result.head()),
842 "error enrollment-candidate-unresolved"
843 );
844 index = attempt + 1;
845 }
846 ensure!(
847 index < membership::MAX_CANDIDATES,
848 "error membership-candidate-limit"
849 );
850 ensure!(!expired, "error enrollment-expired");
851 let inv = Invitation::from_protected_storage(&journal.invitation)?;
852 let record = inputs.keys.authority().prepare_admission(
853 &inputs.membership,
854 &d,
855 &inv,
856 request,
857 inputs.generation_keys(),
858 )?;
859 self.save_phase(db, &inputs.id, &journal.name("bound"), 2048, request)
863 .await?;
864 let candidate = Candidate {
865 predecessor: self.save_evidence(&inputs.evidence)?,
866 record: record.clone(),
867 };
868 self.save_phase(
869 db,
870 &inputs.id,
871 &journal.name(&format!("candidate-{index}")),
872 CANDIDATE_LIMIT,
873 &serde_json::to_vec(&candidate)?,
874 )
875 .await?;
876 self.save_phase(
877 db,
878 &inputs.id,
879 &journal.name(&format!("sent-{index}")),
880 128,
881 &Sha256::digest(&record),
882 )
883 .await?;
884 Ok(record)
885 }
886 pub async fn expired_disclosure(
889 &self,
890 db: &Database,
891 inputs: &ActiveInputs,
892 ) -> Result<Option<Outbound>> {
893 let now = self.enrollment_now()?;
894 Ok(self
895 .unresolved_disclosures(db, inputs)
896 .await?
897 .into_iter()
898 .find(|(_, expiry)| now >= *expiry)
899 .map(|(journal, _)| journal))
900 }
901 pub async fn mark_withdrawing(
903 &self,
904 db: &Database,
905 inputs: &ActiveInputs,
906 journal: &Outbound,
907 ) -> Result<()> {
908 self.save_phase(
909 db,
910 &inputs.id,
911 &journal.name("withdrawing"),
912 128,
913 &journal.handle,
914 )
915 .await
916 }
917 pub async fn reconcile_disclosure(
923 &self,
924 db: &Database,
925 inputs: &ActiveInputs,
926 journal: &Outbound,
927 ) -> Result<Disclosure> {
928 let bound = self
929 .phase(db, &journal.name("bound"), 2048)
930 .await?
931 .context("error enrollment-binding-missing")?;
932 let mut candidates = Vec::new();
933 for attempt in 0..membership::MAX_CANDIDATES {
934 let Some(bytes) = self
935 .phase(
936 db,
937 &journal.name(&format!("candidate-{attempt}")),
938 CANDIDATE_LIMIT,
939 )
940 .await?
941 else {
942 break;
943 };
944 let saved: Candidate = serde_json::from_slice(&bytes)
945 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
946 let (_, before) = self.load_evidence(&saved.predecessor)?;
947 ensure!(
948 inputs.membership.extends(&before),
949 "error enrollment-candidate-fork"
950 );
951 let after = before.append(&journal.declaration, &bound, &saved.record)?;
952 match inputs.membership.head_at(after.sequence()) {
953 Some(head) if head == after.head() => {
954 self.finish_inviter(db, inputs, journal, &saved.record)
955 .await?;
956 return Ok(Disclosure::Admitted);
957 }
958 Some(_) => {}
959 None => return Ok(Disclosure::Unresolved),
960 }
961 let recipient = after
962 .devices()
963 .find(|device| !before.has_device(*device))
964 .context("error enrollment-candidate-recipient")?;
965 candidates.push((before, after.sequence(), recipient));
966 }
967 let disclosed = candidates
968 .iter()
969 .flat_map(|(before, _, _)| before.generations().iter().map(|g| g.id))
970 .collect::<std::collections::HashSet<_>>();
971 let last_slot = candidates.iter().map(|(_, slot, _)| *slot).max();
972 let mut state = candidates
973 .iter()
974 .map(|(before, _, _)| before)
975 .min_by_key(|before| before.sequence())
976 .context("error enrollment-candidate-missing")?
977 .clone();
978 let mut qualifying = None;
979 for t in inputs
980 .evidence
981 .transitions
982 .iter()
983 .skip(state.sequence() as usize - 1)
984 {
985 state = state.append(&t.declaration, &t.request, &t.record)?;
986 ensure!(
987 candidates
988 .iter()
989 .all(|(_, _, recipient)| !state.has_device(*recipient)),
990 "error withdrawal-recipient-member"
991 );
992 if qualifying.is_none()
993 && Some(state.sequence()) > last_slot
994 && !state.rotation_pending()
995 && !disclosed.contains(&state.current_generation().id)
996 {
997 qualifying = Some(state.head());
998 }
999 }
1000 ensure!(
1001 state.head() == inputs.membership.head(),
1002 "error enrollment-checkpoint-mismatch"
1003 );
1004 let Some(head) = qualifying else {
1005 return Ok(Disclosure::Unresolved);
1006 };
1007 self.save_phase(db, &inputs.id, &journal.name("withdrawn"), 128, &head)
1008 .await?;
1009 Ok(Disclosure::Withdrawn)
1010 }
1011 pub async fn finish_inviter(
1012 &self,
1013 db: &Database,
1014 inputs: &ActiveInputs,
1015 journal: &Outbound,
1016 record: &[u8],
1017 ) -> Result<()> {
1018 let head: Hash = Sha256::digest(record).into();
1019 ensure!(
1020 inputs.membership.contains_head(&head),
1021 "error enrollment-outcome-missing"
1022 );
1023 let mut matched = false;
1024 for attempt in 0..membership::MAX_CANDIDATES {
1025 let Some(bytes) = self
1026 .phase(
1027 db,
1028 &journal.name(&format!("candidate-{attempt}")),
1029 CANDIDATE_LIMIT,
1030 )
1031 .await?
1032 else {
1033 break;
1034 };
1035 let candidate: Candidate = serde_json::from_slice(&bytes)
1036 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
1037 if candidate.record == record {
1038 let sent = self
1039 .phase(db, &journal.name(&format!("sent-{attempt}")), 128)
1040 .await?
1041 .context("error enrollment-sent-missing")?;
1042 ensure!(sent.as_slice() == head, "error enrollment-sent-mismatch");
1043 matched = true;
1044 }
1045 }
1046 ensure!(matched, "error enrollment-outcome-mismatch");
1047 self.save_phase(db, &inputs.id, &journal.name("ready"), 128, &head)
1048 .await
1049 }
1050 pub async fn prepare_peer(
1053 &self,
1054 db: &Database,
1055 locator: &str,
1056 invitation: Option<Invitation>,
1057 ) -> Result<Joiner> {
1058 self.select_peer(db, locator, invitation, false).await
1059 }
1060 pub async fn replace_peer(
1064 &self,
1065 db: &Database,
1066 locator: &str,
1067 invitation: Invitation,
1068 ) -> Result<Joiner> {
1069 self.select_peer(db, locator, Some(invitation), true).await
1070 }
1071 async fn select_peer(
1072 &self,
1073 db: &Database,
1074 locator: &str,
1075 invitation: Option<Invitation>,
1076 replace: bool,
1077 ) -> Result<Joiner> {
1078 let guard = InstallationGuard::acquire(db.path())?;
1079 self.validate_database(db).await?;
1080 ensure!(locator.len() <= 2048, "error enrollment-locator-limit");
1081 self.prepare()?;
1082 let _lock = self.lock()?;
1083 let id = match self.identity(db, &guard).await? {
1084 Some(id) => id,
1085 None => {
1086 let client = db.peer_target_preflight().await?;
1087 guard.ensure_unbound()?;
1088 ensure!(
1089 self.load_secret(KEYRING_ITEM, KEYRING_BYTES)?.is_none()
1090 && self.read_marker()?.is_none()
1091 && !self.seed_authority_exists()?,
1092 "error enrollment-existing-authority"
1093 );
1094 let peer = Joiner::generate(
1095 invitation
1096 .as_ref()
1097 .map(|i| Invitation::from_protected_storage(&i.protected_storage_bytes()))
1098 .transpose()?
1099 .context("error enrollment-invitation-required")?,
1100 )?;
1101 guard.fence()?;
1102 let id = Identity {
1103 role: "peer".into(),
1104 client,
1105 incarnation: *Secret::generate()?.expose(),
1106 locator: locator.into(),
1107 authority: peer.protected_storage_bytes().to_vec(),
1108 };
1109 self.write_owned(
1110 "peer-identity",
1111 IDENTITY_LIMIT,
1112 &Zeroizing::new(serde_json::to_vec(&id)?),
1113 )?;
1114 db.pin_enrollment(id.incarnation, &id.client, &id.role, &guard)
1115 .await?;
1116 id
1117 }
1118 };
1119 ensure!(
1120 id.role == "peer" && id.locator == locator,
1121 "error enrollment-context"
1122 );
1123 let mut attempts = self.attempts(db, &id).await?;
1124 let known = invitation
1125 .as_ref()
1126 .map(|i| attempts.iter().position(|peer| peer.matches_invitation(i)));
1127 Ok(match (known, self.response(db).await?) {
1128 (Some(None), _) => {
1129 ensure!(replace, "error enrollment-invitation-conflict");
1130 self.ensure_retry_allowed(db, &id, &guard, attempts.len())
1131 .await?;
1132 let peer = attempts[0].retry(invitation.expect("unknown invitation"))?;
1133 self.save_phase(
1134 db,
1135 &id,
1136 &format!("peer-attempt-{}", attempts.len()),
1137 ATTEMPT_LIMIT,
1138 &peer.attempt_bytes(),
1139 )
1140 .await?;
1141 peer
1142 }
1143 (_, Some(mail)) => responding(attempts, &mail)?,
1144 (Some(Some(index)), None) => attempts.swap_remove(index),
1145 (None, None) => attempts.pop().expect("original attempt"),
1146 })
1147 }
1148 pub async fn peer_attempts(&self, db: &Database, locator: &str) -> Result<Vec<Joiner>> {
1151 let guard = InstallationGuard::acquire(db.path())?;
1152 self.validate_database(db).await?;
1153 let _lock = self.lock()?;
1154 let id = self
1155 .identity(db, &guard)
1156 .await?
1157 .context("error enrollment-missing")?;
1158 ensure!(
1159 id.role == "peer" && id.locator == locator,
1160 "error enrollment-context"
1161 );
1162 let attempts = self.attempts(db, &id).await?;
1163 Ok(match self.response(db).await? {
1164 Some(mail) => vec![responding(attempts, &mail)?],
1165 None => attempts,
1166 })
1167 }
1168 async fn attempts(&self, db: &Database, id: &Identity) -> Result<Vec<Joiner>> {
1173 let original = Joiner::from_protected_storage(&id.authority)?;
1174 let sent = Sha256::digest(original.request());
1175 if let Some(saved) = self.phase(db, "peer-sent", 128).await? {
1176 ensure!(
1177 saved.as_slice() == sent.as_slice(),
1178 "error enrollment-sent-mismatch"
1179 );
1180 }
1181 self.save_phase(db, id, "peer-sent", 128, &sent).await?;
1182 let mut attempts = Vec::new();
1183 let mut gap = false;
1184 for index in 1..MAX_JOIN_ATTEMPTS {
1185 let name = format!("peer-attempt-{index}");
1186 let Some(bytes) = self.phase(db, &name, ATTEMPT_LIMIT).await? else {
1187 gap = true;
1188 continue;
1189 };
1190 ensure!(!gap, "error enrollment-attempt-missing");
1191 if db.enrollment_artifact(&name).await?.is_none() {
1192 self.save_phase(db, id, &name, ATTEMPT_LIMIT, &bytes)
1193 .await?;
1194 }
1195 attempts.push(original.attempt(&bytes)?);
1196 }
1197 attempts.insert(0, original);
1198 Ok(attempts)
1199 }
1200 async fn ensure_retry_allowed(
1204 &self,
1205 db: &Database,
1206 id: &Identity,
1207 guard: &InstallationGuard,
1208 attempts: usize,
1209 ) -> Result<()> {
1210 for (kind, size) in [
1211 ("peer-response", RESPONSE_LIMIT),
1212 ("peer-verified", 2048),
1213 ("peer-ready", 128),
1214 ("peer-installed", 128),
1215 ] {
1216 ensure!(
1217 self.phase(db, kind, size).await?.is_none(),
1218 "error enrollment-retry-unavailable"
1219 );
1220 }
1221 ensure!(
1222 self.membership_floor(db, id.incarnation).await?.is_none()
1223 && db.membership_checkpoint_mirror().await?.is_none()
1224 && self.journals(db).await?.is_empty(),
1225 "error enrollment-retry-unavailable"
1226 );
1227 db.peer_retry_preflight(id.incarnation, &id.client, guard)
1228 .await?;
1229 ensure!(attempts < MAX_JOIN_ATTEMPTS, "error enrollment-retry-limit");
1230 Ok(())
1231 }
1232 async fn response(&self, db: &Database) -> Result<Option<membership::Mailbox>> {
1233 self.phase(db, "peer-response", RESPONSE_LIMIT)
1234 .await?
1235 .map(|bytes| {
1236 serde_json::from_slice(&bytes)
1237 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))
1238 })
1239 .transpose()
1240 }
1241 pub async fn open_peer_response(
1244 &self,
1245 db: &Database,
1246 mail: &membership::Mailbox,
1247 ) -> Result<membership::ProvisionalGrant> {
1248 let guard = InstallationGuard::acquire(db.path())?;
1249 self.validate_database(db).await?;
1250 let _lock = self.lock()?;
1251 let id = self
1252 .identity(db, &guard)
1253 .await?
1254 .context("error enrollment-missing")?;
1255 ensure!(id.role == "peer", "error enrollment-role");
1256 responding(self.attempts(db, &id).await?, mail)?.open_provisional(
1257 &mail.declaration,
1258 mail.admission
1259 .as_deref()
1260 .context("error enrollment-outcome-missing")?,
1261 )
1262 }
1263 pub async fn finish_peer(
1266 &self,
1267 db: &Database,
1268 mail: &membership::Mailbox,
1269 evidence: &Evidence,
1270 ) -> Result<()> {
1271 let guard = InstallationGuard::acquire(db.path())?;
1272 self.validate_database(db).await?;
1273 let _lock = self.lock()?;
1274 let id = self
1275 .identity(db, &guard)
1276 .await?
1277 .context("error enrollment-missing")?;
1278 ensure!(id.role == "peer", "error enrollment-role");
1279 let peer = responding(self.attempts(db, &id).await?, mail)?;
1280 let grant = peer.open_provisional(
1281 &mail.declaration,
1282 mail.admission
1283 .as_deref()
1284 .context("error enrollment-outcome-missing")?,
1285 )?;
1286 let (verified, current) = evidence.enrollment(&peer, grant.outcome)?;
1287 self.save_phase(
1288 db,
1289 &id,
1290 "peer-response",
1291 RESPONSE_LIMIT,
1292 &serde_json::to_vec(mail)?,
1293 )
1294 .await?;
1295 let mut original = evidence.clone();
1297 original
1298 .transitions
1299 .truncate(verified.membership().sequence() as usize - 1);
1300 let record = Verified {
1301 evidence: self.save_evidence(&original)?,
1302 outcome: grant.outcome,
1303 key: verified.key().protected_storage_bytes().to_vec(),
1304 };
1305 self.save_phase(
1306 db,
1307 &id,
1308 "peer-verified",
1309 2048,
1310 &Zeroizing::new(serde_json::to_vec(&record)?),
1311 )
1312 .await?;
1313 if let Some((floor, _, _)) = self.membership_floor(db, id.incarnation).await?
1314 && floor.extends(¤t)
1315 {
1316 peer.authority().validate(&floor)?;
1317 self.save_phase(db, &id, "peer-ready", 128, &record.outcome)
1318 .await?;
1319 return Ok(());
1320 }
1321 self.refresh_membership(
1322 db,
1323 id.incarnation,
1324 peer.authority(),
1325 evidence,
1326 current,
1327 super::membership::Verified {
1328 membership: verified.membership(),
1329 keys: verified.keys(),
1330 },
1331 )
1332 .await?;
1333 self.save_phase(db, &id, "peer-ready", 128, &record.outcome)
1334 .await
1335 }
1336 async fn verified_peer(
1337 &self,
1338 db: &Database,
1339 id: &Identity,
1340 ) -> Result<(Joiner, VerifiedEnrollment, Verified, (Evidence, Membership))> {
1341 let ready = self
1342 .phase(db, "peer-ready", 128)
1343 .await?
1344 .context("error enrollment-not-ready")?;
1345 let bytes = self
1346 .phase(db, "peer-verified", 2048)
1347 .await?
1348 .context("error enrollment-key-coverage-missing")?;
1349 let record: Verified = serde_json::from_slice(&bytes)
1350 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
1351 let mail = self
1352 .response(db)
1353 .await?
1354 .context("error enrollment-response-missing")?;
1355 let peer = responding(self.attempts(db, id).await?, &mail)?;
1356 let evidence = Evidence::decode_unverified(&self.load_evidence_bytes(&record.evidence)?)?;
1358 let (verified, current) = evidence.enrollment(&peer, record.outcome)?;
1359 ensure!(
1360 ready.as_slice() == record.outcome
1361 && verified.key().protected_storage_bytes().as_slice() == record.key,
1362 "error enrollment-verified-corrupt"
1363 );
1364 Ok((peer, verified, record, (evidence, current)))
1365 }
1366 pub async fn install_peer_snapshot(
1367 &self,
1368 db: &Database,
1369 transport: &crate::sync::client::enrollment::Client,
1370 locator: &str,
1371 downloaded: &(dyn Fn(u64, Option<u64>) + Sync),
1372 ) -> Result<crate::sync::SharedStateInstallReport> {
1373 let guard = InstallationGuard::acquire(db.path())?;
1374 self.validate_database(db).await?;
1375 let _lock = self.lock()?;
1376 let id = self
1377 .identity(db, &guard)
1378 .await?
1379 .context("error enrollment-missing")?;
1380 ensure!(
1381 id.role == "peer" && id.locator == locator,
1382 "error enrollment-context"
1383 );
1384 let (peer, verified, _, (evidence, _)) = self.verified_peer(db, &id).await?;
1385 let completed = self.phase(db, "peer-installed", 128).await?;
1386 if let Some(report) = db
1387 .peer_snapshot_receipt(&verified, id.incarnation, &id.client, &guard)
1388 .await?
1389 {
1390 let (floor, _, _) = self
1391 .membership_floor(db, id.incarnation)
1392 .await?
1393 .context("error membership-floor-missing")?;
1394 peer.authority().validate(&floor)?;
1395 self.save_phase(db, &id, "peer-installed", 128, &verified.checkpoint())
1396 .await?;
1397 return Ok(report);
1398 }
1399 ensure!(
1400 completed.is_none(),
1401 "error snapshot-installed-database-lost"
1402 );
1403 let (floor, _, _) = self
1404 .membership_floor(db, id.incarnation)
1405 .await?
1406 .context("error membership-floor-missing")?;
1407 peer.authority().validate(&floor)?;
1408 let package = transport
1409 .download(
1410 self,
1411 db,
1412 id.incarnation,
1413 &peer,
1414 &verified,
1415 &evidence.descriptor,
1416 downloaded,
1417 )
1418 .await?;
1419 let report = db
1420 .install_peer_snapshot(&verified, id.incarnation, &id.client, &guard, &package)
1421 .await?;
1422 self.save_phase(db, &id, "peer-installed", 128, &verified.checkpoint())
1423 .await?;
1424 Ok(report)
1425 }
1426 pub async fn adopt_download_refresh(
1427 &self,
1428 db: &Database,
1429 identity: Hash,
1430 peer: &Joiner,
1431 verified: &VerifiedEnrollment,
1432 evidence: &Evidence,
1433 target: Membership,
1434 ) -> Result<Membership> {
1435 Ok(self
1436 .refresh_membership(
1437 db,
1438 identity,
1439 peer.authority(),
1440 evidence,
1441 target,
1442 super::membership::Verified {
1443 membership: verified.membership(),
1444 keys: verified.keys(),
1445 },
1446 )
1447 .await?
1448 .0)
1449 }
1450 pub async fn tail_inputs(&self, db: &Database, locator: &str) -> Result<TailSnapshot> {
1453 let inputs = self.active_inputs(db, locator).await?;
1454 let withdrawal_deadline = self
1455 .unresolved_disclosures(db, &inputs)
1456 .await?
1457 .into_iter()
1458 .map(|(_, expiry)| expiry)
1459 .min();
1460 let b = inputs.membership.publication().binding();
1461 let association = format!(
1462 "{}:{}:{}",
1463 hex::encode(b.vault_id),
1464 hex::encode(b.stream_id),
1465 hex::encode(b.bootstrap_id)
1466 );
1467 ensure!(
1468 db.meta("e2ee_association").await?.as_deref() == Some(association.as_str()),
1469 "error encrypted-tail-association"
1470 );
1471 let authority = crate::sync::encrypted_tail::Authority {
1472 context: crate::sync::encrypted_tail::Context {
1473 vault: b.vault_id,
1474 genesis: inputs.membership.genesis().commitment(),
1475 device: inputs.device(),
1476 head: inputs.membership.head(),
1477 stream: b.stream_id,
1478 descriptor: b.descriptor_commitment,
1479 },
1480 membership: inputs.membership.clone(),
1481 keys: inputs.coverage.clone(),
1482 prefix: i64::try_from(b.prefix_count)?,
1483 association,
1484 sync_generation: db
1485 .meta("sync_generation")
1486 .await?
1487 .context("error encrypted-tail-generation")?
1488 .parse()?,
1489 };
1490 let snapshot = TailSnapshot {
1491 authority,
1492 bearer: Secret::new(*inputs.bearer().expose()),
1493 withdrawal_deadline,
1494 enrollment_marker: db.enrollment_artifact_marker().await?,
1495 #[cfg(any(test, feature = "test-support"))]
1496 enrollment_clock: self.enrollment_clock.clone(),
1497 };
1498 drop(inputs);
1499 Ok(snapshot)
1500 }
1501 pub async fn association(&self, db: &Database) -> Result<Option<(bool, String)>> {
1504 let guard = InstallationGuard::acquire(db.path())?;
1505 self.validate_database(db).await?;
1506 self.prepare()?;
1507 let _lock = self.lock()?;
1508 Ok(self
1509 .identity(db, &guard)
1510 .await?
1511 .map(|id| (id.role == "peer", id.locator.clone())))
1512 }
1513 #[cfg(any(test, feature = "test-support"))]
1514 pub async fn outbound_invitation(&self, db: &Database) -> Result<Option<OutboundInvitation>> {
1515 let _guard = InstallationGuard::acquire(db.path())?;
1516 self.validate_database(db).await?;
1517 self.prepare()?;
1518 let _lock = self.lock()?;
1519 for journal in self.journals(db).await? {
1520 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
1521 && !self.closed(db, &journal).await?
1522 {
1523 return Ok(Some(if self.may_have_sent(db, &journal).await? {
1524 OutboundInvitation::Disclosed
1525 } else {
1526 OutboundInvitation::Pending
1527 }));
1528 }
1529 }
1530 Ok(None)
1531 }
1532 pub async fn enrollment_readiness(&self, db: &Database) -> Result<EnrollmentReadiness> {
1533 let guard = InstallationGuard::acquire(db.path())?;
1534 self.validate_database(db).await?;
1535 self.prepare()?;
1536 let _lock = self.lock()?;
1537 let Some(id) = self.identity(db, &guard).await? else {
1538 return Ok(EnrollmentReadiness::NotSelected);
1539 };
1540 if id.role == "peer" && self.phase(db, "peer-ready", 128).await?.is_none() {
1541 return Ok(EnrollmentReadiness::Pending);
1542 }
1543 let Some((m, _, _)) = self.membership_floor(db, id.incarnation).await? else {
1544 return Ok(EnrollmentReadiness::Pending);
1545 };
1546 if id.role == "peer" {
1547 self.verified_peer(db, &id)
1548 .await?
1549 .0
1550 .authority()
1551 .validate(&m)?;
1552 } else {
1553 Device::seed(&self.seed_inputs(db).await?.0).validate(&m)?;
1554 }
1555 Ok(EnrollmentReadiness::Enrolled { head: m.head() })
1556 }
1557}
1558fn responding(attempts: Vec<Joiner>, mail: &membership::Mailbox) -> Result<Joiner> {
1561 attempts
1562 .into_iter()
1563 .find(|peer| mail.request.as_deref() == Some(peer.request()))
1564 .context("error enrollment-request-mismatch")
1565}
1566impl Outbound {
1567 fn name(&self, phase: &str) -> String {
1568 invitation_phase_name(&self.handle, phase)
1569 }
1570}
1571fn invitation_phase_name(handle: &Hash, phase: &str) -> String {
1572 format!("invite-{}-{phase}", hex::encode(handle))
1573}
1574#[derive(Debug, PartialEq, Eq)]
1575pub enum Disclosure {
1576 Admitted,
1577 Withdrawn,
1578 Unresolved,
1579}
1580pub struct TailSnapshot {
1581 pub authority: crate::sync::encrypted_tail::Authority,
1582 pub bearer: Secret,
1583 withdrawal_deadline: Option<u64>,
1586 enrollment_marker: i64,
1587 #[cfg(any(test, feature = "test-support"))]
1588 enrollment_clock: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
1589}
1590impl TailSnapshot {
1591 pub fn require_publishing_ready(&self) -> Result<()> {
1593 if self.publishing_blocked()? {
1594 return Err(PublishingBlocked.into());
1595 }
1596 Ok(())
1597 }
1598
1599 pub fn publishing_blocked(&self) -> Result<bool> {
1600 #[cfg(any(test, feature = "test-support"))]
1601 let now = enrollment_now(self.enrollment_clock.as_ref())?;
1602 #[cfg(not(any(test, feature = "test-support")))]
1603 let now = enrollment_now()?;
1604 Ok(self
1605 .withdrawal_deadline
1606 .is_some_and(|deadline| now >= deadline))
1607 }
1608
1609 pub async fn is_current(&self, db: &Database) -> Result<bool> {
1610 Ok(self.enrollment_marker == db.enrollment_artifact_marker().await?)
1611 }
1612}
1613
1614impl ProtectedLocalKeyStore {
1615 fn enrollment_now(&self) -> Result<u64> {
1616 #[cfg(any(test, feature = "test-support"))]
1617 return enrollment_now(self.enrollment_clock.as_ref());
1618 #[cfg(not(any(test, feature = "test-support")))]
1619 enrollment_now()
1620 }
1621}
1622
1623#[cfg(any(test, feature = "test-support"))]
1625fn enrollment_now(clock: Option<&std::sync::Arc<std::sync::atomic::AtomicU64>>) -> Result<u64> {
1626 match clock {
1627 Some(clock) => Ok(clock.load(Ordering::SeqCst)),
1628 None => crate::sync::client::engine::unix_now(),
1629 }
1630}
1631
1632#[cfg(not(any(test, feature = "test-support")))]
1633fn enrollment_now() -> Result<u64> {
1634 crate::sync::client::engine::unix_now()
1635}