#![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] // @@ begin lint list maintained by maint/add_warning @@ #![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable) #![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly) #![warn(missing_docs)] #![warn(noop_method_call)] #![warn(unreachable_pub)] #![warn(clippy::all)] #![deny(clippy::await_holding_lock)] #![deny(clippy::cargo_common_metadata)] #![deny(clippy::cast_lossless)] #![deny(clippy::checked_conversions)] #![warn(clippy::cognitive_complexity)] #![deny(clippy::debug_assert_with_mut_call)] #![deny(clippy::exhaustive_enums)] #![deny(clippy::exhaustive_structs)] #![deny(clippy::expl_impl_clone_on_copy)] #![deny(clippy::fallible_impl_from)] #![deny(clippy::implicit_clone)] #![deny(clippy::large_stack_arrays)] #![warn(clippy::manual_ok_or)] #![deny(clippy::missing_docs_in_private_items)] #![warn(clippy::needless_borrow)] #![warn(clippy::needless_pass_by_value)] #![warn(clippy::option_option)] #![deny(clippy::print_stderr)] #![deny(clippy::print_stdout)] #![warn(clippy::rc_buffer)] #![deny(clippy::ref_option_ref)] #![warn(clippy::semicolon_if_nothing_returned)] #![warn(clippy::trait_duplication_in_bounds)] #![deny(clippy::unchecked_time_subtraction)] #![deny(clippy::unnecessary_wraps)] #![warn(clippy::unseparated_literal_suffix)] #![deny(clippy::unwrap_used)] #![deny(clippy::mod_module_files)] #![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness #![allow(clippy::uninlined_format_args)] #![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945 #![allow(clippy::result_large_err)] // temporary workaround for arti#587 #![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best #![allow(clippy::needless_lifetimes)] // See arti#1765 #![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060 #![allow(clippy::collapsible_if)] // See arti#2342 #![deny(clippy::unused_async)] #![deny(clippy::string_slice)] // See arti#2571 //! mod err; pub mod rsa; #[cfg(feature = "x509")] pub use tor_cert_x509 as x509; use caret::caret_int; use tor_bytes::{Error as BytesError, Result as BytesResult}; use tor_bytes::{Readable, Reader, Writeable, Writer}; use tor_llcrypto::pk::*; use saturating_time::SaturatingTime; use web_time_compat as time; pub use err::CertError; mod encode; pub use encode::{EncodedCert, EncodedEd25519Cert}; pub use err::CertEncodeError; /// A Result defined to use CertError type CertResult = std::result::Result; caret_int! { /// Recognized values for Tor's certificate type field. /// /// In the names used here, "X_V_Y" means "key X verifying key Y", /// whereas "X_CC_Y" means "key X cross-certifying key Y". In both /// cases, X is the key that is doing the signing, and Y is the key /// or object that is getting signed. /// /// Not every one of these types is valid for an Ed25519 /// certificate. Some are for X.509 certs in a CERTS cell; some /// are for RSA->Ed crosscerts in a CERTS cell. pub struct CertType(u8) { /// TLS link key, signed with RSA identity. X.509 format. (Obsolete) TLS_LINK_X509 = 0x01, /// Self-signed RSA identity certificate. X.509 format. (Legacy) RSA_ID_X509 = 0x02, /// RSA lnk authentication key signed with RSA identity /// key. X.509 format. (Obsolete) LINK_AUTH_X509 = 0x03, /// Identity verifying a signing key, directly. IDENTITY_V_SIGNING = 0x04, /// Signing key verifying a TLS certificate by digest. SIGNING_V_TLS_CERT = 0x05, /// Signing key verifying a link authentication key. SIGNING_V_LINK_AUTH = 0x06, /// RSA identity key certifying an Ed25519 identity key. RSA /// crosscert format. (Legacy) RSA_ID_V_IDENTITY = 0x07, /// For onion services: short-term descriptor signing key /// (`KP_hs_desc_sign`), signed with blinded onion service identity /// (`KP_hs_blind_id`). HS_BLINDED_ID_V_SIGNING = 0x08, /// For onion services: Introduction point authentication key /// (`KP_hs_ipt_sid`), signed with short term descriptor signing key /// (`KP_hs_desc_sign`). /// /// This one is, sadly, a bit complicated. In the original specification /// it was meant to be a cross-certificate, where the signature would be /// _on_ the descriptor signing key, _signed with_ the intro TID key. /// But we got it backwards in the C Tor implementation, and now, for /// compatibility, we are stuck doing it backwards in the future. /// /// If we find in the future that it is actually important to /// cross-certify these keys (as originally intended), then we should /// add a new certificate type, and put the new certificate in the onion /// service descriptor. HS_IP_V_SIGNING = 0x09, /// An ntor key converted to a ed25519 key, cross-certifying an /// identity key. NTOR_CC_IDENTITY = 0x0A, /// For onion services: Ntor encryption key (`KP_hss_ntor`), /// converted to ed25519, signed with the descriptor signing key /// (`KP_hs_desc_sign`). /// /// As with [`HS_IP_V_SIGNING`](CertType::HS_IP_V_SIGNING), this /// certificate type is backwards. In the original specification it was /// meant to be a cross certificate, with the signing and signed keys /// reversed. HS_IP_CC_SIGNING = 0x0B, /// For relays: family key certifying membership of a relay /// by signing its identity. FAMILY_V_IDENTITY = 0x0C, } } caret_int! { /// Extension identifiers for extensions in certificates. pub struct ExtType(u8) { /// Extension indicating an Ed25519 key that signed this certificate. /// /// Certificates do not always contain the key that signed them. SIGNED_WITH_ED25519_KEY = 0x04, } } caret_int! { /// Identifiers for the type of key or object getting signed. pub struct KeyType(u8) { /// Identifier for an Ed25519 key. ED25519_KEY = 0x01, /// Identifier for the SHA256 of an DER-encoded RSA key. SHA256_OF_RSA = 0x02, /// Identifies the SHA256 of an X.509 certificate. SHA256_OF_X509 = 0x03, } } /// Structure for an Ed25519-signed certificate as described in Tor's /// cert-spec.txt. #[derive(Debug, Clone, PartialEq, Eq, derive_builder::Builder)] #[builder(build_fn(skip))] pub struct Ed25519Cert { /// How many _hours_ after the epoch will this certificate expire? #[builder(setter(custom))] exp_hours: ExpiryHours, /// Type of the certificate; recognized values are in certtype::* cert_type: CertType, /// The key or object being certified. cert_key: CertifiedKey, /// A list of extensions. #[allow(unused)] // TODO review CertExt and make it pub, and add a getter #[builder(setter(custom))] extensions: Vec, /// The key that signed this cert. /// /// Once the cert has been unwrapped from an KeyUnknownCert, this field will /// be set. If there is a `SignedWithEd25519` extension in /// `self.extensions`, this will match it. #[builder(setter(custom))] signed_with: Option, } /// One of the data types that can be certified by an Ed25519Cert. #[derive(Debug, Clone, PartialEq, Eq, derive_more::From)] #[non_exhaustive] pub enum CertifiedKey { /// An Ed25519 public key, signed directly. Ed25519(ed25519::Ed25519Identity), /// The SHA256 digest of a DER-encoded RsaPublicKey #[from(skip)] RsaSha256Digest([u8; 32]), /// The SHA256 digest of an X.509 certificate. #[from(skip)] X509Sha256Digest([u8; 32]), /// Some unrecognized key type. #[from(skip)] Unrecognized(UnrecognizedKey), } /// A key whose type we didn't recognize. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnrecognizedKey { /// Actual type of the key. key_type: KeyType, /// digest of the key, or the key itself. key_digest: [u8; 32], } impl CertifiedKey { /// Return the byte that identifies the type of this key. pub fn key_type(&self) -> KeyType { match self { CertifiedKey::Ed25519(_) => KeyType::ED25519_KEY, CertifiedKey::RsaSha256Digest(_) => KeyType::SHA256_OF_RSA, CertifiedKey::X509Sha256Digest(_) => KeyType::SHA256_OF_X509, CertifiedKey::Unrecognized(u) => u.key_type, } } /// Return the bytes that are used for the body of this certified /// key or object. pub fn as_bytes(&self) -> &[u8] { match self { CertifiedKey::Ed25519(k) => k.as_bytes(), CertifiedKey::RsaSha256Digest(k) => &k[..], CertifiedKey::X509Sha256Digest(k) => &k[..], CertifiedKey::Unrecognized(u) => &u.key_digest[..], } } /// If this is an Ed25519 public key, return Some(key). /// Otherwise, return None. pub fn as_ed25519(&self) -> Option<&ed25519::Ed25519Identity> { match self { CertifiedKey::Ed25519(k) => Some(k), _ => None, } } /// Try to extract a CertifiedKey from a Reader, given that we have /// already read its type as `key_type`. fn from_reader(key_type: KeyType, r: &mut Reader<'_>) -> BytesResult { Ok(match key_type { KeyType::ED25519_KEY => CertifiedKey::Ed25519(r.extract()?), KeyType::SHA256_OF_RSA => CertifiedKey::RsaSha256Digest(r.extract()?), KeyType::SHA256_OF_X509 => CertifiedKey::X509Sha256Digest(r.extract()?), _ => CertifiedKey::Unrecognized(UnrecognizedKey { key_type, key_digest: r.extract()?, }), }) } } /// An extension in a Tor certificate. #[derive(Debug, Clone, PartialEq, Eq)] enum CertExt { /// Indicates which Ed25519 public key signed this cert. SignedWithEd25519(SignedWithEd25519Ext), /// An extension whose identity we don't recognize. Unrecognized(UnrecognizedExt), } /// Any unrecognized extension on a Tor certificate. #[derive(Debug, Clone, PartialEq, Eq)] #[allow(unused)] struct UnrecognizedExt { /// True iff this extension must be understand in order to validate the /// certificate. affects_validation: bool, /// The type of the extension ext_type: ExtType, /// The body of the extension. body: Vec, } impl CertExt { /// Return the identifier code for this Extension. fn ext_id(&self) -> ExtType { match self { CertExt::SignedWithEd25519(_) => ExtType::SIGNED_WITH_ED25519_KEY, CertExt::Unrecognized(u) => u.ext_type, } } } /// Extension indicating that a key that signed a given certificate. #[derive(Debug, Clone, PartialEq, Eq)] struct SignedWithEd25519Ext { /// The key that signed the certificate including this extension. pk: ed25519::Ed25519Identity, } impl Readable for CertExt { fn take_from(b: &mut Reader<'_>) -> BytesResult { let len = b.take_u16()?; let ext_type: ExtType = b.take_u8()?.into(); let flags = b.take_u8()?; let body = b.take(len as usize)?; Ok(match ext_type { ExtType::SIGNED_WITH_ED25519_KEY => CertExt::SignedWithEd25519(SignedWithEd25519Ext { pk: ed25519::Ed25519Identity::from_bytes(body).ok_or_else(|| { BytesError::InvalidMessage("wrong length on Ed25519 key".into()) })?, }), _ => { if (flags & 1) != 0 { return Err(BytesError::InvalidMessage( "unrecognized certificate extension, with 'affects_validation' flag set." .into(), )); } CertExt::Unrecognized(UnrecognizedExt { affects_validation: false, ext_type, body: body.into(), }) } }) } } impl Writeable for KeyUnknownCert { fn write_onto(&self, b: &mut B) -> Result<(), tor_bytes::EncodeError> { self.cert.write_onto(b) } } impl Readable for KeyUnknownCert { fn take_from(r: &mut Reader<'_>) -> BytesResult { let b = r.take_rest(); Ed25519Cert::decode(b) } } impl Ed25519Cert { /// Try to decode a certificate from a byte slice. /// /// This function returns an error if the byte slice is not /// completely exhausted. /// /// Note that the resulting KeyUnknownCertificate is not checked /// for validity at all: you will need to provide it with an expected /// signing key, then check it for timeliness and well-signedness. pub fn decode(cert: &[u8]) -> BytesResult { let mut r = Reader::from_slice(cert); let v = r.take_u8()?; if v != 1 { // This would be something other than a "v1" certificate. We don't // understand those. return Err(BytesError::InvalidMessage( "Unrecognized certificate version".into(), )); } let cert_type = r.take_u8()?.into(); let exp_hours = r.extract()?; let mut cert_key_type = r.take_u8()?.into(); // This is a workaround for a tor bug: the key type is // wrong. It was fixed in tor#40124, which got merged into Tor // 0.4.5.x and later. if cert_type == CertType::SIGNING_V_TLS_CERT && cert_key_type == KeyType::ED25519_KEY { cert_key_type = KeyType::SHA256_OF_X509; } let cert_key = CertifiedKey::from_reader(cert_key_type, &mut r)?; let n_exts = r.take_u8()?; let mut extensions = Vec::new(); for _ in 0..n_exts { let e: CertExt = r.extract()?; extensions.push(e); } let sig_offset = r.consumed(); let signature: ed25519::Signature = r.extract()?; r.should_be_exhausted()?; // See comment in `impl Writeable for UncheckedCert`. let keyext = extensions .iter() .find(|e| e.ext_id() == ExtType::SIGNED_WITH_ED25519_KEY); let included_pkey = match keyext { Some(CertExt::SignedWithEd25519(s)) => Some(s.pk), _ => None, }; Ok(KeyUnknownCert { cert: UncheckedCert { cert: Ed25519Cert { exp_hours, cert_type, cert_key, extensions, signed_with: included_pkey, }, text: cert[0..sig_offset].into(), signature, }, }) } /// Return the time at which this certificate becomes expired pub fn expiry(&self) -> std::time::SystemTime { self.exp_hours.into() } /// Return true iff this certificate will be expired at the time `when`. /// /// This is inclusive, meaning that `when == self.expiry()` is still valid. pub fn is_expired_at(&self, when: std::time::SystemTime) -> bool { when > self.expiry() } /// Return the signed key or object that is authenticated by this /// certificate. pub fn subject_key(&self) -> &CertifiedKey { &self.cert_key } /// Return the ed25519 key that signed this certificate. pub fn signing_key(&self) -> Option<&ed25519::Ed25519Identity> { self.signed_with.as_ref() } /// Return the type of this certificate. pub fn cert_type(&self) -> CertType { self.cert_type } } /// A parsed Ed25519 certificate. Maybe it includes its signing key; /// maybe it doesn't. /// /// To validate this cert, either it must contain its signing key, /// or the caller must know the signing key. In the first case, call /// [`should_have_signing_key`](KeyUnknownCert::should_have_signing_key); /// in the latter, call /// [`should_be_signed_with`](KeyUnknownCert::should_be_signed_with). #[derive(Clone, Debug, PartialEq, Eq)] pub struct KeyUnknownCert { /// The certificate whose signing key might not be known. cert: UncheckedCert, } impl KeyUnknownCert { /// Return the certificate type of the underling cert. pub fn peek_cert_type(&self) -> CertType { self.cert.cert.cert_type } /// Return subject key of the underlying cert. pub fn peek_subject_key(&self) -> &CertifiedKey { &self.cert.cert.cert_key } /// Check whether a given pkey is (or might be) a key that has correctly /// signed this certificate. /// /// If pkey is None, this certificate must contain its signing key. /// /// On success, we can check whether the certificate is well-signed; /// otherwise, we can't check the certificate. #[deprecated( since = "0.7.1", note = "Use should_have_signing_key or should_be_signed_with instead." )] pub fn check_key(self, pkey: Option<&ed25519::Ed25519Identity>) -> CertResult { match pkey { Some(wanted) => self.should_be_signed_with(wanted), None => self.should_have_signing_key(), } } /// Declare that this should be a self-contained certificate that contains its own /// signing key. /// /// On success, this certificate did indeed turn out to be self-contained, and so /// we can validate it. /// On failure, this certificate was not self-contained. pub fn should_have_signing_key(self) -> CertResult { let real_key = match &self.cert.cert.signed_with { Some(a) => *a, None => return Err(CertError::MissingPubKey), }; Ok(UncheckedCert { cert: Ed25519Cert { signed_with: Some(real_key), ..self.cert.cert }, ..self.cert }) } /// Declare that this should be a certificate signed with a given key. /// /// On success, this certificate either listed the provided key, or did not /// list any key: in either case, we can validate it. /// On failure, this certificate claims to be signed with a different key. pub fn should_be_signed_with( self, pkey: &ed25519::Ed25519Identity, ) -> CertResult { let real_key = match &self.cert.cert.signed_with { Some(a) if a == pkey => *pkey, None => *pkey, Some(_) => return Err(CertError::KeyMismatch), }; Ok(UncheckedCert { cert: Ed25519Cert { signed_with: Some(real_key), ..self.cert.cert }, ..self.cert }) } } /// A certificate that has been parsed, but whose signature and /// timeliness have not been checked. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UncheckedCert { /// The parsed certificate, possibly modified by inserting an externally /// supplied key as its signing key. cert: Ed25519Cert, /// The signed text of the certificate. (Checking ed25519 signatures /// forces us to store this. // TODO(nickm) It would be better to store a hash here, but we // don't have the right Ed25519 API. text: Vec, /// The alleged signature signature: ed25519::Signature, } /// A certificate that has been parsed and signature-checked, but whose /// timeliness has not been checked. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SigCheckedCert { /// The certificate that might or might not be timely cert: Ed25519Cert, } impl UncheckedCert { /// Split this unchecked cert into a component that assumes it has /// been checked, and a signature to validate. pub fn dangerously_split( self, ) -> CertResult<(SigCheckedCert, ed25519::ValidatableEd25519Signature)> { use tor_checkable::SelfSigned; let signing_key = self.cert.signed_with.ok_or(CertError::MissingPubKey)?; let signing_key = signing_key .try_into() .map_err(|_| CertError::BadSignature)?; let signature = ed25519::ValidatableEd25519Signature::new(signing_key, self.signature, &self.text[..]); Ok((self.dangerously_assume_wellsigned(), signature)) } /// Return subject key of the underlying cert. pub fn peek_subject_key(&self) -> &CertifiedKey { &self.cert.cert_key } /// Return signing key of the underlying cert. pub fn peek_signing_key(&self) -> &ed25519::Ed25519Identity { self.cert .signed_with .as_ref() .expect("Made an UncheckedCert without a signing key") } } impl Writeable for UncheckedCert { // TODO in some sense this duplicates things in encode.rs. // However, encode.rs is not useable in type-driven (derive-based) situations, // because it uses entirely different types for encoding to those for decoding. // // Therefore, here we implement tor_bytes's encoding trait for the type which can // also be decoded. Perhaps the encode module could be abolished. fn write_onto(&self, b: &mut B) -> Result<(), tor_bytes::EncodeError> { // Ed25519Cert::decode does a lot of work, which finds a lot of fields, // but also `sig_offset`. It then splits the incoming byte buffer at `sig_offset` // into `text` and `signature`, insisting that there is nothing else. // // Therefore this is guaranteed to write precisely the input to `decode`. self.text.write_onto(b)?; self.signature.write_onto(b)?; Ok(()) } } impl tor_checkable::SelfSigned for UncheckedCert { type Error = CertError; fn is_well_signed(&self) -> CertResult<()> { let pubkey = &self.cert.signed_with.ok_or(CertError::MissingPubKey)?; let pubkey: ed25519::PublicKey = pubkey.try_into().map_err(|_| CertError::BadSignature)?; pubkey .verify(&self.text[..], &self.signature) .map_err(|_| CertError::BadSignature)?; Ok(()) } fn dangerously_assume_wellsigned(self) -> SigCheckedCert { SigCheckedCert { cert: self.cert } } } impl tor_checkable::Timebound for Ed25519Cert { type Error = tor_checkable::TimeValidityError; #[allow(unstable_name_collisions)] fn is_valid_at(&self, t: &time::SystemTime) -> Result<(), Self::Error> { if self.is_expired_at(*t) { let expiry = self.expiry(); Err(Self::Error::Expired(t.saturating_duration_since(expiry))) } else { Ok(()) } } fn dangerously_assume_timely(self) -> Ed25519Cert { self } } impl tor_checkable::Timebound for SigCheckedCert { type Error = tor_checkable::TimeValidityError; fn is_valid_at(&self, t: &time::SystemTime) -> std::result::Result<(), Self::Error> { self.cert.is_valid_at(t) } fn dangerously_assume_timely(self) -> Ed25519Cert { self.cert.dangerously_assume_timely() } } /// A certificate expiration time, represented in _hours_ since the unix epoch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExpiryHours(u32); /// The number of seconds in an hour. const SEC_PER_HOUR: u64 = 3600; impl From for time::SystemTime { fn from(value: ExpiryHours) -> Self { // TODO MSRV 1.91; use from_hours. let d = std::time::Duration::from_secs(u64::from(value.0) * SEC_PER_HOUR); std::time::SystemTime::UNIX_EPOCH + d } } impl ExpiryHours { /// Return the earliest possible `ExpiryHours` that is no earlier than `expiry`. fn try_from_systemtime_ceil(expiry: time::SystemTime) -> Result { let d = expiry .duration_since(time::SystemTime::UNIX_EPOCH) .map_err(|_| CertEncodeError::InvalidExpiration)?; let sec_ceil = d.as_secs() + if d.subsec_nanos() > 0 { 1 } else { 0 }; let hours = sec_ceil .div_ceil(SEC_PER_HOUR) .try_into() .map_err(|_| CertEncodeError::InvalidExpiration)?; Ok(ExpiryHours(hours)) } /// Return the latest possible ExpiryHours const fn max() -> Self { ExpiryHours(u32::MAX) } } impl Readable for ExpiryHours { fn take_from(b: &mut Reader<'_>) -> BytesResult { Ok(ExpiryHours(b.take_u32()?)) } } impl tor_bytes::Writeable for ExpiryHours { fn write_onto(&self, b: &mut B) -> tor_bytes::EncodeResult<()> { b.write_u32(self.0); Ok(()) } } #[cfg(test)] mod test { // @@ begin test lint list maintained by maint/add_warning @@ #![allow(clippy::bool_assert_comparison)] #![allow(clippy::clone_on_copy)] #![allow(clippy::dbg_macro)] #![allow(clippy::mixed_attributes_style)] #![allow(clippy::print_stderr)] #![allow(clippy::print_stdout)] #![allow(clippy::single_char_pattern)] #![allow(clippy::unwrap_used)] #![allow(clippy::unchecked_time_subtraction)] #![allow(clippy::useless_vec)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::string_slice)] // See arti#2571 //! use super::*; use hex_literal::hex; use web_time_compat::SystemTimeExt; #[test] fn parse_unrecognized_ext() -> BytesResult<()> { // case one: a flag is set but we don't know it let b = hex!("0009 99 10 657874656e73696f6e"); let mut r = Reader::from_slice(&b); let e: CertExt = r.extract()?; r.should_be_exhausted()?; assert_eq!(e.ext_id(), 0x99.into()); // case two: we've been told to ignore the cert if we can't // handle the extension. let b = hex!("0009 99 11 657874656e73696f6e"); let mut r = Reader::from_slice(&b); let e: Result = r.extract(); assert!(e.is_err()); assert_eq!( e.err().unwrap(), BytesError::InvalidMessage( "unrecognized certificate extension, with 'affects_validation' flag set.".into() ) ); Ok(()) } #[test] fn certified_key() -> BytesResult<()> { let b = hex!("4c27616d6f757220756e6974206365757820717527656e636861c3ae6e616974206c6520666572"); let mut r = Reader::from_slice(&b); let ck = CertifiedKey::from_reader(KeyType::SHA256_OF_RSA, &mut r)?; assert_eq!(ck.as_bytes(), &b[..32]); assert_eq!(ck.key_type(), KeyType::SHA256_OF_RSA); assert_eq!(r.remaining(), 7); let mut r = Reader::from_slice(&b); let ck = CertifiedKey::from_reader(42.into(), &mut r)?; assert_eq!(ck.as_bytes(), &b[..32]); assert_eq!(ck.key_type(), 42.into()); assert_eq!(r.remaining(), 7); Ok(()) } #[test] fn expiry_hours_ceil() { use std::time::{Duration, SystemTime}; let now = SystemTime::get(); let mut exp = now + Duration::from_secs(24 * 60 * 60); for _ in 0..=3600 { let eh = ExpiryHours::try_from_systemtime_ceil(exp).unwrap(); assert!(SystemTime::from(eh) >= exp); assert!(SystemTime::from(eh) < exp + Duration::from_secs(SEC_PER_HOUR)); exp += Duration::from_secs(1); } } }