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> {
349 let installation = InstallationGuard::acquire(db.path())?;
350 self.validate_database(db).await?;
351 ensure!(locator.len() <= 2048, "error enrollment-locator-limit");
352 self.prepare()?;
353 let lock = self.lock()?;
354 let id = match self.identity(db, &installation).await? {
355 Some(id) => id,
356 None => {
357 let client = db.adopted_enrollment_client().await?;
358 self.seed_inputs(db).await?;
359 let id = Identity {
360 role: "inviter".into(),
361 client,
362 incarnation: *Secret::generate()?.expose(),
363 locator: locator.into(),
364 authority: vec![],
365 };
366 self.write_owned(
367 "peer-identity",
368 IDENTITY_LIMIT,
369 &Zeroizing::new(serde_json::to_vec(&id)?),
370 )?;
371 db.pin_enrollment(id.incarnation, &id.client, &id.role, &installation)
372 .await?;
373 id
374 }
375 };
376 ensure!(id.locator == locator, "error enrollment-context");
377 let (keys, original, original_membership, original_keys) = if id.role == "peer" {
378 ensure!(
379 self.phase(db, "peer-ready", 128).await?.is_some(),
380 "error enrollment-unresolved"
381 );
382 let (peer, verified, _, (evidence, current)) = self.verified_peer(db, &id).await?;
383 let installed = self
384 .phase(db, "peer-installed", 128)
385 .await?
386 .context("error snapshot-not-installed")?;
387 ensure!(
388 installed.as_slice() == verified.checkpoint()
389 && db
390 .peer_snapshot_receipt(&verified, id.incarnation, &id.client, &installation)
391 .await?
392 .is_some(),
393 "error snapshot-receipt-mismatch"
394 );
395 (
396 Keys::Peer(Box::new(peer)),
397 evidence,
398 current,
399 verified.keys().clone(),
400 )
401 } else {
402 ensure!(
403 db.adopted_enrollment_client().await? == id.client,
404 "error enrollment-client-mismatch"
405 );
406 let (seed, evidence, key) = self.seed_inputs(db).await?;
407 let m = evidence.verify()?;
408 let coverage = m.verify_initial_key(&key)?;
409 (Keys::Seed(Box::new(seed)), evidence, m, coverage)
410 };
411 let (membership, evidence, coverage) =
412 if let Some(floor) = self.membership_floor(db, id.incarnation).await? {
413 floor
414 } else {
415 ensure!(
416 id.role == "inviter" && self.journals(db).await?.is_empty(),
417 "error membership-floor-missing"
418 );
419 let m = self
420 .adopt_membership(
421 db,
422 id.incarnation,
423 &original,
424 original_membership.clone(),
425 &original_keys,
426 )
427 .await?;
428 (m, original, original_keys)
429 };
430 ensure!(
431 membership.extends(&original_membership),
432 "error membership-original-mismatch"
433 );
434 keys.authority().validate(&membership)?;
435 coverage.validate(&membership)?;
436 let now = self.enrollment_now()?;
437 for journal in self.journals(db).await? {
438 if let Some(ready) = self.phase(db, &journal.name("ready"), 128).await? {
439 ensure!(
440 membership.contains_head(&ready.as_slice().try_into()?),
441 "error enrollment-checkpoint-mismatch"
442 );
443 } else if !self.retired(db, &journal).await?
444 && !self.may_have_sent(db, &journal).await?
445 && now >= Declaration::from_record(&membership, &journal.declaration)?.expiry()
446 {
447 self.save_phase(db, &id, &journal.name("retired"), 128, &journal.handle)
451 .await?;
452 }
453 }
454 Ok(ActiveInputs {
455 id,
456 keys,
457 membership,
458 evidence,
459 coverage,
460 _installation: installation,
461 _lock: lock,
462 })
463 }
464 pub async fn adopt_refresh(
465 &self,
466 db: &Database,
467 inputs: &mut ActiveInputs,
468 evidence: Evidence,
469 ) -> Result<()> {
470 let target = evidence.verify()?;
471 self.adopt_verified_refresh(db, inputs, evidence, target)
472 .await
473 }
474 pub async fn adopt_verified_refresh(
476 &self,
477 db: &Database,
478 inputs: &mut ActiveInputs,
479 evidence: Evidence,
480 target: Membership,
481 ) -> Result<()> {
482 let (membership, coverage) = self
483 .refresh_membership(
484 db,
485 inputs.id.incarnation,
486 inputs.keys.authority(),
487 &evidence,
488 target,
489 super::membership::Verified {
490 membership: &inputs.membership,
491 keys: &inputs.coverage,
492 },
493 )
494 .await?;
495 inputs.membership = membership;
496 inputs.coverage = coverage;
497 inputs.evidence = evidence;
498 Ok(())
499 }
500 async fn journals(&self, db: &Database) -> Result<Vec<Outbound>> {
501 let mut journals = Vec::new();
502 let mut gap = false;
503 for index in 0..membership::MAX_INVITATIONS {
504 let Some(bytes) = self
505 .phase(db, &format!("outbound-{index}"), JOURNAL_LIMIT)
506 .await?
507 else {
508 gap = true;
509 continue;
510 };
511 ensure!(!gap, "error enrollment-journal-missing");
512 let journal: Outbound = serde_json::from_slice(&bytes)
513 .map_err(|_| anyhow::anyhow!("error enrollment-journal-corrupt"))?;
514 let invitation = Invitation::from_protected_storage(&journal.invitation)?;
515 ensure!(
516 journal.handle == invitation.handle(),
517 "error enrollment-journal-corrupt"
518 );
519 self.phase(db, &journal.name("registered"), 128).await?;
520 let bound = self.phase(db, &journal.name("bound"), 2048).await?;
521 let ready = self.phase(db, &journal.name("ready"), 128).await?;
522 let mut gap = false;
523 let mut resolved = ready.is_none();
524 for candidate in 0..membership::MAX_CANDIDATES {
525 let bytes = self
526 .phase(
527 db,
528 &journal.name(&format!("candidate-{candidate}")),
529 CANDIDATE_LIMIT,
530 )
531 .await?;
532 let sent = self
533 .phase(db, &journal.name(&format!("sent-{candidate}")), 128)
534 .await?;
535 if let Some(bytes) = bytes {
536 ensure!(!gap, "error enrollment-candidate-missing");
537 let saved: Candidate = serde_json::from_slice(&bytes)
538 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
539 let (_, before) = self.load_evidence(&saved.predecessor)?;
540 let after = before.append(
541 &journal.declaration,
542 bound.as_ref().context("error enrollment-binding-missing")?,
543 &saved.record,
544 )?;
545 if let Some(sent) = &sent {
546 ensure!(
547 sent.as_slice() == after.head(),
548 "error enrollment-sent-mismatch"
549 );
550 }
551 if ready.as_ref().is_some_and(|r| r.as_slice() == after.head()) {
552 ensure!(sent.is_some(), "error enrollment-sent-missing");
553 resolved = true;
554 }
555 } else {
556 gap = true;
557 ensure!(sent.is_none(), "error enrollment-candidate-missing");
558 }
559 }
560 ensure!(resolved, "error enrollment-candidate-missing");
561 journals.push(journal);
562 }
563 Ok(journals)
564 }
565 async fn retired(&self, db: &Database, journal: &Outbound) -> Result<bool> {
567 Ok(self
568 .phase(db, &journal.name("retired"), 128)
569 .await?
570 .is_some())
571 }
572 async fn closed(&self, db: &Database, journal: &Outbound) -> Result<bool> {
574 Ok(self.retired(db, journal).await?
575 || self
576 .phase(db, &journal.name("withdrawn"), 128)
577 .await?
578 .is_some())
579 }
580 async fn may_have_sent(&self, db: &Database, journal: &Outbound) -> Result<bool> {
581 for attempt in 0..membership::MAX_CANDIDATES {
582 if self
583 .phase(db, &journal.name(&format!("sent-{attempt}")), 128)
584 .await?
585 .is_some()
586 {
587 return Ok(true);
588 }
589 }
590 Ok(false)
591 }
592 async fn unresolved_disclosures(
595 &self,
596 db: &Database,
597 inputs: &ActiveInputs,
598 ) -> Result<Vec<(Outbound, u64)>> {
599 let mut unresolved = Vec::new();
600 for journal in self.journals(db).await? {
601 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
602 && !self.closed(db, &journal).await?
603 && self.may_have_sent(db, &journal).await?
604 {
605 let expiry =
606 Declaration::from_record(&inputs.membership, &journal.declaration)?.expiry();
607 unresolved.push((journal, expiry));
608 }
609 }
610 Ok(unresolved)
611 }
612 pub async fn open_invitation(
613 &self,
614 db: &Database,
615 inputs: &ActiveInputs,
616 ) -> Result<Option<OpenInvitation>> {
617 for journal in self.journals(db).await? {
618 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
619 && !self.closed(db, &journal).await?
620 {
621 let declaration =
622 Declaration::from_record(&inputs.membership, &journal.declaration)?;
623 return Ok(Some(OpenInvitation {
624 handle: journal.handle,
625 expires_at: declaration.expiry(),
626 keys_may_have_been_sent: self.may_have_sent(db, &journal).await?,
627 }));
628 }
629 }
630 Ok(None)
631 }
632
633 pub async fn invitation_progress(
636 &self,
637 db: &Database,
638 handle: &Hash,
639 ) -> Result<InvitationProgress> {
640 let recorded = async |name| {
641 Ok::<_, anyhow::Error>(
642 self.phase(db, &invitation_phase_name(handle, name), 128)
643 .await?
644 .is_some(),
645 )
646 };
647 if recorded("ready").await? {
648 Ok(InvitationProgress::Admitted)
649 } else if recorded("retired").await? || recorded("withdrawn").await? {
650 Ok(InvitationProgress::Closed)
651 } else {
652 Ok(InvitationProgress::Open)
653 }
654 }
655
656 pub async fn retire_open_invitation(
659 &self,
660 db: &Database,
661 inputs: &ActiveInputs,
662 ) -> Result<(Option<Outbound>, Option<OpenInvitation>)> {
663 let Some(state) = self.open_invitation(db, inputs).await? else {
664 return Ok((None, None));
665 };
666 if state.keys_may_have_been_sent {
667 return Ok((None, Some(state)));
668 }
669 let journal = self
670 .journals(db)
671 .await?
672 .into_iter()
673 .find(|journal| journal.handle == state.handle)
674 .context("error enrollment-invitation-missing")?;
675 self.save_phase(
676 db,
677 &inputs.id,
678 &journal.name("retired"),
679 128,
680 &journal.handle,
681 )
682 .await?;
683 Ok((Some(journal), Some(state)))
684 }
685
686 pub async fn prepare_invitation(
687 &self,
688 db: &Database,
689 inputs: &ActiveInputs,
690 expires: Option<u64>,
691 handle: Option<Hash>,
692 ) -> Result<Outbound> {
693 let mut journals = self.journals(db).await?;
694 if let Some(handle) = handle {
695 return journals
696 .into_iter()
697 .find(|j| j.handle == handle)
698 .context("error enrollment-invitation-missing");
699 }
700 if expires.is_none() {
701 return journals
702 .pop()
703 .context("error enrollment-invitation-missing");
704 }
705 for journal in &journals {
706 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
707 && !self.closed(db, journal).await?
708 {
709 ensure!(
711 self.phase(db, &journal.name("sent-0"), 128)
712 .await?
713 .is_none(),
714 "error withdrawal-required-unsupported"
715 );
716 return journals
717 .pop()
718 .context("error enrollment-invitation-missing");
719 }
720 }
721 ensure!(
722 journals.len() < membership::MAX_INVITATIONS,
723 "error membership-limit"
724 );
725 let (invitation, d) = inputs.keys.authority().prepare_invitation(
726 &inputs.membership,
727 expires.context("error enrollment-expiry")?,
728 )?;
729 let journal = Outbound {
730 handle: invitation.handle(),
731 invitation: invitation.protected_storage_bytes().to_vec(),
732 declaration: d.record().to_vec(),
733 };
734 self.save_phase(
735 db,
736 &inputs.id,
737 &format!("outbound-{}", journals.len()),
738 JOURNAL_LIMIT,
739 &Zeroizing::new(serde_json::to_vec(&journal)?),
740 )
741 .await?;
742 Ok(journal)
743 }
744 pub async fn registered_invitation(
745 &self,
746 db: &Database,
747 inputs: &ActiveInputs,
748 journal: &Outbound,
749 ) -> Result<Invitation> {
750 self.save_phase(
751 db,
752 &inputs.id,
753 &journal.name("registered"),
754 128,
755 &Sha256::digest(&journal.declaration),
756 )
757 .await?;
758 Invitation::from_protected_storage(&journal.invitation)
759 }
760 pub async fn prepare_admission(
761 &self,
762 db: &Database,
763 inputs: &ActiveInputs,
764 journal: &Outbound,
765 request: &[u8],
766 ) -> Result<Vec<u8>> {
767 ensure!(
768 self.phase(db, &journal.name("registered"), 128)
769 .await?
770 .is_some(),
771 "error enrollment-not-registered"
772 );
773 ensure!(
774 !self.retired(db, journal).await?,
775 "error enrollment-invitation-retired"
776 );
777 ensure!(
779 self.phase(db, &journal.name("withdrawing"), 128)
780 .await?
781 .is_none()
782 && !self.closed(db, journal).await?,
783 "error enrollment-invitation-withdrawing"
784 );
785 let d = Declaration::from_record(&inputs.membership, &journal.declaration)?;
786 let expired = self.enrollment_now()? >= d.expiry();
790 let mut index = 0;
791 for attempt in 0..membership::MAX_CANDIDATES {
792 let Some(bytes) = self
793 .phase(
794 db,
795 &journal.name(&format!("candidate-{attempt}")),
796 CANDIDATE_LIMIT,
797 )
798 .await?
799 else {
800 break;
801 };
802 self.save_phase(db, &inputs.id, &journal.name("bound"), 2048, request)
804 .await?;
805 let candidate: Candidate = serde_json::from_slice(&bytes)
806 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
807 let (_, predecessor) = self.load_evidence(&candidate.predecessor)?;
808 let result = predecessor.append(&journal.declaration, request, &candidate.record)?;
809 ensure!(
810 inputs.membership.extends(&predecessor),
811 "error enrollment-candidate-fork"
812 );
813 if inputs.membership.contains_head(&result.head())
814 || inputs.membership.head() == predecessor.head()
815 {
816 let sent = journal.name(&format!("sent-{attempt}"));
817 ensure!(
818 !expired || self.phase(db, &sent, 128).await?.is_some(),
819 "error enrollment-expired"
820 );
821 self.save_phase(
822 db,
823 &inputs.id,
824 &journal.name(&format!("sent-{attempt}")),
825 128,
826 &result.head(),
827 )
828 .await?;
829 return Ok(candidate.record);
830 }
831 ensure!(
833 inputs
834 .membership
835 .head_at(result.sequence())
836 .is_some_and(|head| head != result.head()),
837 "error enrollment-candidate-unresolved"
838 );
839 index = attempt + 1;
840 }
841 ensure!(
842 index < membership::MAX_CANDIDATES,
843 "error membership-candidate-limit"
844 );
845 ensure!(!expired, "error enrollment-expired");
846 let inv = Invitation::from_protected_storage(&journal.invitation)?;
847 let record = inputs.keys.authority().prepare_admission(
848 &inputs.membership,
849 &d,
850 &inv,
851 request,
852 inputs.generation_keys(),
853 )?;
854 self.save_phase(db, &inputs.id, &journal.name("bound"), 2048, request)
858 .await?;
859 let candidate = Candidate {
860 predecessor: self.save_evidence(&inputs.evidence)?,
861 record: record.clone(),
862 };
863 self.save_phase(
864 db,
865 &inputs.id,
866 &journal.name(&format!("candidate-{index}")),
867 CANDIDATE_LIMIT,
868 &serde_json::to_vec(&candidate)?,
869 )
870 .await?;
871 self.save_phase(
872 db,
873 &inputs.id,
874 &journal.name(&format!("sent-{index}")),
875 128,
876 &Sha256::digest(&record),
877 )
878 .await?;
879 Ok(record)
880 }
881 pub async fn expired_disclosure(
884 &self,
885 db: &Database,
886 inputs: &ActiveInputs,
887 ) -> Result<Option<Outbound>> {
888 let now = self.enrollment_now()?;
889 Ok(self
890 .unresolved_disclosures(db, inputs)
891 .await?
892 .into_iter()
893 .find(|(_, expiry)| now >= *expiry)
894 .map(|(journal, _)| journal))
895 }
896 pub async fn mark_withdrawing(
898 &self,
899 db: &Database,
900 inputs: &ActiveInputs,
901 journal: &Outbound,
902 ) -> Result<()> {
903 self.save_phase(
904 db,
905 &inputs.id,
906 &journal.name("withdrawing"),
907 128,
908 &journal.handle,
909 )
910 .await
911 }
912 pub async fn reconcile_disclosure(
918 &self,
919 db: &Database,
920 inputs: &ActiveInputs,
921 journal: &Outbound,
922 ) -> Result<Disclosure> {
923 let bound = self
924 .phase(db, &journal.name("bound"), 2048)
925 .await?
926 .context("error enrollment-binding-missing")?;
927 let mut candidates = Vec::new();
928 for attempt in 0..membership::MAX_CANDIDATES {
929 let Some(bytes) = self
930 .phase(
931 db,
932 &journal.name(&format!("candidate-{attempt}")),
933 CANDIDATE_LIMIT,
934 )
935 .await?
936 else {
937 break;
938 };
939 let saved: Candidate = serde_json::from_slice(&bytes)
940 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
941 let (_, before) = self.load_evidence(&saved.predecessor)?;
942 ensure!(
943 inputs.membership.extends(&before),
944 "error enrollment-candidate-fork"
945 );
946 let after = before.append(&journal.declaration, &bound, &saved.record)?;
947 match inputs.membership.head_at(after.sequence()) {
948 Some(head) if head == after.head() => {
949 self.finish_inviter(db, inputs, journal, &saved.record)
950 .await?;
951 return Ok(Disclosure::Admitted);
952 }
953 Some(_) => {}
954 None => return Ok(Disclosure::Unresolved),
955 }
956 let recipient = after
957 .devices()
958 .find(|device| !before.has_device(*device))
959 .context("error enrollment-candidate-recipient")?;
960 candidates.push((before, after.sequence(), recipient));
961 }
962 let disclosed = candidates
963 .iter()
964 .flat_map(|(before, _, _)| before.generations().iter().map(|g| g.id))
965 .collect::<std::collections::HashSet<_>>();
966 let last_slot = candidates.iter().map(|(_, slot, _)| *slot).max();
967 let mut state = candidates
968 .iter()
969 .map(|(before, _, _)| before)
970 .min_by_key(|before| before.sequence())
971 .context("error enrollment-candidate-missing")?
972 .clone();
973 let mut qualifying = None;
974 for t in inputs
975 .evidence
976 .transitions
977 .iter()
978 .skip(state.sequence() as usize - 1)
979 {
980 state = state.append(&t.declaration, &t.request, &t.record)?;
981 ensure!(
982 candidates
983 .iter()
984 .all(|(_, _, recipient)| !state.has_device(*recipient)),
985 "error withdrawal-recipient-member"
986 );
987 if qualifying.is_none()
988 && Some(state.sequence()) > last_slot
989 && !state.rotation_pending()
990 && !disclosed.contains(&state.current_generation().id)
991 {
992 qualifying = Some(state.head());
993 }
994 }
995 ensure!(
996 state.head() == inputs.membership.head(),
997 "error enrollment-checkpoint-mismatch"
998 );
999 let Some(head) = qualifying else {
1000 return Ok(Disclosure::Unresolved);
1001 };
1002 self.save_phase(db, &inputs.id, &journal.name("withdrawn"), 128, &head)
1003 .await?;
1004 Ok(Disclosure::Withdrawn)
1005 }
1006 pub async fn finish_inviter(
1007 &self,
1008 db: &Database,
1009 inputs: &ActiveInputs,
1010 journal: &Outbound,
1011 record: &[u8],
1012 ) -> Result<()> {
1013 let head: Hash = Sha256::digest(record).into();
1014 ensure!(
1015 inputs.membership.contains_head(&head),
1016 "error enrollment-outcome-missing"
1017 );
1018 let mut matched = false;
1019 for attempt in 0..membership::MAX_CANDIDATES {
1020 let Some(bytes) = self
1021 .phase(
1022 db,
1023 &journal.name(&format!("candidate-{attempt}")),
1024 CANDIDATE_LIMIT,
1025 )
1026 .await?
1027 else {
1028 break;
1029 };
1030 let candidate: Candidate = serde_json::from_slice(&bytes)
1031 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
1032 if candidate.record == record {
1033 let sent = self
1034 .phase(db, &journal.name(&format!("sent-{attempt}")), 128)
1035 .await?
1036 .context("error enrollment-sent-missing")?;
1037 ensure!(sent.as_slice() == head, "error enrollment-sent-mismatch");
1038 matched = true;
1039 }
1040 }
1041 ensure!(matched, "error enrollment-outcome-mismatch");
1042 self.save_phase(db, &inputs.id, &journal.name("ready"), 128, &head)
1043 .await
1044 }
1045 pub async fn prepare_peer(
1048 &self,
1049 db: &Database,
1050 locator: &str,
1051 invitation: Option<Invitation>,
1052 ) -> Result<Joiner> {
1053 self.select_peer(db, locator, invitation, false).await
1054 }
1055 pub async fn replace_peer(
1059 &self,
1060 db: &Database,
1061 locator: &str,
1062 invitation: Invitation,
1063 ) -> Result<Joiner> {
1064 self.select_peer(db, locator, Some(invitation), true).await
1065 }
1066 async fn select_peer(
1067 &self,
1068 db: &Database,
1069 locator: &str,
1070 invitation: Option<Invitation>,
1071 replace: bool,
1072 ) -> Result<Joiner> {
1073 let guard = InstallationGuard::acquire(db.path())?;
1074 self.validate_database(db).await?;
1075 ensure!(locator.len() <= 2048, "error enrollment-locator-limit");
1076 self.prepare()?;
1077 let _lock = self.lock()?;
1078 let id = match self.identity(db, &guard).await? {
1079 Some(id) => id,
1080 None => {
1081 let client = db.peer_target_preflight().await?;
1082 guard.ensure_unbound()?;
1083 ensure!(
1084 self.load_secret(KEYRING_ITEM, KEYRING_BYTES)?.is_none()
1085 && self.read_marker()?.is_none()
1086 && !self.seed_authority_exists()?,
1087 "error enrollment-existing-authority"
1088 );
1089 let peer = Joiner::generate(
1090 invitation
1091 .as_ref()
1092 .map(|i| Invitation::from_protected_storage(&i.protected_storage_bytes()))
1093 .transpose()?
1094 .context("error enrollment-invitation-required")?,
1095 )?;
1096 guard.fence()?;
1097 let id = Identity {
1098 role: "peer".into(),
1099 client,
1100 incarnation: *Secret::generate()?.expose(),
1101 locator: locator.into(),
1102 authority: peer.protected_storage_bytes().to_vec(),
1103 };
1104 self.write_owned(
1105 "peer-identity",
1106 IDENTITY_LIMIT,
1107 &Zeroizing::new(serde_json::to_vec(&id)?),
1108 )?;
1109 db.pin_enrollment(id.incarnation, &id.client, &id.role, &guard)
1110 .await?;
1111 id
1112 }
1113 };
1114 ensure!(
1115 id.role == "peer" && id.locator == locator,
1116 "error enrollment-context"
1117 );
1118 let mut attempts = self.attempts(db, &id).await?;
1119 let known = invitation
1120 .as_ref()
1121 .map(|i| attempts.iter().position(|peer| peer.matches_invitation(i)));
1122 Ok(match (known, self.response(db).await?) {
1123 (Some(None), _) => {
1124 ensure!(replace, "error enrollment-invitation-conflict");
1125 self.ensure_retry_allowed(db, &id, &guard, attempts.len())
1126 .await?;
1127 let peer = attempts[0].retry(invitation.expect("unknown invitation"))?;
1128 self.save_phase(
1129 db,
1130 &id,
1131 &format!("peer-attempt-{}", attempts.len()),
1132 ATTEMPT_LIMIT,
1133 &peer.attempt_bytes(),
1134 )
1135 .await?;
1136 peer
1137 }
1138 (_, Some(mail)) => responding(attempts, &mail)?,
1139 (Some(Some(index)), None) => attempts.swap_remove(index),
1140 (None, None) => attempts.pop().expect("original attempt"),
1141 })
1142 }
1143 pub async fn peer_attempts(&self, db: &Database, locator: &str) -> Result<Vec<Joiner>> {
1146 let guard = InstallationGuard::acquire(db.path())?;
1147 self.validate_database(db).await?;
1148 let _lock = self.lock()?;
1149 let id = self
1150 .identity(db, &guard)
1151 .await?
1152 .context("error enrollment-missing")?;
1153 ensure!(
1154 id.role == "peer" && id.locator == locator,
1155 "error enrollment-context"
1156 );
1157 let attempts = self.attempts(db, &id).await?;
1158 Ok(match self.response(db).await? {
1159 Some(mail) => vec![responding(attempts, &mail)?],
1160 None => attempts,
1161 })
1162 }
1163 async fn attempts(&self, db: &Database, id: &Identity) -> Result<Vec<Joiner>> {
1168 let original = Joiner::from_protected_storage(&id.authority)?;
1169 let sent = Sha256::digest(original.request());
1170 if let Some(saved) = self.phase(db, "peer-sent", 128).await? {
1171 ensure!(
1172 saved.as_slice() == sent.as_slice(),
1173 "error enrollment-sent-mismatch"
1174 );
1175 }
1176 self.save_phase(db, id, "peer-sent", 128, &sent).await?;
1177 let mut attempts = Vec::new();
1178 let mut gap = false;
1179 for index in 1..MAX_JOIN_ATTEMPTS {
1180 let name = format!("peer-attempt-{index}");
1181 let Some(bytes) = self.phase(db, &name, ATTEMPT_LIMIT).await? else {
1182 gap = true;
1183 continue;
1184 };
1185 ensure!(!gap, "error enrollment-attempt-missing");
1186 if db.enrollment_artifact(&name).await?.is_none() {
1187 self.save_phase(db, id, &name, ATTEMPT_LIMIT, &bytes)
1188 .await?;
1189 }
1190 attempts.push(original.attempt(&bytes)?);
1191 }
1192 attempts.insert(0, original);
1193 Ok(attempts)
1194 }
1195 async fn ensure_retry_allowed(
1199 &self,
1200 db: &Database,
1201 id: &Identity,
1202 guard: &InstallationGuard,
1203 attempts: usize,
1204 ) -> Result<()> {
1205 for (kind, size) in [
1206 ("peer-response", RESPONSE_LIMIT),
1207 ("peer-verified", 2048),
1208 ("peer-ready", 128),
1209 ("peer-installed", 128),
1210 ] {
1211 ensure!(
1212 self.phase(db, kind, size).await?.is_none(),
1213 "error enrollment-retry-unavailable"
1214 );
1215 }
1216 ensure!(
1217 self.membership_floor(db, id.incarnation).await?.is_none()
1218 && db.membership_checkpoint_mirror().await?.is_none()
1219 && self.journals(db).await?.is_empty(),
1220 "error enrollment-retry-unavailable"
1221 );
1222 db.peer_retry_preflight(id.incarnation, &id.client, guard)
1223 .await?;
1224 ensure!(attempts < MAX_JOIN_ATTEMPTS, "error enrollment-retry-limit");
1225 Ok(())
1226 }
1227 async fn response(&self, db: &Database) -> Result<Option<membership::Mailbox>> {
1228 self.phase(db, "peer-response", RESPONSE_LIMIT)
1229 .await?
1230 .map(|bytes| {
1231 serde_json::from_slice(&bytes)
1232 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))
1233 })
1234 .transpose()
1235 }
1236 pub async fn open_peer_response(
1239 &self,
1240 db: &Database,
1241 mail: &membership::Mailbox,
1242 ) -> Result<membership::ProvisionalGrant> {
1243 let guard = InstallationGuard::acquire(db.path())?;
1244 self.validate_database(db).await?;
1245 let _lock = self.lock()?;
1246 let id = self
1247 .identity(db, &guard)
1248 .await?
1249 .context("error enrollment-missing")?;
1250 ensure!(id.role == "peer", "error enrollment-role");
1251 responding(self.attempts(db, &id).await?, mail)?.open_provisional(
1252 &mail.declaration,
1253 mail.admission
1254 .as_deref()
1255 .context("error enrollment-outcome-missing")?,
1256 )
1257 }
1258 pub async fn finish_peer(
1261 &self,
1262 db: &Database,
1263 mail: &membership::Mailbox,
1264 evidence: &Evidence,
1265 ) -> Result<()> {
1266 let guard = InstallationGuard::acquire(db.path())?;
1267 self.validate_database(db).await?;
1268 let _lock = self.lock()?;
1269 let id = self
1270 .identity(db, &guard)
1271 .await?
1272 .context("error enrollment-missing")?;
1273 ensure!(id.role == "peer", "error enrollment-role");
1274 let peer = responding(self.attempts(db, &id).await?, mail)?;
1275 let grant = peer.open_provisional(
1276 &mail.declaration,
1277 mail.admission
1278 .as_deref()
1279 .context("error enrollment-outcome-missing")?,
1280 )?;
1281 let (verified, current) = evidence.enrollment(&peer, grant.outcome)?;
1282 self.save_phase(
1283 db,
1284 &id,
1285 "peer-response",
1286 RESPONSE_LIMIT,
1287 &serde_json::to_vec(mail)?,
1288 )
1289 .await?;
1290 let mut original = evidence.clone();
1292 original
1293 .transitions
1294 .truncate(verified.membership().sequence() as usize - 1);
1295 let record = Verified {
1296 evidence: self.save_evidence(&original)?,
1297 outcome: grant.outcome,
1298 key: verified.key().protected_storage_bytes().to_vec(),
1299 };
1300 self.save_phase(
1301 db,
1302 &id,
1303 "peer-verified",
1304 2048,
1305 &Zeroizing::new(serde_json::to_vec(&record)?),
1306 )
1307 .await?;
1308 if let Some((floor, _, _)) = self.membership_floor(db, id.incarnation).await?
1309 && floor.extends(¤t)
1310 {
1311 peer.authority().validate(&floor)?;
1312 self.save_phase(db, &id, "peer-ready", 128, &record.outcome)
1313 .await?;
1314 return Ok(());
1315 }
1316 self.refresh_membership(
1317 db,
1318 id.incarnation,
1319 peer.authority(),
1320 evidence,
1321 current,
1322 super::membership::Verified {
1323 membership: verified.membership(),
1324 keys: verified.keys(),
1325 },
1326 )
1327 .await?;
1328 self.save_phase(db, &id, "peer-ready", 128, &record.outcome)
1329 .await
1330 }
1331 async fn verified_peer(
1332 &self,
1333 db: &Database,
1334 id: &Identity,
1335 ) -> Result<(Joiner, VerifiedEnrollment, Verified, (Evidence, Membership))> {
1336 let ready = self
1337 .phase(db, "peer-ready", 128)
1338 .await?
1339 .context("error enrollment-not-ready")?;
1340 let bytes = self
1341 .phase(db, "peer-verified", 2048)
1342 .await?
1343 .context("error enrollment-key-coverage-missing")?;
1344 let record: Verified = serde_json::from_slice(&bytes)
1345 .map_err(|_| anyhow::anyhow!("error enrollment-protected-framing"))?;
1346 let mail = self
1347 .response(db)
1348 .await?
1349 .context("error enrollment-response-missing")?;
1350 let peer = responding(self.attempts(db, id).await?, &mail)?;
1351 let evidence = Evidence::decode_unverified(&self.load_evidence_bytes(&record.evidence)?)?;
1353 let (verified, current) = evidence.enrollment(&peer, record.outcome)?;
1354 ensure!(
1355 ready.as_slice() == record.outcome
1356 && verified.key().protected_storage_bytes().as_slice() == record.key,
1357 "error enrollment-verified-corrupt"
1358 );
1359 Ok((peer, verified, record, (evidence, current)))
1360 }
1361 pub async fn install_peer_snapshot(
1362 &self,
1363 db: &Database,
1364 transport: &crate::sync::client::enrollment::Client,
1365 locator: &str,
1366 downloaded: &(dyn Fn(u64, Option<u64>) + Sync),
1367 ) -> Result<crate::sync::SharedStateInstallReport> {
1368 let guard = InstallationGuard::acquire(db.path())?;
1369 self.validate_database(db).await?;
1370 let _lock = self.lock()?;
1371 let id = self
1372 .identity(db, &guard)
1373 .await?
1374 .context("error enrollment-missing")?;
1375 ensure!(
1376 id.role == "peer" && id.locator == locator,
1377 "error enrollment-context"
1378 );
1379 let (peer, verified, _, (evidence, _)) = self.verified_peer(db, &id).await?;
1380 let completed = self.phase(db, "peer-installed", 128).await?;
1381 if let Some(report) = db
1382 .peer_snapshot_receipt(&verified, id.incarnation, &id.client, &guard)
1383 .await?
1384 {
1385 let (floor, _, _) = self
1386 .membership_floor(db, id.incarnation)
1387 .await?
1388 .context("error membership-floor-missing")?;
1389 peer.authority().validate(&floor)?;
1390 self.save_phase(db, &id, "peer-installed", 128, &verified.checkpoint())
1391 .await?;
1392 return Ok(report);
1393 }
1394 ensure!(
1395 completed.is_none(),
1396 "error snapshot-installed-database-lost"
1397 );
1398 let (floor, _, _) = self
1399 .membership_floor(db, id.incarnation)
1400 .await?
1401 .context("error membership-floor-missing")?;
1402 peer.authority().validate(&floor)?;
1403 let package = transport
1404 .download(
1405 self,
1406 db,
1407 id.incarnation,
1408 &peer,
1409 &verified,
1410 &evidence.descriptor,
1411 downloaded,
1412 )
1413 .await?;
1414 let report = db
1415 .install_peer_snapshot(&verified, id.incarnation, &id.client, &guard, &package)
1416 .await?;
1417 self.save_phase(db, &id, "peer-installed", 128, &verified.checkpoint())
1418 .await?;
1419 Ok(report)
1420 }
1421 pub async fn adopt_download_refresh(
1422 &self,
1423 db: &Database,
1424 identity: Hash,
1425 peer: &Joiner,
1426 verified: &VerifiedEnrollment,
1427 evidence: &Evidence,
1428 target: Membership,
1429 ) -> Result<Membership> {
1430 Ok(self
1431 .refresh_membership(
1432 db,
1433 identity,
1434 peer.authority(),
1435 evidence,
1436 target,
1437 super::membership::Verified {
1438 membership: verified.membership(),
1439 keys: verified.keys(),
1440 },
1441 )
1442 .await?
1443 .0)
1444 }
1445 pub async fn tail_inputs(&self, db: &Database, locator: &str) -> Result<TailSnapshot> {
1448 let inputs = self.active_inputs(db, locator).await?;
1449 let withdrawal_deadline = self
1450 .unresolved_disclosures(db, &inputs)
1451 .await?
1452 .into_iter()
1453 .map(|(_, expiry)| expiry)
1454 .min();
1455 let b = inputs.membership.publication().binding();
1456 let association = format!(
1457 "{}:{}:{}",
1458 hex::encode(b.vault_id),
1459 hex::encode(b.stream_id),
1460 hex::encode(b.bootstrap_id)
1461 );
1462 ensure!(
1463 db.meta("e2ee_association").await?.as_deref() == Some(association.as_str()),
1464 "error encrypted-tail-association"
1465 );
1466 let authority = crate::sync::encrypted_tail::Authority {
1467 context: crate::sync::encrypted_tail::Context {
1468 vault: b.vault_id,
1469 genesis: inputs.membership.genesis().commitment(),
1470 device: inputs.device(),
1471 head: inputs.membership.head(),
1472 stream: b.stream_id,
1473 descriptor: b.descriptor_commitment,
1474 },
1475 membership: inputs.membership.clone(),
1476 keys: inputs.coverage.clone(),
1477 prefix: i64::try_from(b.prefix_count)?,
1478 association,
1479 sync_generation: db
1480 .meta("sync_generation")
1481 .await?
1482 .context("error encrypted-tail-generation")?
1483 .parse()?,
1484 };
1485 let snapshot = TailSnapshot {
1486 authority,
1487 bearer: Secret::new(*inputs.bearer().expose()),
1488 withdrawal_deadline,
1489 enrollment_marker: db.enrollment_artifact_marker().await?,
1490 #[cfg(any(test, feature = "test-support"))]
1491 enrollment_clock: self.enrollment_clock.clone(),
1492 };
1493 drop(inputs);
1494 Ok(snapshot)
1495 }
1496 pub async fn association(&self, db: &Database) -> Result<Option<(bool, String)>> {
1499 let guard = InstallationGuard::acquire(db.path())?;
1500 self.validate_database(db).await?;
1501 self.prepare()?;
1502 let _lock = self.lock()?;
1503 Ok(self
1504 .identity(db, &guard)
1505 .await?
1506 .map(|id| (id.role == "peer", id.locator.clone())))
1507 }
1508 #[cfg(any(test, feature = "test-support"))]
1509 pub async fn outbound_invitation(&self, db: &Database) -> Result<Option<OutboundInvitation>> {
1510 let _guard = InstallationGuard::acquire(db.path())?;
1511 self.validate_database(db).await?;
1512 self.prepare()?;
1513 let _lock = self.lock()?;
1514 for journal in self.journals(db).await? {
1515 if self.phase(db, &journal.name("ready"), 128).await?.is_none()
1516 && !self.closed(db, &journal).await?
1517 {
1518 return Ok(Some(if self.may_have_sent(db, &journal).await? {
1519 OutboundInvitation::Disclosed
1520 } else {
1521 OutboundInvitation::Pending
1522 }));
1523 }
1524 }
1525 Ok(None)
1526 }
1527 pub async fn enrollment_readiness(&self, db: &Database) -> Result<EnrollmentReadiness> {
1528 let guard = InstallationGuard::acquire(db.path())?;
1529 self.validate_database(db).await?;
1530 self.prepare()?;
1531 let _lock = self.lock()?;
1532 let Some(id) = self.identity(db, &guard).await? else {
1533 return Ok(EnrollmentReadiness::NotSelected);
1534 };
1535 if id.role == "peer" && self.phase(db, "peer-ready", 128).await?.is_none() {
1536 return Ok(EnrollmentReadiness::Pending);
1537 }
1538 let Some((m, _, _)) = self.membership_floor(db, id.incarnation).await? else {
1539 return Ok(EnrollmentReadiness::Pending);
1540 };
1541 if id.role == "peer" {
1542 self.verified_peer(db, &id)
1543 .await?
1544 .0
1545 .authority()
1546 .validate(&m)?;
1547 } else {
1548 Device::seed(&self.seed_inputs(db).await?.0).validate(&m)?;
1549 }
1550 Ok(EnrollmentReadiness::Enrolled { head: m.head() })
1551 }
1552}
1553fn responding(attempts: Vec<Joiner>, mail: &membership::Mailbox) -> Result<Joiner> {
1556 attempts
1557 .into_iter()
1558 .find(|peer| mail.request.as_deref() == Some(peer.request()))
1559 .context("error enrollment-request-mismatch")
1560}
1561impl Outbound {
1562 fn name(&self, phase: &str) -> String {
1563 invitation_phase_name(&self.handle, phase)
1564 }
1565}
1566fn invitation_phase_name(handle: &Hash, phase: &str) -> String {
1567 format!("invite-{}-{phase}", hex::encode(handle))
1568}
1569#[derive(Debug, PartialEq, Eq)]
1570pub enum Disclosure {
1571 Admitted,
1572 Withdrawn,
1573 Unresolved,
1574}
1575pub struct TailSnapshot {
1576 pub authority: crate::sync::encrypted_tail::Authority,
1577 pub bearer: Secret,
1578 withdrawal_deadline: Option<u64>,
1581 enrollment_marker: i64,
1582 #[cfg(any(test, feature = "test-support"))]
1583 enrollment_clock: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
1584}
1585impl TailSnapshot {
1586 pub fn require_publishing_ready(&self) -> Result<()> {
1588 if self.publishing_blocked()? {
1589 return Err(PublishingBlocked.into());
1590 }
1591 Ok(())
1592 }
1593
1594 pub fn publishing_blocked(&self) -> Result<bool> {
1595 #[cfg(any(test, feature = "test-support"))]
1596 let now = enrollment_now(self.enrollment_clock.as_ref())?;
1597 #[cfg(not(any(test, feature = "test-support")))]
1598 let now = enrollment_now()?;
1599 Ok(self
1600 .withdrawal_deadline
1601 .is_some_and(|deadline| now >= deadline))
1602 }
1603
1604 pub async fn is_current(&self, db: &Database) -> Result<bool> {
1605 Ok(self.enrollment_marker == db.enrollment_artifact_marker().await?)
1606 }
1607}
1608
1609impl ProtectedLocalKeyStore {
1610 fn enrollment_now(&self) -> Result<u64> {
1611 #[cfg(any(test, feature = "test-support"))]
1612 return enrollment_now(self.enrollment_clock.as_ref());
1613 #[cfg(not(any(test, feature = "test-support")))]
1614 enrollment_now()
1615 }
1616}
1617
1618#[cfg(any(test, feature = "test-support"))]
1620fn enrollment_now(clock: Option<&std::sync::Arc<std::sync::atomic::AtomicU64>>) -> Result<u64> {
1621 match clock {
1622 Some(clock) => Ok(clock.load(Ordering::SeqCst)),
1623 None => crate::sync::client::engine::unix_now(),
1624 }
1625}
1626
1627#[cfg(not(any(test, feature = "test-support")))]
1628fn enrollment_now() -> Result<u64> {
1629 crate::sync::client::engine::unix_now()
1630}