Skip to main content

aven_core/sync/client/keys/
rotation.rs

1//! Bounded append-only removal intents and exact management dispatch ownership.
2use super::{membership::EvidenceRef, peer::ActiveInputs, *};
3use crate::sync::seed_claim::membership::{
4    Evidence, MAX_CANDIDATES, MAX_RECORD_BYTES, MAX_TRANSITIONS, Membership, RotationMaterial,
5};
6use anyhow::{Context, Result, ensure};
7use serde::{Deserialize, Serialize};
8type Hash = [u8; 32];
9const INTENT_BYTES: usize = 1024;
10const PLAN_BYTES: usize = 1024;
11const RECORD_BYTES: usize = MAX_RECORD_BYTES + 128;
12const MATERIAL_BYTES: usize = 256;
13
14#[derive(Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Intent {
17    index: usize,
18    target: Option<Hash>,
19    /// Outbound invitation handle whose possibly sent grant this freeze and
20    /// rotation withdraw. Removal intents carry `target` instead.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    withdraw: Option<Hash>,
23    original: EvidenceRef,
24}
25impl Intent {
26    fn name(&self, phase: &str) -> String {
27        format!("management-{}-{phase}", self.index)
28    }
29    pub fn target(&self) -> Option<Hash> {
30        self.target
31    }
32    /// Removal revokes its target; withdrawal freezes without one.
33    fn revoke_targets(&self) -> Result<Vec<Hash>> {
34        match (self.target, self.withdraw) {
35            (Some(target), None) => Ok(vec![target]),
36            (None, Some(_)) => Ok(vec![]),
37            _ => anyhow::bail!("error management-target"),
38        }
39    }
40}
41#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Action {
43    Revoke,
44    Rotate,
45}
46#[derive(Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48struct Plan {
49    before: EvidenceRef,
50    action: Action,
51    cutoff: u64,
52}
53pub struct Dispatch {
54    pub record: Vec<u8>,
55    pub evidence: Evidence,
56    pub membership: Membership,
57    pub action: Action,
58}
59
60impl ProtectedLocalKeyStore {
61    fn management_action(&self, inputs: &ActiveInputs, intent: &Intent) -> Result<Option<Action>> {
62        let (_, original) = self.load_evidence(&intent.original)?;
63        ensure!(
64            inputs.membership.extends(&original),
65            "error management-fork"
66        );
67        match (intent.target, intent.withdraw) {
68            (Some(target), None) => {
69                ensure!(original.has_device(target), "error management-target")
70            }
71            (None, None) => ensure!(original.rotation_pending(), "error management-intent"),
72            (None, Some(_)) => {}
73            (Some(_), Some(_)) => anyhow::bail!("error management-intent"),
74        }
75        let mut removed_generation = intent
76            .target
77            .is_none()
78            .then_some(original.current_generation().id);
79        let mut before = original.clone();
80        for t in inputs
81            .evidence
82            .transitions
83            .iter()
84            .skip(original.sequence() as usize - 1)
85        {
86            let next = before.append(&t.declaration, &t.request, &t.record)?;
87            if removed_generation.is_none()
88                && intent.target.is_some_and(|target| !next.has_device(target))
89            {
90                removed_generation = Some(next.current_generation().id);
91            }
92            if removed_generation
93                .is_some_and(|generation| generation != next.current_generation().id)
94                && !next.rotation_pending()
95            {
96                return Ok(None);
97            }
98            before = next;
99        }
100        // Withdrawal freezes unless membership is already pending.
101        let freeze = intent.withdraw.is_some() && !inputs.membership.rotation_pending();
102        Ok(Some(if removed_generation.is_none() || freeze {
103            Action::Revoke
104        } else {
105            Action::Rotate
106        }))
107    }
108
109    /// Validate every retained phase before treating its slot as committed or
110    /// lost. Without `prove`, only phase presence, framing and evidence digests
111    /// are checked, for an intent whose completion `ready` already proves.
112    async fn management_plans(
113        &self,
114        db: &Database,
115        inputs: &ActiveInputs,
116        intent: &Intent,
117        prove: bool,
118    ) -> Result<Vec<(Plan, Option<Membership>, Option<Vec<u8>>)>> {
119        let mut plans = Vec::new();
120        let mut gap = false;
121        let mut previous_sequence = 0;
122        for index in 0..MAX_CANDIDATES {
123            let raw = self
124                .phase(db, &intent.name(&format!("plan-{index}")), PLAN_BYTES)
125                .await?;
126            let record = self
127                .phase(
128                    db,
129                    &intent.name(&format!("candidate-{index}")),
130                    RECORD_BYTES,
131                )
132                .await?;
133            let material = self
134                .phase(
135                    db,
136                    &intent.name(&format!("material-{index}")),
137                    MATERIAL_BYTES,
138                )
139                .await?;
140            let sent = self
141                .phase(db, &intent.name(&format!("sent-{index}")), 128)
142                .await?;
143            let Some(raw) = raw else {
144                ensure!(
145                    record.is_none() && material.is_none() && sent.is_none(),
146                    "error management-plan-missing"
147                );
148                gap = true;
149                continue;
150            };
151            ensure!(!gap, "error management-plan-missing");
152            let plan: Plan = serde_json::from_slice(&raw)?;
153            if plan.action == Action::Revoke {
154                ensure!(
155                    material.is_none() && plan.cutoff == 0,
156                    "error management-plan"
157                );
158            }
159            if let Some(material) = &material {
160                RotationMaterial::from_protected_storage(material)?;
161            }
162            ensure!(
163                record.is_some() || sent.is_none(),
164                "error management-candidate-missing"
165            );
166            ensure!(
167                record.is_none() || plan.action != Action::Rotate || material.is_some(),
168                "error management-material-missing"
169            );
170            if !prove {
171                self.load_evidence_bytes(&plan.before)?;
172                plans.push((plan, None, record.map(|r| r.to_vec())));
173                continue;
174            }
175            let (_, before) = self.load_evidence(&plan.before)?;
176            ensure!(inputs.membership.extends(&before), "error management-fork");
177            ensure!(
178                before.sequence() > previous_sequence,
179                "error management-plan-order"
180            );
181            previous_sequence = before.sequence();
182            if let Some(record) = &record {
183                let after = before.append(&[], &[], record)?;
184                if plan.action == Action::Revoke {
185                    ensure!(
186                        inputs
187                            .authority()
188                            .prepare_revoke(&before, &intent.revoke_targets()?)?
189                            == record.as_slice(),
190                        "error management-target-mismatch"
191                    );
192                }
193                ensure!(
194                    (after.generations().len() > before.generations().len())
195                        == (plan.action == Action::Rotate),
196                    "error management-action"
197                );
198                if plan.action == Action::Rotate {
199                    ensure!(
200                        after.current_generation().starts_after == plan.cutoff,
201                        "error management-cutoff"
202                    );
203                    RotationMaterial::from_protected_storage(
204                        material
205                            .as_ref()
206                            .context("error management-material-missing")?,
207                    )?
208                    .validate_generation(&after)?;
209                }
210                if inputs.membership.head_at(after.sequence()) == Some(after.head()) {
211                    ensure!(sent.is_some(), "error management-sent-missing");
212                }
213                if let Some(sent) = &sent {
214                    ensure!(
215                        sent.as_slice() == after.head(),
216                        "error management-sent-mismatch"
217                    );
218                }
219            }
220            plans.push((plan, Some(before), record.map(|r| r.to_vec())));
221        }
222        Ok(plans)
223    }
224    /// `target` requests removal, `withdraw` an invitation withdrawal, and
225    /// neither resumes unfinished work or finishes a pending rotation.
226    pub async fn management_intent(
227        &self,
228        db: &Database,
229        inputs: &ActiveInputs,
230        target: Option<Hash>,
231        withdraw: Option<Hash>,
232    ) -> Result<Option<Intent>> {
233        let mut next = 0;
234        let mut gap = false;
235        let mut unfinished = None;
236        let mut completed_target = false;
237        for index in 0..MAX_TRANSITIONS {
238            let raw = self
239                .phase(db, &format!("management-{index}-intent"), INTENT_BYTES)
240                .await?;
241            let Some(raw) = raw else {
242                gap = true;
243                continue;
244            };
245            ensure!(!gap, "error management-intent-missing");
246            ensure!(unfinished.is_none(), "error management-intent-order");
247            next = index + 1;
248            let intent: Intent = serde_json::from_slice(&raw)?;
249            ensure!(intent.index == index, "error management-intent");
250            // `ready` was recorded only once the chain proved the intent done,
251            // and every extension of that head stays done, so a completed
252            // intent keeps only its structural phase checks.
253            let done = if let Some(ready) = self.phase(db, &intent.name("ready"), 128).await? {
254                ensure!(
255                    inputs
256                        .membership
257                        .contains_head(&ready.as_slice().try_into()?),
258                    "error management-ready-mismatch"
259                );
260                self.load_evidence_bytes(&intent.original)?;
261                self.management_plans(db, inputs, &intent, false).await?;
262                true
263            } else {
264                self.management_plans(db, inputs, &intent, true).await?;
265                let done = self.management_action(inputs, &intent)?.is_none();
266                if done {
267                    self.save_management_phase(
268                        db,
269                        inputs,
270                        &intent.name("ready"),
271                        128,
272                        &inputs.membership.head(),
273                    )
274                    .await?;
275                }
276                done
277            };
278            if done {
279                if (target.is_some() && target == intent.target)
280                    || (withdraw.is_some() && withdraw == intent.withdraw)
281                {
282                    completed_target = true;
283                }
284            } else {
285                unfinished = Some(intent);
286            }
287        }
288        if completed_target {
289            return Ok(None);
290        }
291        if let Some(intent) = unfinished {
292            ensure!(
293                (target.is_none() && withdraw.is_none())
294                    || (target == intent.target && withdraw == intent.withdraw),
295                "error management-unfinished"
296            );
297            return Ok(Some(intent));
298        }
299        if target.is_none() && withdraw.is_none() && !inputs.membership.rotation_pending() {
300            return Ok(None);
301        }
302        ensure!(next < MAX_TRANSITIONS, "error membership-change-limit");
303        if let Some(target) = target {
304            inputs
305                .authority()
306                .prepare_revoke(&inputs.membership, &[target])?;
307        }
308        let intent = Intent {
309            index: next,
310            target,
311            withdraw,
312            original: self.save_evidence(&inputs.evidence)?,
313        };
314        self.save_management_phase(
315            db,
316            inputs,
317            &intent.name("intent"),
318            INTENT_BYTES,
319            &serde_json::to_vec(&intent)?,
320        )
321        .await?;
322        Ok(Some(intent))
323    }
324    pub async fn management_dispatch(
325        &self,
326        db: &Database,
327        inputs: &ActiveInputs,
328        intent: &Intent,
329        high: u64,
330    ) -> Result<Option<Dispatch>> {
331        let plans = self.management_plans(db, inputs, intent, true).await?;
332        let Some(action) = self.management_action(inputs, intent)? else {
333            return Ok(None);
334        };
335        let mut index = plans.len();
336        let mut selected = None;
337        for (n, (plan, before, record)) in plans.into_iter().enumerate() {
338            let before = before.context("error management-plan")?;
339            if let Some(head) = inputs.membership.head_at(before.sequence() + 1) {
340                if record
341                    .as_ref()
342                    .is_some_and(|r| Sha256::digest(r).as_slice() == head)
343                {
344                    ensure!(
345                        self.phase(db, &intent.name(&format!("sent-{n}")), 128)
346                            .await?
347                            .is_some(),
348                        "error management-sent-missing"
349                    );
350                }
351                continue;
352            }
353            ensure!(
354                before.head() == inputs.membership.head() && plan.action == action,
355                "error management-unresolved"
356            );
357            index = n;
358            selected = Some((plan, record));
359            break;
360        }
361        ensure!(index < MAX_CANDIDATES, "error management-limit");
362        let (plan, saved) = match selected {
363            Some(value) => value,
364            None => {
365                let plan = Plan {
366                    before: self.save_evidence(&inputs.evidence)?,
367                    action,
368                    cutoff: if action == Action::Rotate { high } else { 0 },
369                };
370                self.save_management_phase(
371                    db,
372                    inputs,
373                    &intent.name(&format!("plan-{index}")),
374                    PLAN_BYTES,
375                    &serde_json::to_vec(&plan)?,
376                )
377                .await?;
378                (plan, None)
379            }
380        };
381        let record = if let Some(saved) = saved {
382            saved
383        } else {
384            let record = match action {
385                Action::Revoke => inputs
386                    .authority()
387                    .prepare_revoke(&inputs.membership, &intent.revoke_targets()?)?,
388                Action::Rotate => {
389                    ensure!(plan.cutoff == high, "error management-cutoff");
390                    let name = intent.name(&format!("material-{index}"));
391                    let material = match self.phase(db, &name, MATERIAL_BYTES).await? {
392                        Some(bytes) => RotationMaterial::from_protected_storage(&bytes)?,
393                        None => {
394                            let material = RotationMaterial::generate()?;
395                            self.save_management_phase(
396                                db,
397                                inputs,
398                                &name,
399                                MATERIAL_BYTES,
400                                &material.protected_storage_bytes(),
401                            )
402                            .await?;
403                            material
404                        }
405                    };
406                    inputs.authority().prepare_rotation_with(
407                        &inputs.membership,
408                        inputs.generation_keys(),
409                        plan.cutoff,
410                        &material,
411                    )?
412                }
413            };
414            self.save_management_phase(
415                db,
416                inputs,
417                &intent.name(&format!("candidate-{index}")),
418                RECORD_BYTES,
419                &record,
420            )
421            .await?;
422            record
423        };
424        self.save_management_phase(
425            db,
426            inputs,
427            &intent.name(&format!("sent-{index}")),
428            128,
429            &Sha256::digest(&record),
430        )
431        .await?;
432        let (mut evidence, before) = self.load_evidence(&plan.before)?;
433        let membership = before.append(&[], &[], &record)?;
434        evidence
435            .transitions
436            .push(crate::sync::seed_claim::membership::EvidenceRecord {
437                declaration: vec![],
438                request: vec![],
439                record: record.clone(),
440            });
441        Ok(Some(Dispatch {
442            record,
443            evidence,
444            membership,
445            action,
446        }))
447    }
448}