1use std::collections::HashMap;
49
50use crate::crypto::{SigningKey, VerifyingKey};
51use crate::types::AuthorId;
52use crate::{AionError, Result};
53
54pub(crate) const ROTATION_DOMAIN: &[u8] = b"AION_V2_ROTATION_V1";
56
57pub(crate) const REVOCATION_DOMAIN: &[u8] = b"AION_V2_REVOCATION_V1";
59
60#[repr(u16)]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum RevocationReason {
64 Compromised = 1,
66 Superseded = 2,
68 Retired = 3,
70 Unspecified = 255,
72}
73
74impl RevocationReason {
75 pub fn from_u16(value: u16) -> Result<Self> {
84 match value {
85 1 => Ok(Self::Compromised),
86 2 => Ok(Self::Superseded),
87 3 => Ok(Self::Retired),
88 255 => Ok(Self::Unspecified),
89 other => Err(AionError::InvalidFormat {
90 reason: format!("Unknown revocation reason: {other}"),
91 }),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum KeyStatus {
99 Active,
101 Rotated {
103 successor_epoch: u32,
105 effective_from_version: u64,
107 },
108 Revoked {
110 reason: RevocationReason,
112 effective_from_version: u64,
114 },
115}
116
117#[derive(Debug, Clone)]
119pub struct KeyEpoch {
120 pub author_id: AuthorId,
122 pub epoch: u32,
124 pub public_key: [u8; 32],
126 pub created_at_version: u64,
128 pub status: KeyStatus,
130}
131
132impl KeyEpoch {
133 #[must_use]
137 pub const fn is_valid_for(&self, version_number: u64) -> bool {
138 if version_number < self.created_at_version {
139 return false;
140 }
141 match self.status {
142 KeyStatus::Active => true,
143 KeyStatus::Rotated {
144 effective_from_version,
145 ..
146 }
147 | KeyStatus::Revoked {
148 effective_from_version,
149 ..
150 } => version_number < effective_from_version,
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct KeyRotationRecord {
158 pub author_id: AuthorId,
160 pub from_epoch: u32,
162 pub to_epoch: u32,
164 pub to_public_key: [u8; 32],
166 pub effective_from_version: u64,
168 pub master_signature: [u8; 64],
171}
172
173#[derive(Debug, Clone)]
175pub struct RevocationRecord {
176 pub author_id: AuthorId,
178 pub revoked_epoch: u32,
180 pub reason: RevocationReason,
182 pub effective_from_version: u64,
184 pub master_signature: [u8; 64],
187}
188
189#[must_use]
192#[allow(clippy::arithmetic_side_effects)] pub fn canonical_rotation_message(record: &KeyRotationRecord) -> Vec<u8> {
194 let mut msg = Vec::with_capacity(ROTATION_DOMAIN.len() + 8 + 4 + 4 + 32 + 8);
195 msg.extend_from_slice(ROTATION_DOMAIN);
196 msg.extend_from_slice(&record.author_id.as_u64().to_le_bytes());
197 msg.extend_from_slice(&record.from_epoch.to_le_bytes());
198 msg.extend_from_slice(&record.to_epoch.to_le_bytes());
199 msg.extend_from_slice(&record.to_public_key);
200 msg.extend_from_slice(&record.effective_from_version.to_le_bytes());
201 msg
202}
203
204#[must_use]
207#[allow(clippy::arithmetic_side_effects)] pub fn canonical_revocation_message(record: &RevocationRecord) -> Vec<u8> {
209 let mut msg = Vec::with_capacity(REVOCATION_DOMAIN.len() + 8 + 4 + 2 + 8);
210 msg.extend_from_slice(REVOCATION_DOMAIN);
211 msg.extend_from_slice(&record.author_id.as_u64().to_le_bytes());
212 msg.extend_from_slice(&record.revoked_epoch.to_le_bytes());
213 msg.extend_from_slice(&(record.reason as u16).to_le_bytes());
214 msg.extend_from_slice(&record.effective_from_version.to_le_bytes());
215 msg
216}
217
218#[must_use]
225pub fn sign_rotation_record(
226 author: AuthorId,
227 from_epoch: u32,
228 to_epoch: u32,
229 to_public_key: [u8; 32],
230 effective_from_version: u64,
231 master_key: &SigningKey,
232) -> KeyRotationRecord {
233 let mut record = KeyRotationRecord {
234 author_id: author,
235 from_epoch,
236 to_epoch,
237 to_public_key,
238 effective_from_version,
239 master_signature: [0u8; 64],
240 };
241 let message = canonical_rotation_message(&record);
242 record.master_signature = master_key.sign(&message);
243 record
244}
245
246#[must_use]
248pub fn sign_revocation_record(
249 author: AuthorId,
250 revoked_epoch: u32,
251 reason: RevocationReason,
252 effective_from_version: u64,
253 master_key: &SigningKey,
254) -> RevocationRecord {
255 let mut record = RevocationRecord {
256 author_id: author,
257 revoked_epoch,
258 reason,
259 effective_from_version,
260 master_signature: [0u8; 64],
261 };
262 let message = canonical_revocation_message(&record);
263 record.master_signature = master_key.sign(&message);
264 record
265}
266
267#[derive(Debug, Clone)]
270struct AuthorRecord {
271 master_key: VerifyingKey,
272 epochs: Vec<KeyEpoch>,
273}
274
275#[derive(Debug, Default)]
277pub struct KeyRegistry {
278 authors: HashMap<AuthorId, AuthorRecord>,
279}
280
281impl KeyRegistry {
282 #[must_use]
284 pub fn new() -> Self {
285 Self::default()
286 }
287
288 pub fn register_author(
295 &mut self,
296 author: AuthorId,
297 master_key: VerifyingKey,
298 initial_operational_key: VerifyingKey,
299 created_at_version: u64,
300 ) -> Result<()> {
301 if self.authors.contains_key(&author) {
302 return Err(AionError::InvalidFormat {
303 reason: format!("author {author} already registered"),
304 });
305 }
306 let epoch = KeyEpoch {
307 author_id: author,
308 epoch: 0,
309 public_key: initial_operational_key.to_bytes(),
310 created_at_version,
311 status: KeyStatus::Active,
312 };
313 let record = AuthorRecord {
314 master_key,
315 epochs: vec![epoch],
316 };
317 self.authors.insert(author, record);
318 Ok(())
319 }
320
321 pub fn apply_rotation(&mut self, record: &KeyRotationRecord) -> Result<()> {
333 let author_record =
334 self.authors
335 .get_mut(&record.author_id)
336 .ok_or_else(|| AionError::InvalidFormat {
337 reason: format!("author {} not registered", record.author_id),
338 })?;
339 let active_epoch_number = validate_rotation_preconditions(record, &author_record.epochs)?;
340 let message = canonical_rotation_message(record);
341 author_record
342 .master_key
343 .verify(&message, &record.master_signature)?;
344 mark_epoch_rotated(
345 &mut author_record.epochs,
346 active_epoch_number,
347 record.to_epoch,
348 record.effective_from_version,
349 );
350 author_record.epochs.push(KeyEpoch {
351 author_id: record.author_id,
352 epoch: record.to_epoch,
353 public_key: record.to_public_key,
354 created_at_version: record.effective_from_version,
355 status: KeyStatus::Active,
356 });
357 Ok(())
358 }
359
360 pub fn apply_revocation(&mut self, record: &RevocationRecord) -> Result<()> {
367 let author_record =
368 self.authors
369 .get_mut(&record.author_id)
370 .ok_or_else(|| AionError::InvalidFormat {
371 reason: format!("author {} not registered", record.author_id),
372 })?;
373
374 let message = canonical_revocation_message(record);
375 author_record
376 .master_key
377 .verify(&message, &record.master_signature)?;
378
379 let mut updated = false;
380 for epoch in &mut author_record.epochs {
381 if epoch.epoch != record.revoked_epoch {
382 continue;
383 }
384 if matches!(epoch.status, KeyStatus::Revoked { .. }) {
385 return Err(AionError::InvalidFormat {
386 reason: format!(
387 "epoch {} for author {} already revoked",
388 record.revoked_epoch, record.author_id
389 ),
390 });
391 }
392 epoch.status = KeyStatus::Revoked {
393 reason: record.reason,
394 effective_from_version: record.effective_from_version,
395 };
396 updated = true;
397 break;
398 }
399 if !updated {
400 return Err(AionError::InvalidFormat {
401 reason: format!(
402 "epoch {} not found for author {}",
403 record.revoked_epoch, record.author_id
404 ),
405 });
406 }
407 Ok(())
408 }
409
410 #[must_use]
413 pub fn active_epoch_at(&self, author: AuthorId, version_number: u64) -> Option<&KeyEpoch> {
414 let record = self.authors.get(&author)?;
415 record
416 .epochs
417 .iter()
418 .find(|epoch| epoch.is_valid_for(version_number))
419 }
420
421 #[must_use]
423 pub fn master_key(&self, author: AuthorId) -> Option<&VerifyingKey> {
424 self.authors.get(&author).map(|record| &record.master_key)
425 }
426
427 #[must_use]
429 pub fn epochs_for(&self, author: AuthorId) -> &[KeyEpoch] {
430 self.authors
431 .get(&author)
432 .map_or(&[][..], |record| record.epochs.as_slice())
433 }
434
435 pub fn insert_epoch_unchecked(
459 &mut self,
460 author: AuthorId,
461 epoch: u32,
462 public_key: [u8; 32],
463 active_from_version: u64,
464 ) -> Result<()> {
465 let record = self
466 .authors
467 .get_mut(&author)
468 .ok_or_else(|| AionError::InvalidFormat {
469 reason: format!("author {author} not registered"),
470 })?;
471 let max_epoch = record.epochs.iter().map(|e| e.epoch).max().unwrap_or(0);
472 if epoch <= max_epoch {
473 return Err(AionError::InvalidFormat {
474 reason: format!(
475 "epoch {epoch} not strictly greater than existing max {max_epoch} for author {author}"
476 ),
477 });
478 }
479 let active = find_active_epoch(&record.epochs).ok_or_else(|| AionError::InvalidFormat {
480 reason: format!("author {author} has no active epoch to rotate from"),
481 })?;
482 if active_from_version <= active.created_at_version {
483 return Err(AionError::InvalidFormat {
484 reason: format!(
485 "active_from_version {active_from_version} does not strictly follow prior epoch at version {}",
486 active.created_at_version
487 ),
488 });
489 }
490 let active_epoch_number = active.epoch;
491 mark_epoch_rotated(
492 &mut record.epochs,
493 active_epoch_number,
494 epoch,
495 active_from_version,
496 );
497 record.epochs.push(KeyEpoch {
498 author_id: author,
499 epoch,
500 public_key,
501 created_at_version: active_from_version,
502 status: KeyStatus::Active,
503 });
504 Ok(())
505 }
506
507 pub fn insert_revocation_unchecked(
518 &mut self,
519 author: AuthorId,
520 epoch: u32,
521 reason: RevocationReason,
522 effective_from_version: u64,
523 ) -> Result<()> {
524 let record = self
525 .authors
526 .get_mut(&author)
527 .ok_or_else(|| AionError::InvalidFormat {
528 reason: format!("author {author} not registered"),
529 })?;
530 for existing in &mut record.epochs {
531 if existing.epoch != epoch {
532 continue;
533 }
534 if matches!(existing.status, KeyStatus::Revoked { .. }) {
535 return Err(AionError::InvalidFormat {
536 reason: format!("epoch {epoch} for author {author} already revoked"),
537 });
538 }
539 existing.status = KeyStatus::Revoked {
540 reason,
541 effective_from_version,
542 };
543 return Ok(());
544 }
545 Err(AionError::InvalidFormat {
546 reason: format!("epoch {epoch} not found for author {author}"),
547 })
548 }
549
550 pub fn from_trusted_json(input: &str) -> Result<Self> {
584 let file: TrustedRegistryFile =
585 serde_json::from_str(input).map_err(|e| AionError::InvalidFormat {
586 reason: format!("registry JSON parse failed: {e}"),
587 })?;
588 if file.version != 1 {
589 return Err(AionError::InvalidFormat {
590 reason: format!(
591 "unsupported registry file version: {} (expected 1)",
592 file.version
593 ),
594 });
595 }
596 let mut registry = Self::new();
597 for author_entry in file.authors {
598 registry.load_trusted_author(author_entry)?;
599 }
600 Ok(registry)
601 }
602
603 fn load_trusted_author(&mut self, entry: TrustedAuthorEntry) -> Result<()> {
604 let author = AuthorId::new(entry.author_id);
605 let master_bytes = decode_registry_key_bytes(&entry.master_key, "master_key")?;
606 let master_key = VerifyingKey::from_bytes(&master_bytes)?;
607 let first_epoch = entry
608 .epochs
609 .first()
610 .ok_or_else(|| AionError::InvalidFormat {
611 reason: format!("author {author} has no epochs"),
612 })?;
613 let first_pub = decode_registry_key_bytes(&first_epoch.public_key, "public_key")?;
614 let first_pub_key = VerifyingKey::from_bytes(&first_pub)?;
615 self.register_author(
616 author,
617 master_key,
618 first_pub_key,
619 first_epoch.active_from_version,
620 )?;
621 if first_epoch.epoch != 0 {
622 return Err(AionError::InvalidFormat {
623 reason: format!(
624 "first epoch for author {author} must be 0, got {}",
625 first_epoch.epoch
626 ),
627 });
628 }
629 for subsequent in entry.epochs.iter().skip(1) {
630 let pub_bytes = decode_registry_key_bytes(&subsequent.public_key, "public_key")?;
631 self.insert_epoch_unchecked(
632 author,
633 subsequent.epoch,
634 pub_bytes,
635 subsequent.active_from_version,
636 )?;
637 }
638 for rev in entry.revocations {
639 self.insert_revocation_unchecked(
640 author,
641 rev.epoch,
642 rev.reason,
643 rev.effective_from_version,
644 )?;
645 }
646 Ok(())
647 }
648
649 pub fn to_trusted_json(&self) -> Result<String> {
660 let mut authors: Vec<(&AuthorId, &AuthorRecord)> = self.authors.iter().collect();
661 authors.sort_by_key(|(id, _)| id.as_u64());
662 let mut entries = Vec::with_capacity(authors.len());
663 for (author, record) in authors {
664 entries.push(serialize_author_entry(*author, record));
665 }
666 let file = TrustedRegistryFile {
667 version: 1,
668 authors: entries,
669 };
670 serde_json::to_string_pretty(&file).map_err(|e| AionError::InvalidFormat {
671 reason: format!("registry JSON serialize failed: {e}"),
672 })
673 }
674}
675
676fn serialize_author_entry(author: AuthorId, record: &AuthorRecord) -> TrustedAuthorEntry {
677 use base64::Engine;
678 let engine = base64::engine::general_purpose::STANDARD;
679 let mut sorted_epochs: Vec<&KeyEpoch> = record.epochs.iter().collect();
680 sorted_epochs.sort_by_key(|e| e.epoch);
681 let mut epochs = Vec::with_capacity(sorted_epochs.len());
682 let mut revocations = Vec::new();
683 for epoch in sorted_epochs {
684 let status = match epoch.status {
685 KeyStatus::Active => TrustedEpochStatus::Active,
686 KeyStatus::Rotated { .. } => TrustedEpochStatus::Rotated,
687 KeyStatus::Revoked { .. } => TrustedEpochStatus::Revoked,
688 };
689 epochs.push(TrustedEpochEntry {
690 epoch: epoch.epoch,
691 public_key: engine.encode(epoch.public_key),
692 active_from_version: epoch.created_at_version,
693 status: Some(status),
694 });
695 if let KeyStatus::Revoked {
696 reason,
697 effective_from_version,
698 } = epoch.status
699 {
700 revocations.push(TrustedRevocationEntry {
701 epoch: epoch.epoch,
702 reason,
703 effective_from_version,
704 });
705 }
706 }
707 TrustedAuthorEntry {
708 author_id: author.as_u64(),
709 master_key: engine.encode(record.master_key.to_bytes()),
710 epochs,
711 revocations,
712 }
713}
714
715fn decode_registry_key_bytes(encoded: &str, field: &str) -> Result<[u8; 32]> {
716 use base64::Engine;
717 let bytes = base64::engine::general_purpose::STANDARD
718 .decode(encoded)
719 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded))
720 .map_err(|e| AionError::InvalidFormat {
721 reason: format!("registry {field} base64 decode failed: {e}"),
722 })?;
723 <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| AionError::InvalidFormat {
724 reason: format!(
725 "registry {field} must decode to exactly 32 bytes (got {})",
726 bytes.len()
727 ),
728 })
729}
730
731#[derive(serde::Deserialize, serde::Serialize)]
732struct TrustedRegistryFile {
733 version: u32,
734 authors: Vec<TrustedAuthorEntry>,
735}
736
737#[derive(serde::Deserialize, serde::Serialize)]
738struct TrustedAuthorEntry {
739 author_id: u64,
740 master_key: String,
741 epochs: Vec<TrustedEpochEntry>,
742 #[serde(default, skip_serializing_if = "Vec::is_empty")]
743 revocations: Vec<TrustedRevocationEntry>,
744}
745
746#[derive(serde::Deserialize, serde::Serialize)]
747struct TrustedEpochEntry {
748 epoch: u32,
749 public_key: String,
750 active_from_version: u64,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
762 status: Option<TrustedEpochStatus>,
763}
764
765#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy, PartialEq, Eq)]
769#[serde(rename_all = "snake_case")]
770enum TrustedEpochStatus {
771 Active,
772 Rotated,
773 Revoked,
774}
775
776#[derive(serde::Deserialize, serde::Serialize)]
777struct TrustedRevocationEntry {
778 epoch: u32,
779 reason: RevocationReason,
780 effective_from_version: u64,
781}
782
783fn find_active_epoch(epochs: &[KeyEpoch]) -> Option<&KeyEpoch> {
784 epochs
785 .iter()
786 .find(|epoch| matches!(epoch.status, KeyStatus::Active))
787}
788
789fn validate_rotation_preconditions(record: &KeyRotationRecord, epochs: &[KeyEpoch]) -> Result<u32> {
795 let current_active = find_active_epoch(epochs).ok_or_else(|| AionError::InvalidFormat {
796 reason: format!("author {} has no active epoch", record.author_id),
797 })?;
798 if current_active.epoch != record.from_epoch {
799 return Err(AionError::InvalidFormat {
800 reason: format!(
801 "rotation from_epoch {} does not match current active epoch {}",
802 record.from_epoch, current_active.epoch
803 ),
804 });
805 }
806 let expected_to =
807 current_active
808 .epoch
809 .checked_add(1)
810 .ok_or_else(|| AionError::InvalidFormat {
811 reason: "epoch counter overflow".to_string(),
812 })?;
813 if record.to_epoch != expected_to {
814 return Err(AionError::InvalidFormat {
815 reason: format!(
816 "rotation to_epoch {} must be {} (from_epoch + 1)",
817 record.to_epoch, expected_to
818 ),
819 });
820 }
821 if record.effective_from_version < current_active.created_at_version {
822 return Err(AionError::InvalidFormat {
823 reason: "rotation effective_from_version precedes active epoch".to_string(),
824 });
825 }
826 Ok(current_active.epoch)
827}
828
829fn mark_epoch_rotated(
832 epochs: &mut [KeyEpoch],
833 current_active_epoch_number: u32,
834 successor_epoch: u32,
835 effective_from_version: u64,
836) {
837 for epoch in epochs.iter_mut() {
838 if epoch.epoch == current_active_epoch_number {
839 epoch.status = KeyStatus::Rotated {
840 successor_epoch,
841 effective_from_version,
842 };
843 }
844 }
845}
846
847#[cfg(test)]
848#[allow(
849 clippy::unwrap_used,
850 clippy::indexing_slicing,
851 clippy::arithmetic_side_effects
852)]
853mod tests {
854 use super::*;
855
856 fn setup() -> (AuthorId, SigningKey, SigningKey, KeyRegistry) {
857 let author = AuthorId::new(42);
858 let master = SigningKey::generate();
859 let op0 = SigningKey::generate();
860 let mut reg = KeyRegistry::new();
861 reg.register_author(author, master.verifying_key(), op0.verifying_key(), 0)
862 .unwrap();
863 (author, master, op0, reg)
864 }
865
866 #[test]
867 fn should_register_and_resolve_initial_epoch() {
868 let (author, _, op0, reg) = setup();
869 let epoch = reg.active_epoch_at(author, 1).unwrap();
870 assert_eq!(epoch.epoch, 0);
871 assert_eq!(epoch.public_key, op0.verifying_key().to_bytes());
872 }
873
874 #[test]
875 fn should_reject_double_registration() {
876 let (author, master, op0, mut reg) = setup();
877 let result = reg.register_author(author, master.verifying_key(), op0.verifying_key(), 0);
878 assert!(result.is_err());
879 }
880
881 #[test]
882 fn should_apply_rotation_and_track_boundaries() {
883 let (author, master, _op0, mut reg) = setup();
884 let op1 = SigningKey::generate();
885 let rec = sign_rotation_record(author, 0, 1, op1.verifying_key().to_bytes(), 10, &master);
886 reg.apply_rotation(&rec).unwrap();
887
888 let at_v1 = reg.active_epoch_at(author, 1).unwrap();
889 assert_eq!(at_v1.epoch, 0);
890 let at_v10 = reg.active_epoch_at(author, 10).unwrap();
891 assert_eq!(at_v10.epoch, 1);
892 assert_eq!(at_v10.public_key, op1.verifying_key().to_bytes());
893 }
894
895 #[test]
896 fn should_reject_rotation_with_wrong_from_epoch() {
897 let (author, master, _op0, mut reg) = setup();
898 let op1 = SigningKey::generate();
899 let rec = sign_rotation_record(
900 author,
901 5, 6,
903 op1.verifying_key().to_bytes(),
904 10,
905 &master,
906 );
907 assert!(reg.apply_rotation(&rec).is_err());
908 }
909
910 #[test]
911 fn should_reject_rotation_with_wrong_master_signature() {
912 let (author, _master, _op0, mut reg) = setup();
913 let other_master = SigningKey::generate();
914 let op1 = SigningKey::generate();
915 let rec = sign_rotation_record(
916 author,
917 0,
918 1,
919 op1.verifying_key().to_bytes(),
920 10,
921 &other_master,
922 );
923 assert!(reg.apply_rotation(&rec).is_err());
924 }
925
926 #[test]
927 fn should_apply_revocation_and_invalidate_epoch() {
928 let (author, master, _op0, mut reg) = setup();
929 let rec = sign_revocation_record(author, 0, RevocationReason::Compromised, 10, &master);
930 reg.apply_revocation(&rec).unwrap();
931
932 let at_v1 = reg.active_epoch_at(author, 1).unwrap();
933 assert_eq!(at_v1.epoch, 0);
934 assert!(reg.active_epoch_at(author, 10).is_none());
935 }
936
937 #[test]
938 fn should_reject_double_revocation() {
939 let (author, master, _op0, mut reg) = setup();
940 let rec = sign_revocation_record(author, 0, RevocationReason::Compromised, 10, &master);
941 reg.apply_revocation(&rec).unwrap();
942 assert!(reg.apply_revocation(&rec).is_err());
943 }
944
945 #[test]
946 fn should_reject_revocation_of_unknown_epoch() {
947 let (author, master, _op0, mut reg) = setup();
948 let rec = sign_revocation_record(author, 99, RevocationReason::Retired, 10, &master);
949 assert!(reg.apply_revocation(&rec).is_err());
950 }
951
952 #[test]
953 fn revocation_reason_from_u16_round_trips() {
954 assert_eq!(
955 RevocationReason::from_u16(1).unwrap(),
956 RevocationReason::Compromised
957 );
958 assert_eq!(
959 RevocationReason::from_u16(255).unwrap(),
960 RevocationReason::Unspecified
961 );
962 assert!(RevocationReason::from_u16(7).is_err());
963 }
964
965 mod properties {
966 use super::*;
967 use hegel::generators as gs;
968
969 fn register_author(
970 tc: &hegel::TestCase,
971 ) -> (AuthorId, SigningKey, SigningKey, KeyRegistry) {
972 let author = AuthorId::new(tc.draw(gs::integers::<u64>().min_value(1)));
973 let master = SigningKey::generate();
974 let op0 = SigningKey::generate();
975 let mut reg = KeyRegistry::new();
976 reg.register_author(author, master.verifying_key(), op0.verifying_key(), 0)
977 .unwrap_or_else(|_| std::process::abort());
978 (author, master, op0, reg)
979 }
980
981 #[hegel::test]
982 fn prop_register_and_verify_active(tc: hegel::TestCase) {
983 let (author, _master, op0, reg) = register_author(&tc);
984 let v = tc.draw(gs::integers::<u64>().min_value(0).max_value(1 << 40));
985 let epoch = reg
986 .active_epoch_at(author, v)
987 .unwrap_or_else(|| std::process::abort());
988 assert_eq!(epoch.epoch, 0);
989 assert_eq!(epoch.public_key, op0.verifying_key().to_bytes());
990 }
991
992 #[hegel::test]
993 fn prop_sig_before_rotation_verifies(tc: hegel::TestCase) {
994 let (author, master, _op0, mut reg) = register_author(&tc);
995 let op1 = SigningKey::generate();
996 let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
997 let rec = sign_rotation_record(
998 author,
999 0,
1000 1,
1001 op1.verifying_key().to_bytes(),
1002 effective,
1003 &master,
1004 );
1005 reg.apply_rotation(&rec)
1006 .unwrap_or_else(|_| std::process::abort());
1007 let v = tc.draw(gs::integers::<u64>().max_value(effective.saturating_sub(1)));
1009 let epoch = reg
1010 .active_epoch_at(author, v)
1011 .unwrap_or_else(|| std::process::abort());
1012 assert_eq!(epoch.epoch, 0);
1013 }
1014
1015 #[hegel::test]
1016 fn prop_sig_after_rotation_switches_to_new_epoch(tc: hegel::TestCase) {
1017 let (author, master, _op0, mut reg) = register_author(&tc);
1018 let op1 = SigningKey::generate();
1019 let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
1020 let rec = sign_rotation_record(
1021 author,
1022 0,
1023 1,
1024 op1.verifying_key().to_bytes(),
1025 effective,
1026 &master,
1027 );
1028 reg.apply_rotation(&rec)
1029 .unwrap_or_else(|_| std::process::abort());
1030 let v = tc.draw(
1031 gs::integers::<u64>()
1032 .min_value(effective)
1033 .max_value(effective.saturating_add(1 << 20)),
1034 );
1035 let epoch = reg
1036 .active_epoch_at(author, v)
1037 .unwrap_or_else(|| std::process::abort());
1038 assert_eq!(epoch.epoch, 1);
1039 assert_eq!(epoch.public_key, op1.verifying_key().to_bytes());
1040 }
1041
1042 #[hegel::test]
1043 fn prop_revocation_rejects_later_sigs(tc: hegel::TestCase) {
1044 let (author, master, _op0, mut reg) = register_author(&tc);
1045 let effective = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
1046 let rec = sign_revocation_record(
1047 author,
1048 0,
1049 RevocationReason::Compromised,
1050 effective,
1051 &master,
1052 );
1053 reg.apply_revocation(&rec)
1054 .unwrap_or_else(|_| std::process::abort());
1055 let earlier = tc.draw(gs::integers::<u64>().max_value(effective.saturating_sub(1)));
1057 assert!(reg.active_epoch_at(author, earlier).is_some());
1058 let later = tc.draw(
1059 gs::integers::<u64>()
1060 .min_value(effective)
1061 .max_value(effective.saturating_add(1 << 20)),
1062 );
1063 assert!(reg.active_epoch_at(author, later).is_none());
1064 }
1065
1066 #[hegel::test]
1067 fn prop_rotation_requires_valid_master_sig(tc: hegel::TestCase) {
1068 let (author, master, _op0, mut reg) = register_author(&tc);
1069 let op1 = SigningKey::generate();
1070 let mut rec =
1071 sign_rotation_record(author, 0, 1, op1.verifying_key().to_bytes(), 5, &master);
1072 let idx = tc.draw(gs::integers::<usize>().max_value(rec.master_signature.len() - 1));
1074 if let Some(b) = rec.master_signature.get_mut(idx) {
1075 *b ^= 0x01;
1076 }
1077 assert!(reg.apply_rotation(&rec).is_err());
1078 }
1079
1080 #[hegel::test]
1081 fn prop_epochs_are_monotonic(tc: hegel::TestCase) {
1082 let (author, master, _op0, mut reg) = register_author(&tc);
1083 let n = tc.draw(gs::integers::<u32>().min_value(1).max_value(8));
1084 let mut effective: u64 = 0;
1085 for i in 0..n {
1086 effective = effective
1087 .saturating_add(tc.draw(gs::integers::<u64>().min_value(1).max_value(10_000)));
1088 let new_op = SigningKey::generate();
1089 let rec = sign_rotation_record(
1090 author,
1091 i,
1092 i.saturating_add(1),
1093 new_op.verifying_key().to_bytes(),
1094 effective,
1095 &master,
1096 );
1097 reg.apply_rotation(&rec)
1098 .unwrap_or_else(|_| std::process::abort());
1099 }
1100 let epochs = reg.epochs_for(author);
1101 for pair in epochs.windows(2) {
1102 assert!(pair[1].epoch == pair[0].epoch.saturating_add(1));
1103 assert!(pair[1].created_at_version >= pair[0].created_at_version);
1104 }
1105 }
1106
1107 #[hegel::test]
1108 fn prop_multi_hop_rotation_tracks_correctly(tc: hegel::TestCase) {
1109 let (author, master, op0, mut reg) = register_author(&tc);
1111 let op1 = SigningKey::generate();
1112 let op2 = SigningKey::generate();
1113 let v_a = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20));
1114 let v_b =
1115 v_a.saturating_add(tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 20)));
1116 let r1 =
1117 sign_rotation_record(author, 0, 1, op1.verifying_key().to_bytes(), v_a, &master);
1118 reg.apply_rotation(&r1)
1119 .unwrap_or_else(|_| std::process::abort());
1120 let r2 =
1121 sign_rotation_record(author, 1, 2, op2.verifying_key().to_bytes(), v_b, &master);
1122 reg.apply_rotation(&r2)
1123 .unwrap_or_else(|_| std::process::abort());
1124
1125 let in_first = tc.draw(gs::integers::<u64>().max_value(v_a.saturating_sub(1)));
1127 assert_eq!(
1128 reg.active_epoch_at(author, in_first)
1129 .unwrap_or_else(|| std::process::abort())
1130 .public_key,
1131 op0.verifying_key().to_bytes()
1132 );
1133 let in_second = tc.draw(
1134 gs::integers::<u64>()
1135 .min_value(v_a)
1136 .max_value(v_b.saturating_sub(1)),
1137 );
1138 assert_eq!(
1139 reg.active_epoch_at(author, in_second)
1140 .unwrap_or_else(|| std::process::abort())
1141 .public_key,
1142 op1.verifying_key().to_bytes()
1143 );
1144 let in_third = tc.draw(
1145 gs::integers::<u64>()
1146 .min_value(v_b)
1147 .max_value(v_b.saturating_add(1 << 20)),
1148 );
1149 assert_eq!(
1150 reg.active_epoch_at(author, in_third)
1151 .unwrap_or_else(|| std::process::abort())
1152 .public_key,
1153 op2.verifying_key().to_bytes()
1154 );
1155 }
1156
1157 #[hegel::test]
1158 fn prop_unknown_author_returns_none(tc: hegel::TestCase) {
1159 let reg = KeyRegistry::new();
1160 let author = AuthorId::new(tc.draw(gs::integers::<u64>()));
1161 let v = tc.draw(gs::integers::<u64>());
1162 assert!(reg.active_epoch_at(author, v).is_none());
1163 }
1164
1165 #[hegel::test]
1166 fn prop_tampered_revocation_rejected(tc: hegel::TestCase) {
1167 let (author, master, _op0, mut reg) = register_author(&tc);
1168 let mut rec =
1169 sign_revocation_record(author, 0, RevocationReason::Superseded, 10, &master);
1170 rec.effective_from_version = rec
1172 .effective_from_version
1173 .checked_add(1)
1174 .unwrap_or_else(|| std::process::abort());
1175 assert!(reg.apply_revocation(&rec).is_err());
1176 }
1177 }
1178
1179 mod trusted_json {
1180 use super::*;
1181 use base64::Engine;
1182
1183 fn b64(bytes: &[u8; 32]) -> String {
1184 base64::engine::general_purpose::STANDARD.encode(bytes)
1185 }
1186
1187 #[test]
1188 fn loads_single_author_single_epoch() {
1189 let master = SigningKey::generate();
1190 let op = SigningKey::generate();
1191 let json = format!(
1192 r#"{{"version":1,"authors":[{{
1193 "author_id": 7,
1194 "master_key": "{}",
1195 "epochs": [{{"epoch":0,"public_key":"{}","active_from_version":0}}]
1196 }}]}}"#,
1197 b64(&master.verifying_key().to_bytes()),
1198 b64(&op.verifying_key().to_bytes()),
1199 );
1200 let reg =
1201 KeyRegistry::from_trusted_json(&json).unwrap_or_else(|_| std::process::abort());
1202 let author = AuthorId::new(7);
1203 let epoch = reg
1204 .active_epoch_at(author, 42)
1205 .unwrap_or_else(|| std::process::abort());
1206 assert_eq!(epoch.epoch, 0);
1207 assert_eq!(epoch.public_key, op.verifying_key().to_bytes());
1208 }
1209
1210 #[test]
1211 fn loads_multi_epoch_with_revocation() {
1212 let master = SigningKey::generate();
1213 let op0 = SigningKey::generate();
1214 let op1 = SigningKey::generate();
1215 let json = format!(
1216 r#"{{"version":1,"authors":[{{
1217 "author_id": 11,
1218 "master_key": "{}",
1219 "epochs": [
1220 {{"epoch":0,"public_key":"{}","active_from_version":0}},
1221 {{"epoch":1,"public_key":"{}","active_from_version":100}}
1222 ],
1223 "revocations": [
1224 {{"epoch":1,"reason":"Compromised","effective_from_version":200}}
1225 ]
1226 }}]}}"#,
1227 b64(&master.verifying_key().to_bytes()),
1228 b64(&op0.verifying_key().to_bytes()),
1229 b64(&op1.verifying_key().to_bytes()),
1230 );
1231 let reg =
1232 KeyRegistry::from_trusted_json(&json).unwrap_or_else(|_| std::process::abort());
1233 let author = AuthorId::new(11);
1234 assert_eq!(
1235 reg.active_epoch_at(author, 50)
1236 .unwrap_or_else(|| std::process::abort())
1237 .epoch,
1238 0
1239 );
1240 assert_eq!(
1241 reg.active_epoch_at(author, 150)
1242 .unwrap_or_else(|| std::process::abort())
1243 .epoch,
1244 1
1245 );
1246 assert!(reg.active_epoch_at(author, 300).is_none());
1247 }
1248
1249 #[test]
1250 fn rejects_unsupported_version() {
1251 let err = KeyRegistry::from_trusted_json(r#"{"version":2,"authors":[]}"#);
1252 assert!(err.is_err());
1253 }
1254
1255 #[test]
1256 fn rejects_malformed_base64() {
1257 let json = r#"{"version":1,"authors":[{
1258 "author_id": 1,
1259 "master_key": "not-base64!!!",
1260 "epochs": [{"epoch":0,"public_key":"also-bad","active_from_version":0}]
1261 }]}"#;
1262 assert!(KeyRegistry::from_trusted_json(json).is_err());
1263 }
1264
1265 #[test]
1266 fn rejects_wrong_length_key() {
1267 use base64::engine::general_purpose::STANDARD;
1268 let short = STANDARD.encode([0u8; 16]);
1269 let json = format!(
1270 r#"{{"version":1,"authors":[{{
1271 "author_id": 1,
1272 "master_key": "{short}",
1273 "epochs": [{{"epoch":0,"public_key":"{short}","active_from_version":0}}]
1274 }}]}}"#
1275 );
1276 assert!(KeyRegistry::from_trusted_json(&json).is_err());
1277 }
1278
1279 fn build_two_epoch_rotated_registry() -> KeyRegistry {
1290 let author = AuthorId::new(11);
1291 let master = SigningKey::generate();
1292 let op0 = SigningKey::generate();
1293 let op1 = SigningKey::generate();
1294 let mut reg = KeyRegistry::new();
1295 reg.register_author(author, master.verifying_key(), op0.verifying_key(), 0)
1296 .unwrap();
1297 let rotation =
1298 sign_rotation_record(author, 0, 1, op1.verifying_key().to_bytes(), 100, &master);
1299 reg.apply_rotation(&rotation).unwrap();
1300 reg
1301 }
1302
1303 #[test]
1304 fn serialized_json_marks_rotated_and_active_epochs() {
1305 let reg = build_two_epoch_rotated_registry();
1306 let out = reg.to_trusted_json().unwrap();
1307 assert!(
1309 out.contains("\"status\": \"rotated\""),
1310 "rotated epoch must be marked, got: {out}"
1311 );
1312 assert!(
1313 out.contains("\"status\": \"active\""),
1314 "active epoch must be marked, got: {out}"
1315 );
1316 }
1317
1318 #[test]
1319 fn serialized_json_marks_revoked_epoch() {
1320 let author = AuthorId::new(12);
1321 let master = SigningKey::generate();
1322 let op = SigningKey::generate();
1323 let mut reg = KeyRegistry::new();
1324 reg.register_author(author, master.verifying_key(), op.verifying_key(), 0)
1325 .unwrap();
1326 let revocation =
1327 sign_revocation_record(author, 0, RevocationReason::Compromised, 50, &master);
1328 reg.apply_revocation(&revocation).unwrap();
1329
1330 let out = reg.to_trusted_json().unwrap();
1331 assert!(
1332 out.contains("\"status\": \"revoked\""),
1333 "revoked epoch must be marked, got: {out}"
1334 );
1335 }
1336
1337 #[test]
1338 fn old_json_without_status_field_still_parses() {
1339 let master = SigningKey::generate();
1343 let op0 = SigningKey::generate();
1344 let op1 = SigningKey::generate();
1345 let json = format!(
1346 r#"{{"version":1,"authors":[{{
1347 "author_id": 13,
1348 "master_key": "{}",
1349 "epochs": [
1350 {{"epoch":0,"public_key":"{}","active_from_version":0}},
1351 {{"epoch":1,"public_key":"{}","active_from_version":100}}
1352 ]
1353 }}]}}"#,
1354 b64(&master.verifying_key().to_bytes()),
1355 b64(&op0.verifying_key().to_bytes()),
1356 b64(&op1.verifying_key().to_bytes()),
1357 );
1358 let reg = KeyRegistry::from_trusted_json(&json).unwrap();
1359 let author = AuthorId::new(13);
1361 assert_eq!(reg.active_epoch_at(author, 50).unwrap().epoch, 0);
1362 assert_eq!(reg.active_epoch_at(author, 200).unwrap().epoch, 1);
1363 }
1364
1365 #[test]
1366 fn round_trip_preserves_status() {
1367 let original = build_two_epoch_rotated_registry();
1368 let json = original.to_trusted_json().unwrap();
1369 let reloaded = KeyRegistry::from_trusted_json(&json).unwrap();
1370 let json2 = reloaded.to_trusted_json().unwrap();
1373 assert_eq!(json, json2, "round-trip JSON must be byte-identical");
1374 }
1375 }
1376}