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