Skip to main content

aven_core/sync/client/keys/
peer.rs

1//! Independent installation identity, inbound enrollment and outbound journals.
2use 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;
22// Attempts and mailbox responses are stored as separate protected records, so
23// these per-record bounds do not grow with MAX_JOIN_ATTEMPTS.
24const CANDIDATE_LIMIT: usize = 4 * membership::MAX_RECORD_BYTES + 1024;
25const RESPONSE_LIMIT: usize = CANDIDATE_LIMIT + 4096;
26const ATTEMPT_LIMIT: usize = 512;
27/// Retained join attempts per installation, including the original request.
28pub 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/// This installation's own enrollment. Invitations it issued never change it.
75#[derive(Debug, PartialEq, Eq)]
76pub enum EnrollmentReadiness {
77    NotSelected,
78    Pending,
79    Enrolled { head: Hash },
80}
81/// Secret-free state of an issued invitation that is neither admitted nor closed.
82#[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/// Where one issued invitation stands, as recorded on this device.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum InvitationProgress {
92    Open,
93    Admitted,
94    Closed,
95}
96
97/// An issued invitation that is neither admitted nor closed.
98#[cfg(any(test, feature = "test-support"))]
99#[derive(Debug, PartialEq, Eq)]
100pub enum OutboundInvitation {
101    /// No grant was prepared for sending.
102    Pending,
103    /// A grant may have been sent.
104    Disclosed,
105}
106/// New encrypted content waits for the withdrawal rotation of an expired
107/// invitation whose grant may have been sent. Pulls and downloads continue.
108#[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    /// Pins are only ever added, by `save_phase`, so one bulk load serves the
206    /// whole lock.
207    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                // This store lock also covers admission preparation and dispatch,
448                // so no grant for this handle was or can be sent. Sent journals
449                // stay unresolved until admission or a qualifying rotation.
450                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    /// `target` must be what `evidence` verifies to.
475    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    /// Terminal pre-disclosure abandonment of an expired, never-sent invitation.
566    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    /// Retired before disclosure, or withdrawn after a proven rotation.
573    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    /// Unadmitted, unclosed journals whose grant may have been sent, with their
593    /// declared expiry.
594    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    /// Reads only this invitation's terminal phases, so waiting for a join can
634    /// check it often without loading every journal.
635    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    /// Retires the open invitation if no grant has been sent. The returned
657    /// journal remains available for best-effort server cancellation.
658    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                // Exact registration retry retains the immutable invitation.
710                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        // Withdrawal never sends another candidate or resends an old one.
778        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        // Inputs may predate a long mailbox wait, so expiry is sampled here
787        // rather than when they were loaded. A candidate that may already have
788        // been sent can still be resent; anything else would be a first dispatch.
789        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            // A stored candidate means this binding exists; it never rebinds.
803            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            // A verified different successor at the signed slot resolves CAS loss.
832            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        // Binding follows the request's full validation during preparation, so
855        // an unauthenticated request never occupies it, and precedes the
856        // candidate that depends on it.
857        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    /// The unresolved journal whose grant may have been sent, once its
882    /// declared expiry has passed. Expiry only starts withdrawal work.
883    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    /// Fences local candidate creation and resends before remote cancellation.
897    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    /// Resolves a possibly disclosed journal from verified membership only.
913    /// A candidate at its signed slot is an admission and finishes `ready`.
914    /// `withdrawn` requires every stored candidate to have lost its slot, no
915    /// candidate recipient ever becoming a member, and a later non-pending
916    /// generation absent from every candidate predecessor.
917    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    /// The request to post: a known invitation's exact attempt, otherwise the
1046    /// latest attempt, or the one a pinned response answers.
1047    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    /// Like `prepare_peer`, but an unknown invitation becomes a new attempt
1056    /// while joining is unfinished. The installation keys, enrollment pin and
1057    /// fence stay unchanged, and every earlier attempt is retained.
1058    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    /// Every retained attempt, oldest first, or only the one a pinned
1144    /// response answers. Attempts are never removed.
1145    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    /// The immutable original followed by retained replacement attempts.
1164    /// The original's `peer-sent` record, written and committed before its
1165    /// first post, must match; a record written before its SQLite commitment
1166    /// is committed unchanged, as is any replacement attempt.
1167    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    /// A replacement attempt only while no admission has been accepted here:
1196    /// no pinned response or later phase, membership floor, outbound journal,
1197    /// installation or association, and an empty domain.
1198    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    /// Opens a mailbox admission without retaining it. The signature and
1237    /// membership binding are unchecked here, so only `finish_peer` pins it.
1238    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    /// Pins `mail` only after `evidence` proves its exact outcome, so an
1259    /// unverified response never blocks a correct one.
1260    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        // Retain only the original outcome's ancestry, never a mutable Ready value.
1291        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(&current)
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        // Locating the outcome verifies the whole chain, so load it unverified.
1352        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    /// Validated tail material for one drain. It stays current until the
1446    /// enrollment marker changes or the server reports a stale context.
1447    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    /// Whether this installation joined as a peer, and the server locator its
1497    /// enrollment identity is bound to.
1498    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}
1553/// The attempt whose exact request a mailbox response answers. Its grant
1554/// binds that attempt's invitation, handle and request hash.
1555fn 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    /// Earliest expiry of an invitation whose grant may have been sent. From
1579    /// then on, publishing waits for its withdrawal rotation.
1580    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    /// Checked before every step that publishes new encrypted content.
1587    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/// Unix seconds enrollment deadlines compare against, overridable in tests.
1619#[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}