1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Registration {
17 Existing(Box<ProjectCert>),
19 New {
22 serial: u64,
24 },
25}
26
27#[derive(Debug, Default, Clone)]
28struct ProjectIdentity {
29 certs: Vec<ProjectCert>,
30 revoked: HashSet<String>,
31 retired: HashSet<String>,
34}
35
36#[derive(Debug, Clone)]
40pub struct RevocationView {
41 trust: UserKeyHistory,
42 anchored: HashMap<String, u64>,
45 rotated_seq: HashMap<String, u64>,
47 projects: HashMap<String, ProjectIdentity>,
48 key_owner: HashMap<String, String>,
50 rejected: usize,
51}
52
53impl RevocationView {
54 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 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 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 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 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 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 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 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 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 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 pub fn rejected(&self) -> usize {
300 self.rejected
301 }
302
303 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 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 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 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 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 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 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 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 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 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