Skip to main content

clawft_kernel/
project_identity_view.rs

1//! The merged revocation / binding view (see the parent module docs).
2
3use std::collections::{HashMap, HashSet};
4
5use clawft_types::project::cert::{ProjectCert, key_id};
6
7use super::{
8    IdentityError, JournalRecord, KIND_REGISTER, KIND_REKEY, KIND_REVOKE, KIND_ROTATED, SOURCE,
9    verify_cert_historic,
10};
11use crate::user_key_rotation::UserKeyHistory;
12use crate::chain::ChainEvent;
13
14/// Outcome of [`RevocationView::plan_registration`].
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Registration {
17    /// This key is already certified; reuse the certificate (no new event).
18    Existing(Box<ProjectCert>),
19    /// First key for the id (or the first after a revoke): certify with
20    /// this serial.
21    New {
22        /// Serial to issue.
23        serial: u64,
24    },
25}
26
27#[derive(Debug, Default, Clone)]
28struct ProjectIdentity {
29    certs: Vec<ProjectCert>,
30    revoked: HashSet<String>,
31    /// Keys replaced by a `project.rekey`: revoked as signers, but not
32    /// compromised (the owner replaced them on purpose).
33    retired: HashSet<String>,
34}
35
36/// Which keys of which projects are certified or revoked, merged from the
37/// user chain, the identity journal and the certificate files. Built only
38/// by [`Self::build`]; there is no way to add an unverified certificate.
39#[derive(Debug, Clone)]
40pub struct RevocationView {
41    trust: UserKeyHistory,
42    /// Chain sequence of the `project.register`/`project.rekey` event that
43    /// carries each certificate (by signature).
44    anchored: HashMap<String, u64>,
45    /// Chain sequence of the first `user.key.rotated` event per retired key id.
46    rotated_seq: HashMap<String, u64>,
47    projects: HashMap<String, ProjectIdentity>,
48    /// key id -> the project that first claimed it (certified or revoked).
49    key_owner: HashMap<String, String>,
50    rejected: usize,
51}
52
53impl RevocationView {
54    /// Merge `events` (only [`SOURCE`] events count), `journal` records and
55    /// `cert_files` into one view. Every certificate must verify against
56    /// `user_pubkey` or it is dropped (see [`Self::rejected`]).
57    pub fn build(
58        user_pubkey: &[u8; 32],
59        events: &[ChainEvent],
60        journal: &[JournalRecord],
61        cert_files: &[ProjectCert],
62    ) -> Self {
63        Self::build_with(&UserKeyHistory::single(user_pubkey), events, journal, cert_files)
64    }
65
66    /// [`Self::build`] under a user-key rotation history: a certificate sealed
67    /// by a retired user key counts when it is dated at or before that key's
68    /// rotation point (ADR-103 A13); one dated after it is dropped.
69    pub fn build_with(
70        trust: &UserKeyHistory,
71        events: &[ChainEvent],
72        journal: &[JournalRecord],
73        cert_files: &[ProjectCert],
74    ) -> Self {
75        let mut v = Self {
76            trust: trust.clone(),
77            anchored: HashMap::new(),
78            rotated_seq: HashMap::new(),
79            projects: HashMap::new(),
80            key_owner: HashMap::new(),
81            rejected: 0,
82        };
83        v.index_chain(events);
84        for rec in journal {
85            match rec {
86                JournalRecord::Register { cert } => v.ingest_cert(cert),
87                JournalRecord::Rekey { old_key_id, cert } => {
88                    v.ingest_revoke(&cert.project_id, old_key_id);
89                    v.ingest_retire(&cert.project_id, old_key_id);
90                    v.ingest_cert(cert);
91                }
92                JournalRecord::Revoke { project_id, key_id } => v.ingest_revoke(project_id, key_id),
93                JournalRecord::Init {} => {}
94            }
95        }
96        for c in cert_files {
97            v.ingest_cert(c);
98        }
99        for e in events {
100            v.ingest_event(e);
101        }
102        v
103    }
104
105    /// Record where the chain holds each certificate and each rotation, the
106    /// evidence a retired-key certificate needs (see [`Self::verify_historic`]).
107    fn index_chain(&mut self, events: &[ChainEvent]) {
108        for ev in events.iter().filter(|e| e.source == SOURCE) {
109            let Some(p) = ev.payload.as_ref() else { continue };
110            match ev.kind.as_str() {
111                KIND_REGISTER | KIND_REKEY => {
112                    let field = if ev.kind == KIND_REGISTER { "cert" } else { "new_cert" };
113                    if let Some(c) = p.get(field).and_then(|c| serde_json::from_value::<ProjectCert>(c.clone()).ok()) {
114                        self.anchored.entry(c.sig).or_insert(ev.sequence);
115                    }
116                }
117                k if k == KIND_ROTATED => {
118                    if let Some(old) = p.get("record").and_then(|r| r.get("old_key_id")).and_then(|v| v.as_str()) {
119                        self.rotated_seq.entry(old.to_owned()).or_insert(ev.sequence);
120                    }
121                }
122                _ => {}
123            }
124        }
125    }
126
127    fn ingest_event(&mut self, ev: &ChainEvent) {
128        if ev.source != SOURCE {
129            return;
130        }
131        let Some(p) = ev.payload.as_ref() else { return };
132        let cert_of = |field: &str| {
133            p.get(field)
134                .and_then(|c| serde_json::from_value::<ProjectCert>(c.clone()).ok())
135        };
136        let text = |field: &str| p.get(field).and_then(|v| v.as_str());
137        match ev.kind.as_str() {
138            KIND_REGISTER => {
139                if let Some(c) = cert_of("cert") {
140                    self.ingest_cert(&c);
141                }
142            }
143            KIND_REKEY => {
144                if let (Some(id), Some(old)) = (text("project_id"), text("old_key_id")) {
145                    self.ingest_revoke(id, old);
146                    self.ingest_retire(id, old);
147                }
148                if let Some(c) = cert_of("new_cert") {
149                    self.ingest_cert(&c);
150                }
151            }
152            KIND_REVOKE => {
153                if let (Some(id), Some(old)) = (text("project_id"), text("old_key_id")) {
154                    self.ingest_revoke(id, old);
155                }
156            }
157            _ => {}
158        }
159    }
160
161    fn ingest_cert(&mut self, c: &ProjectCert) {
162        if let Some(cause) = self.rejection_cause(c) {
163            // The view is rebuilt on every identity call: warn once per
164            // (project, serial, cause), not on each rebuild.
165            static SEEN: std::sync::Mutex<Option<HashSet<(String, u64, String)>>> = std::sync::Mutex::new(None);
166            let first = SEEN
167                .lock()
168                .unwrap_or_else(|e| e.into_inner())
169                .get_or_insert_with(HashSet::new)
170                .insert((c.project_id.clone(), c.serial, cause.clone()));
171            if first {
172                tracing::warn!(
173                    project = %c.project_id,
174                    user_key = %c.user_key_id,
175                    serial = c.serial,
176                    "project certificate rejected: {cause}"
177                );
178            }
179            self.rejected += 1;
180            return;
181        }
182        self.key_owner
183            .entry(c.project_key_id.clone())
184            .or_insert_with(|| c.project_id.clone());
185        let st = self.projects.entry(c.project_id.clone()).or_default();
186        if !st.certs.iter().any(|x| x.sig == c.sig) {
187            st.certs.push(c.clone());
188        }
189    }
190
191    /// Why `c` cannot be trusted, or `None` when it can.
192    ///
193    /// Signature and shape under the user key that sealed `c`. A certificate
194    /// sealed by the key in use needs nothing more. One sealed by a RETIRED
195    /// key counts only when it is dated at or before the rotation point AND
196    /// the user chain proves it: a `project.register`/`project.rekey` event
197    /// carrying it has a sequence below the `user.key.rotated` event of that
198    /// key. A journal line or certificate file alone, however it is dated,
199    /// is not evidence (whoever holds the old private key can backdate; only
200    /// the daemon appends to the reserved chain source).
201    pub(crate) fn rejection_cause(&self, c: &ProjectCert) -> Option<String> {
202        if let Err(e) = verify_cert_historic(c, &self.trust) {
203            return Some(format!("it does not verify under the user key history ({e})"));
204        }
205        if c.user_key_id == key_id(self.trust.current()) {
206            return None;
207        }
208        let anchored = self.anchored.get(&c.sig);
209        let rotated = self.rotated_seq.get(&c.user_key_id);
210        match (anchored, rotated) {
211            (Some(s), Some(r)) if s < r => None,
212            _ => Some(format!(
213                "it is sealed by retired user key {} but the user chain does not corroborate it \
214                 (certificate event at chain seq {anchored:?}, key rotated at chain seq {rotated:?}); \
215                 a journal line or certificate file alone is not evidence",
216                c.user_key_id
217            )),
218        }
219    }
220
221    fn ingest_revoke(&mut self, project_id: &str, key_id: &str) {
222        self.key_owner
223            .entry(key_id.to_owned())
224            .or_insert_with(|| project_id.to_owned());
225        self.projects
226            .entry(project_id.to_owned())
227            .or_default()
228            .revoked
229            .insert(key_id.to_owned());
230    }
231
232    fn ingest_retire(&mut self, project_id: &str, key_id: &str) {
233        self.projects
234            .entry(project_id.to_owned())
235            .or_default()
236            .retired
237            .insert(key_id.to_owned());
238    }
239
240    /// Every project id the view knows (certified, rekeyed or revoked).
241    pub fn all_project_ids(&self) -> Vec<String> {
242        let mut v: Vec<String> = self.projects.keys().cloned().collect();
243        v.sort();
244        v
245    }
246
247    /// Every certificate ever issued for `project_id` whose key was not
248    /// revoked for compromise: the current key and keys replaced by a
249    /// rekey. A key removed by `project.revoke` is excluded. Statements the
250    /// project signed with these keys are history, not forgeries.
251    pub fn key_history(&self, project_id: &str) -> Vec<&ProjectCert> {
252        self.projects.get(project_id).map_or_else(Vec::new, |st| {
253            st.certs
254                .iter()
255                .filter(|c| {
256                    !st.revoked.contains(&c.project_key_id) || st.retired.contains(&c.project_key_id)
257                })
258                .collect()
259        })
260    }
261
262    /// Was `key_id` removed by `project.revoke` (not replaced by a rekey)?
263    pub fn is_compromised(&self, project_id: &str, key_id: &str) -> bool {
264        self.projects
265            .get(project_id)
266            .is_some_and(|st| st.revoked.contains(key_id) && !st.retired.contains(key_id))
267    }
268
269    /// Every certificate ever issued for `project_id`, compromised keys
270    /// included. For re-verifying records the daemon itself accepted while
271    /// the key was valid.
272    pub fn all_certs(&self, project_id: &str) -> Vec<&ProjectCert> {
273        self.projects.get(project_id).map_or_else(Vec::new, |st| st.certs.iter().collect())
274    }
275
276    /// The state as journal records, for rebuilding a lost journal: every
277    /// verified certificate, then every revocation this view knows. A
278    /// revocation that only the lost journal remembered cannot be recovered.
279    pub fn export_records(&self) -> Vec<JournalRecord> {
280        let mut ids: Vec<&String> = self.projects.keys().collect();
281        ids.sort();
282        let mut out = Vec::new();
283        for id in ids {
284            let st = &self.projects[id];
285            let mut certs: Vec<&ProjectCert> = st.certs.iter().collect();
286            certs.sort_by_key(|c| c.serial);
287            out.extend(certs.into_iter().map(|c| JournalRecord::Register { cert: c.clone() }));
288            let mut rev: Vec<&String> = st.revoked.iter().collect();
289            rev.sort();
290            out.extend(rev.into_iter().map(|k| JournalRecord::Revoke {
291                project_id: id.clone(),
292                key_id: k.clone(),
293            }));
294        }
295        out
296    }
297
298    /// Certificates dropped because they did not verify against the user key.
299    pub fn rejected(&self) -> usize {
300        self.rejected
301    }
302
303    /// Is this key revoked for this project?
304    pub fn is_revoked(&self, project_id: &str, key_id: &str) -> bool {
305        self.projects
306            .get(project_id)
307            .is_some_and(|s| s.revoked.contains(key_id))
308    }
309
310    /// True when `project.revoke` revoked a key of this project (a key only
311    /// replaced by `project.rekey` does not count).
312    pub fn was_revoked(&self, project_id: &str) -> bool {
313        self.projects
314            .get(project_id)
315            .is_some_and(|s| s.revoked.iter().any(|k| !s.retired.contains(k)))
316    }
317
318    /// The certificate in force: the highest-serial one whose key is not
319    /// revoked.
320    pub fn current_cert(&self, project_id: &str) -> Option<&ProjectCert> {
321        let st = self.projects.get(project_id)?;
322        st.certs
323            .iter()
324            .filter(|c| !st.revoked.contains(&c.project_key_id))
325            .max_by_key(|c| c.serial)
326    }
327
328    /// The `key_id` currently certified for `project_id`.
329    pub fn bound_key_id(&self, project_id: &str) -> Option<&str> {
330        self.current_cert(project_id).map(|c| c.project_key_id.as_str())
331    }
332
333    /// Highest serial ever issued for `project_id` (0 when none).
334    pub fn last_serial(&self, project_id: &str) -> u64 {
335        self.projects
336            .get(project_id)
337            .and_then(|s| s.certs.iter().map(|c| c.serial).max())
338            .unwrap_or(0)
339    }
340
341    /// The project that first claimed `key_id`, certified or revoked.
342    pub fn key_owner(&self, key_id: &str) -> Option<&str> {
343        self.key_owner.get(key_id).map(String::as_str)
344    }
345
346    fn refuse_reuse(&self, project_id: &str, kid: &str) -> Result<(), IdentityError> {
347        match self.key_owner(kid) {
348            Some(o) if o != project_id => Err(IdentityError::KeyReuse {
349                key_id: kid.to_owned(),
350                other_project: o.to_owned(),
351            }),
352            _ => Ok(()),
353        }
354    }
355
356    /// Refuse a certificate whose key was revoked or replaced.
357    pub fn check_cert(&self, cert: &ProjectCert) -> Result<(), IdentityError> {
358        let id = &cert.project_id;
359        if self.is_revoked(id, &cert.project_key_id) {
360            return Err(IdentityError::KeyRevoked {
361                project_id: id.clone(),
362                key_id: cert.project_key_id.clone(),
363            });
364        }
365        match self.bound_key_id(id) {
366            Some(b) if b != cert.project_key_id => Err(IdentityError::KeyConflict {
367                project_id: id.clone(),
368                bound: b.to_owned(),
369            }),
370            _ => Ok(()),
371        }
372    }
373
374    /// TOFU decision for `project.register` of `project_pubkey`.
375    pub fn plan_registration(
376        &self,
377        project_id: &str,
378        project_pubkey: &[u8; 32],
379    ) -> Result<Registration, IdentityError> {
380        let kid = key_id(project_pubkey);
381        self.refuse_reuse(project_id, &kid)?;
382        if self.is_revoked(project_id, &kid) {
383            return Err(IdentityError::KeyRevoked {
384                project_id: project_id.to_owned(),
385                key_id: kid,
386            });
387        }
388        // Review S1: a revoked project never re-enrols, whatever the marker
389        // says (a revoke whose marker could not be written must not be
390        // undone by the next `ensure_running`). A key replaced by `rekey` is
391        // retired, not revoked in this sense.
392        if self.current_cert(project_id).is_none() && self.was_revoked(project_id) {
393            return Err(IdentityError::ProjectRevoked(project_id.to_owned()));
394        }
395        match self.current_cert(project_id) {
396            Some(c) if c.project_key_id == kid => Ok(Registration::Existing(Box::new(c.clone()))),
397            Some(c) => Err(IdentityError::KeyConflict {
398                project_id: project_id.to_owned(),
399                bound: c.project_key_id.clone(),
400            }),
401            None => Ok(Registration::New {
402                serial: self.last_serial(project_id) + 1,
403            }),
404        }
405    }
406
407    /// Rekey decision: the project must have a certified key, and the new
408    /// one must differ from it, never have been revoked and not belong to
409    /// another project. Returns the old `key_id` and the next serial.
410    pub fn plan_rekey(
411        &self,
412        project_id: &str,
413        new_pubkey: &[u8; 32],
414    ) -> Result<(String, u64), IdentityError> {
415        let old = self
416            .bound_key_id(project_id)
417            .ok_or_else(|| IdentityError::NotBound(project_id.to_owned()))?
418            .to_owned();
419        let new = key_id(new_pubkey);
420        self.refuse_reuse(project_id, &new)?;
421        if new == old {
422            return Err(IdentityError::KeyConflict {
423                project_id: project_id.to_owned(),
424                bound: old,
425            });
426        }
427        if self.is_revoked(project_id, &new) {
428            return Err(IdentityError::KeyRevoked {
429                project_id: project_id.to_owned(),
430                key_id: new,
431            });
432        }
433        Ok((old, self.last_serial(project_id) + 1))
434    }
435}
436