Skip to main content

aion_context/
key_registry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Key rotation and revocation registry — RFC-0028.
3//!
4//! Each author has:
5//!
6//! - A long-lived **master key** that authorizes epoch changes.
7//! - A sequence of **operational key epochs** — the keys that
8//!   actually sign versions and attestations.
9//!
10//! [`KeyRegistry`] holds both and resolves `(author, version_number)`
11//! to the epoch that was active at sign time. Verification helpers
12//! in [`crate::signature_chain`] use the registry to reject
13//! signatures made by rotated-out or revoked keys.
14//!
15//! This module does not touch the on-disk file format; RFC-0028
16//! Phase B covers embedding the registry in `.aion` files.
17//!
18//! # Example
19//!
20//! ```
21//! use aion_context::crypto::SigningKey;
22//! use aion_context::key_registry::{KeyRegistry, sign_rotation_record};
23//! use aion_context::types::AuthorId;
24//!
25//! let author = AuthorId::new(42);
26//! let master = SigningKey::generate();
27//! let op0 = SigningKey::generate();
28//! let op1 = SigningKey::generate();
29//!
30//! let mut reg = KeyRegistry::new();
31//! reg.register_author(author, master.verifying_key(), op0.verifying_key(), 0).unwrap();
32//!
33//! let rotation = sign_rotation_record(
34//!     author,
35//!     0, 1,
36//!     op1.verifying_key().to_bytes(),
37//!     5,
38//!     &master,
39//! );
40//! reg.apply_rotation(&rotation).unwrap();
41//!
42//! let at_v1 = reg.active_epoch_at(author, 1).unwrap();
43//! assert_eq!(at_v1.epoch, 0);
44//! let at_v7 = reg.active_epoch_at(author, 7).unwrap();
45//! assert_eq!(at_v7.epoch, 1);
46//! ```
47
48use std::collections::HashMap;
49
50use crate::crypto::{SigningKey, VerifyingKey};
51use crate::types::AuthorId;
52use crate::{AionError, Result};
53
54/// Domain separator for rotation-record signatures.
55pub(crate) const ROTATION_DOMAIN: &[u8] = b"AION_V2_ROTATION_V1";
56
57/// Domain separator for revocation-record signatures.
58pub(crate) const REVOCATION_DOMAIN: &[u8] = b"AION_V2_REVOCATION_V1";
59
60/// Reason code carried in a revocation record.
61#[repr(u16)]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum RevocationReason {
64    /// Key material is known or suspected to be compromised.
65    Compromised = 1,
66    /// Routine rotation; the prior key is not believed compromised.
67    Superseded = 2,
68    /// Signer leaves the org; no successor epoch.
69    Retired = 3,
70    /// Reason not recorded at protocol level.
71    Unspecified = 255,
72}
73
74impl RevocationReason {
75    /// Convert a raw `u16` back to a known reason.
76    ///
77    /// # Errors
78    ///
79    /// Returns `Err` for discriminants that are not defined by this
80    /// enum. Unknown values must not be silently mapped onto
81    /// [`Self::Unspecified`] — that would let an attacker forge a
82    /// weaker reason.
83    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/// Lifecycle state of a single [`KeyEpoch`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum KeyStatus {
99    /// The epoch is currently usable.
100    Active,
101    /// A rotation record has moved authority to `successor_epoch`.
102    Rotated {
103        /// Next epoch in the sequence.
104        successor_epoch: u32,
105        /// Version number at which the rotation takes effect.
106        effective_from_version: u64,
107    },
108    /// An explicit revocation record has invalidated this epoch.
109    Revoked {
110        /// Reason carried by the revocation record.
111        reason: RevocationReason,
112        /// Version number at which the revocation takes effect.
113        effective_from_version: u64,
114    },
115}
116
117/// One epoch for one author. Epochs are append-only per author.
118#[derive(Debug, Clone)]
119pub struct KeyEpoch {
120    /// Which author this epoch belongs to.
121    pub author_id: AuthorId,
122    /// Monotonic per-author epoch number, starting at 0.
123    pub epoch: u32,
124    /// 32-byte Ed25519 verifying key for this epoch.
125    pub public_key: [u8; 32],
126    /// First version number at which this epoch is valid.
127    pub created_at_version: u64,
128    /// Current lifecycle state.
129    pub status: KeyStatus,
130}
131
132impl KeyEpoch {
133    /// Return `true` if `version_number` falls within this epoch's
134    /// valid window (inclusive lower bound, exclusive upper bound
135    /// on the effective-from of the next rotation or revocation).
136    #[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/// Rotation record — signed by the author's master key.
156#[derive(Debug, Clone)]
157pub struct KeyRotationRecord {
158    /// Author whose epoch sequence is being extended.
159    pub author_id: AuthorId,
160    /// Currently-active epoch being rotated out.
161    pub from_epoch: u32,
162    /// New epoch being added (must be `from_epoch + 1`).
163    pub to_epoch: u32,
164    /// Public key for the new epoch.
165    pub to_public_key: [u8; 32],
166    /// Version number at which the rotation takes effect.
167    pub effective_from_version: u64,
168    /// Ed25519 signature by the author's master key over the
169    /// canonical rotation message.
170    pub master_signature: [u8; 64],
171}
172
173/// Revocation record — signed by the author's master key.
174#[derive(Debug, Clone)]
175pub struct RevocationRecord {
176    /// Author whose epoch is being revoked.
177    pub author_id: AuthorId,
178    /// Epoch being revoked.
179    pub revoked_epoch: u32,
180    /// Why the epoch is being revoked.
181    pub reason: RevocationReason,
182    /// Version number at which the revocation takes effect.
183    pub effective_from_version: u64,
184    /// Ed25519 signature by the author's master key over the
185    /// canonical revocation message.
186    pub master_signature: [u8; 64],
187}
188
189/// Canonical bytes signed by the master key when producing a
190/// rotation record.
191#[must_use]
192#[allow(clippy::arithmetic_side_effects)] // fixed-size add over constants
193pub 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/// Canonical bytes signed by the master key when producing a
205/// revocation record.
206#[must_use]
207#[allow(clippy::arithmetic_side_effects)] // fixed-size add over constants
208pub 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/// Build a rotation record signed by the supplied master key.
219///
220/// The caller is responsible for ensuring `to_epoch == from_epoch + 1`
221/// and `effective_from_version` is at or after the current active
222/// epoch's `created_at_version`; the registry enforces both on
223/// [`KeyRegistry::apply_rotation`].
224#[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/// Build a revocation record signed by the supplied master key.
247#[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/// Per-author registry entry — the master key plus the append-only
268/// epoch sequence.
269#[derive(Debug, Clone)]
270struct AuthorRecord {
271    master_key: VerifyingKey,
272    epochs: Vec<KeyEpoch>,
273}
274
275/// Authoritative registry of master keys and operational-key epochs.
276#[derive(Debug, Default)]
277pub struct KeyRegistry {
278    authors: HashMap<AuthorId, AuthorRecord>,
279}
280
281impl KeyRegistry {
282    /// Construct an empty registry.
283    #[must_use]
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    /// Register a new author with a master key and an initial
289    /// operational key (epoch 0).
290    ///
291    /// # Errors
292    ///
293    /// Returns `Err` if the author is already registered.
294    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    /// Apply a rotation record to the registry.
322    ///
323    /// # Errors
324    ///
325    /// Returns `Err` if:
326    /// - the author is unknown,
327    /// - `from_epoch` is not the current active epoch,
328    /// - `to_epoch != from_epoch + 1`,
329    /// - `effective_from_version` precedes the current active
330    ///   epoch's `created_at_version`,
331    /// - the master signature does not verify.
332    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    /// Apply a revocation record to the registry.
361    ///
362    /// # Errors
363    ///
364    /// Returns `Err` if the author / epoch is unknown, the epoch is
365    /// already revoked, or the master signature does not verify.
366    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    /// Return the operational epoch that was valid for `author` at
411    /// `version_number`, or `None` if no epoch covers that version.
412    #[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    /// Return the registered master key for `author`, if any.
422    #[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    /// Return every recorded epoch for `author`, in insertion order.
428    #[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    /// Append an epoch to `author` without verifying a signed
436    /// rotation record.
437    ///
438    /// The caller is asserting that this registry is itself the
439    /// trust anchor — e.g. a pinning file the operator brings to
440    /// verification. [`Self::apply_rotation`] is the signed-record
441    /// path and is the correct choice when the rotation arrives
442    /// from an untrusted source (transparency log, network peer).
443    ///
444    /// The prior active epoch is transitioned to
445    /// [`KeyStatus::Rotated`] at `active_from_version`. The new
446    /// epoch is inserted with [`KeyStatus::Active`] status.
447    ///
448    /// # Errors
449    ///
450    /// Returns `Err` if:
451    /// - the author is not registered,
452    /// - `epoch` is not strictly greater than every existing epoch
453    ///   for this author,
454    /// - `active_from_version` is not strictly greater than the
455    ///   prior active epoch's `created_at_version`,
456    /// - the author currently has no active epoch (i.e. the prior
457    ///   epoch is already revoked or rotated).
458    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    /// Mark `epoch` as revoked for `author` without verifying a
508    /// signed revocation record.
509    ///
510    /// See [`Self::insert_epoch_unchecked`] for when this is the
511    /// correct path vs. [`Self::apply_revocation`].
512    ///
513    /// # Errors
514    ///
515    /// Returns `Err` if the author / epoch is unknown or already
516    /// revoked.
517    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    /// Load a trusted registry from the CLI JSON file format.
551    ///
552    /// The on-disk shape is:
553    ///
554    /// ```json
555    /// {
556    ///   "version": 1,
557    ///   "authors": [
558    ///     {
559    ///       "author_id": 50001,
560    ///       "master_key": "<base64-32-bytes>",
561    ///       "epochs": [
562    ///         { "epoch": 0, "public_key": "<base64-32-bytes>", "active_from_version": 0 }
563    ///       ],
564    ///       "revocations": []
565    ///     }
566    ///   ]
567    /// }
568    /// ```
569    ///
570    /// This is a *trusted* load: every epoch and revocation is
571    /// inserted via the `_unchecked` path. Use it for operator-
572    /// supplied pinning files; use [`Self::apply_rotation`] and
573    /// [`Self::apply_revocation`] for records that arrived from
574    /// an untrusted source.
575    ///
576    /// # Errors
577    ///
578    /// Returns `Err` if the JSON is malformed, the format version
579    /// is not 1, any base64 field does not decode to exactly 32
580    /// bytes, any author appears twice, any epoch number repeats
581    /// or is non-monotonic within an author, or any revocation
582    /// points at an unknown epoch.
583    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    /// Serialize the registry to the trusted-JSON format parsed by
650    /// [`Self::from_trusted_json`]. Authors and epochs are emitted in
651    /// stable, sorted order (`author_id` ascending, then epoch
652    /// ascending) so output is deterministic.
653    ///
654    /// # Errors
655    ///
656    /// Returns `Err` if `serde_json` fails to serialize — which in
657    /// practice does not happen with the on-disk shape this method
658    /// constructs.
659    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    /// One of `active`, `rotated`, `revoked`. Optional on parse
752    /// for backward compatibility — older JSON without a status
753    /// field reconstructs the status from neighboring epochs and
754    /// the revocations array. Always emitted on serialize for
755    /// operator-readability: an auditor diffing two registry
756    /// files can see at a glance which epochs are still active.
757    /// Audit-pass HIGH finding (2026-04-25); the in-memory
758    /// status is still derived from the reconstruction, not
759    /// trusted from this field, so an inconsistent JSON cannot
760    /// elevate a rotated key back to active.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    status: Option<TrustedEpochStatus>,
763}
764
765/// Wire representation of [`KeyStatus`] for the trusted-JSON
766/// format. Exposed only for round-trip readability — the parser
767/// reconstructs the in-memory status independently.
768#[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
789/// Validate the structural preconditions on a rotation record
790/// against the current epoch list, without mutating state or
791/// touching crypto. Returns the epoch number of the currently
792/// active epoch (which the caller marks `Rotated` once the
793/// master signature has also verified).
794fn 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
829/// Flip the previously-active epoch into the `Rotated` state. The
830/// caller is responsible for pushing the new epoch afterwards.
831fn 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, // wrong: active is 0
902            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            // any version strictly less than `effective` is still epoch 0
1008            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            // Earlier versions still covered, later versions are not.
1056            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            // Flip a byte in the master signature → rejection.
1073            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            // Three-epoch chain: op0 -> op1 at v_a -> op2 at v_b.
1110            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            // Probe three windows.
1126            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            // Tamper the effective_from_version after signing.
1171            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        // ----------------------------------------------------------
1280        // Audit-pass HIGH finding: the serialized JSON now carries
1281        // an explicit `status` per epoch ("active" / "rotated" /
1282        // "revoked") so an auditor diffing two registry files can
1283        // see at a glance which keys are still in force. The
1284        // in-memory status is still derived from reconstruction —
1285        // these tests confirm the new field is informative AND the
1286        // backward-compat path still works.
1287        // ----------------------------------------------------------
1288
1289        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            // Epoch 0 should be marked rotated; epoch 1 active.
1308            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            // The JSON below predates the status field. It must
1340            // still parse, with status reconstructed from epoch
1341            // ordering and the revocations array.
1342            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            // Verify the in-memory reconstruction:
1360            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            // Re-serialize and compare. The status fields must
1371            // survive the round-trip.
1372            let json2 = reloaded.to_trusted_json().unwrap();
1373            assert_eq!(json, json2, "round-trip JSON must be byte-identical");
1374        }
1375    }
1376}