1use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::cbor::Value;
24use crate::connection::{CallError, Session};
25use crate::frame::CallResponse;
26use crate::identity::KeyPair;
27
28pub const TYPE_PROCEDURE_ADVERTISEMENT: u8 = 0x06;
30pub const TYPE_STATION_ENDPOINT: u8 = 0x12;
31pub const TYPE_CONTENT_ANNOUNCEMENT: u8 = 0x11;
32
33pub const DEFAULT_TTL: Duration = Duration::from_secs(48 * 60 * 60);
36
37const SIG_DOMAIN: &[u8] = b"macula-v2-record\0";
41
42#[derive(Debug, Clone)]
46pub struct Record {
47 pub record_type: u8,
48 pub key: [u8; 32],
50 pub version: [u8; 16],
52 pub created_at: i128,
54 pub expires_at: i128,
56 pub payload: Value,
57 pub signature: Vec<u8>,
59}
60
61fn now_ms() -> i128 {
62 SystemTime::now()
63 .duration_since(UNIX_EPOCH)
64 .expect("system clock before 1970")
65 .as_millis() as i128
66}
67
68fn new_envelope(record_type: u8, key: [u8; 32], payload: Value, ttl: Duration) -> Record {
69 let created_at = now_ms();
70 Record {
71 record_type,
72 key,
73 version: *uuid::Uuid::now_v7().as_bytes(),
74 created_at,
75 expires_at: created_at + ttl.as_millis() as i128,
76 payload,
77 signature: Vec::new(),
78 }
79}
80
81pub fn new_procedure_advertisement(
92 advertiser_node: [u8; 32],
93 procedure_uri: impl Into<String>,
94 serving_station: [u8; 32],
95 ttl: Duration,
96) -> Record {
97 let ttl = if ttl.is_zero() { DEFAULT_TTL } else { ttl };
98 let payload = Value::Map(vec![
99 (Value::text("procedure_uri"), Value::text(procedure_uri)),
100 (
101 Value::text("advertiser_node"),
102 Value::Bytes(advertiser_node.to_vec()),
103 ),
104 (
105 Value::text("serving_station"),
106 Value::Bytes(serving_station.to_vec()),
107 ),
108 ]);
109 new_envelope(TYPE_PROCEDURE_ADVERTISEMENT, advertiser_node, payload, ttl)
110}
111
112pub fn new_procedure_advertisement_with_cert_chain(
119 advertiser_node: [u8; 32],
120 procedure_uri: impl Into<String>,
121 serving_station: [u8; 32],
122 ttl: Duration,
123 cert_chain_pem: Vec<u8>,
124) -> Record {
125 let mut rec = new_procedure_advertisement(advertiser_node, procedure_uri, serving_station, ttl);
126 let Value::Map(mut entries) = rec.payload else {
127 unreachable!("new_procedure_advertisement always returns a Map payload");
128 };
129 entries.push((Value::text("cert_chain"), Value::Bytes(cert_chain_pem)));
130 rec.payload = Value::Map(entries);
131 rec
132}
133
134pub fn new_content_announcement(
140 announcer_node: [u8; 32],
141 mcid: crate::manifest::Mcid,
142 endpoint: impl Into<String>,
143 ttl: Duration,
144) -> Record {
145 let payload = Value::Map(vec![
146 (
147 Value::text("announcer_node"),
148 Value::Bytes(announcer_node.to_vec()),
149 ),
150 (Value::text("mcid"), Value::Bytes(mcid.to_vec())),
151 (Value::text("endpoint"), Value::text(endpoint)),
152 ]);
153 new_envelope(TYPE_CONTENT_ANNOUNCEMENT, announcer_node, payload, ttl)
154}
155
156pub fn read_content_announcement(r: &Record) -> Result<ContentAnnouncement, ReadRecordError> {
160 if r.record_type != TYPE_CONTENT_ANNOUNCEMENT {
161 return Err(ReadRecordError::WrongRecordType);
162 }
163 let announcer_node = bytes32_field(&r.payload, "announcer_node")?;
164 let mcid: crate::manifest::Mcid = match r.payload.get("mcid") {
165 Some(Value::Bytes(b)) => b
166 .as_slice()
167 .try_into()
168 .map_err(|_| ReadRecordError::WrongFieldType("mcid"))?,
169 Some(_) => return Err(ReadRecordError::WrongFieldType("mcid")),
170 None => return Err(ReadRecordError::MissingField("mcid")),
171 };
172 let endpoint = match r.payload.get("endpoint") {
173 Some(Value::Text(t)) => t.clone(),
174 Some(_) => return Err(ReadRecordError::WrongFieldType("endpoint")),
175 None => return Err(ReadRecordError::MissingField("endpoint")),
176 };
177 Ok(ContentAnnouncement {
178 announcer_node,
179 mcid,
180 endpoint,
181 })
182}
183
184fn canonical_unsigned(r: &Record) -> Vec<u8> {
191 let entries = Value::Map(vec![
192 (Value::text("t"), Value::Int(r.record_type as i128)),
193 (Value::text("k"), Value::Bytes(r.key.to_vec())),
194 (Value::text("v"), Value::Bytes(r.version.to_vec())),
195 (Value::text("c"), Value::Int(r.created_at)),
196 (Value::text("x"), Value::Int(r.expires_at)),
197 (Value::text("p"), r.payload.clone()),
198 ]);
199 crate::cbor::encode(&entries).expect("dht record payload must be encodable")
204}
205
206pub fn sign(mut r: Record, id: &KeyPair) -> Record {
209 let mut msg = SIG_DOMAIN.to_vec();
210 msg.extend_from_slice(&canonical_unsigned(&r));
211 r.signature = id.sign(&msg).to_vec();
212 r
213}
214
215#[derive(Debug, PartialEq, Eq)]
216pub enum VerifyError {
217 InvalidSignature,
218 Expired,
219}
220
221impl std::fmt::Display for VerifyError {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 VerifyError::InvalidSignature => write!(f, "dht: signature invalid"),
225 VerifyError::Expired => write!(f, "dht: record expired"),
226 }
227 }
228}
229
230impl std::error::Error for VerifyError {}
231
232pub fn verify(r: &Record) -> Result<(), VerifyError> {
240 let sig: [u8; 64] = r
241 .signature
242 .as_slice()
243 .try_into()
244 .map_err(|_| VerifyError::InvalidSignature)?;
245 let mut msg = SIG_DOMAIN.to_vec();
246 msg.extend_from_slice(&canonical_unsigned(r));
247 if !crate::identity::verify(&msg, &sig, &r.key) {
248 return Err(VerifyError::InvalidSignature);
249 }
250 if r.expires_at > 0 && now_ms() >= r.expires_at {
251 return Err(VerifyError::Expired);
252 }
253 Ok(())
254}
255
256const STORAGE_DOMAIN_STATION_ENDPOINT: &[u8] = b"station_endpoint";
260
261pub fn procedure_key(procedure_uri: &str) -> [u8; 32] {
265 use sha2::{Digest, Sha256};
266 Sha256::digest(procedure_uri.as_bytes()).into()
267}
268
269pub fn station_endpoint_key(station_pubkey: [u8; 32]) -> [u8; 32] {
273 use sha2::{Digest, Sha256};
274 let mut hasher = Sha256::new();
275 hasher.update(STORAGE_DOMAIN_STATION_ENDPOINT);
276 hasher.update(station_pubkey);
277 hasher.finalize().into()
278}
279
280pub fn content_key(mcid: crate::manifest::Mcid) -> [u8; 32] {
285 use sha2::{Digest, Sha256};
286 Sha256::digest(mcid).into()
287}
288
289pub fn discovery_uri(realm: [u8; 32], procedure: &str) -> String {
294 let mut hex_realm = String::with_capacity(64);
295 for b in realm {
296 hex_realm.push_str(&format!("{b:02X}"));
297 }
298 format!("{hex_realm}/{procedure}")
299}
300
301#[derive(Debug, Clone)]
307pub struct ProcedureAdvertisement {
308 pub procedure_uri: String,
309 pub advertiser_node: [u8; 32],
310 pub serving_station: [u8; 32],
311 pub cert_chain: Option<Vec<u8>>,
313}
314
315#[derive(Debug, Clone)]
318pub struct StationEndpoint {
319 pub quic_port: u16,
320 pub host_advertised: Vec<String>,
321}
322
323#[derive(Debug, Clone)]
330pub struct ContentAnnouncement {
331 pub announcer_node: [u8; 32],
332 pub mcid: crate::manifest::Mcid,
333 pub endpoint: String,
337}
338
339#[derive(Debug, PartialEq, Eq)]
340pub enum ReadRecordError {
341 WrongRecordType,
342 MissingField(&'static str),
343 WrongFieldType(&'static str),
344}
345
346impl std::fmt::Display for ReadRecordError {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 match self {
349 ReadRecordError::WrongRecordType => write!(f, "dht: unexpected record type"),
350 ReadRecordError::MissingField(name) => write!(f, "dht: missing field {name:?}"),
351 ReadRecordError::WrongFieldType(name) => {
352 write!(f, "dht: field {name:?} has the wrong type")
353 }
354 }
355 }
356}
357
358impl std::error::Error for ReadRecordError {}
359
360pub fn read_procedure_advertisement(r: &Record) -> Result<ProcedureAdvertisement, ReadRecordError> {
363 if r.record_type != TYPE_PROCEDURE_ADVERTISEMENT {
364 return Err(ReadRecordError::WrongRecordType);
365 }
366 let procedure_uri = match r.payload.get("procedure_uri") {
367 Some(Value::Text(t)) => t.clone(),
368 Some(_) => return Err(ReadRecordError::WrongFieldType("procedure_uri")),
369 None => return Err(ReadRecordError::MissingField("procedure_uri")),
370 };
371 let advertiser_node = bytes32_field(&r.payload, "advertiser_node")?;
372 let serving_station = bytes32_field(&r.payload, "serving_station")?;
373 let cert_chain = match r.payload.get("cert_chain") {
375 Some(Value::Bytes(b)) => Some(b.clone()),
376 _ => None,
377 };
378 Ok(ProcedureAdvertisement {
379 procedure_uri,
380 advertiser_node,
381 serving_station,
382 cert_chain,
383 })
384}
385
386pub fn read_station_endpoint(r: &Record) -> Result<StationEndpoint, ReadRecordError> {
389 if r.record_type != TYPE_STATION_ENDPOINT {
390 return Err(ReadRecordError::WrongRecordType);
391 }
392 let quic_port = match r.payload.get("quic_port") {
393 Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16,
394 Some(_) => return Err(ReadRecordError::WrongFieldType("quic_port")),
395 None => return Err(ReadRecordError::MissingField("quic_port")),
396 };
397 let host_advertised = match r.payload.get("host_advertised") {
405 Some(Value::List(items)) => items
406 .iter()
407 .filter_map(|item| match item {
408 Value::Bytes(b) => String::from_utf8(b.clone()).ok(),
409 Value::Text(t) => Some(t.clone()),
410 _ => None,
411 })
412 .collect(),
413 _ => Vec::new(),
414 };
415 Ok(StationEndpoint {
416 quic_port,
417 host_advertised,
418 })
419}
420
421fn bytes32_field(v: &Value, name: &'static str) -> Result<[u8; 32], ReadRecordError> {
422 match v.get(name) {
423 Some(Value::Bytes(b)) => b
424 .as_slice()
425 .try_into()
426 .map_err(|_| ReadRecordError::WrongFieldType(name)),
427 Some(_) => Err(ReadRecordError::WrongFieldType(name)),
428 None => Err(ReadRecordError::MissingField(name)),
429 }
430}
431
432const DHT_REALM: [u8; 32] = [0u8; 32];
439
440const DHT_TIMEOUT: Duration = Duration::from_secs(5);
442
443const PUT_RECORD_PROC: &str = "_dht.put_record";
444const FIND_RECORD_PROC: &str = "_dht.find_record";
445const FIND_RECORDS_PROC: &str = "_dht.find_records";
446const FIND_RECORDS_BY_TYPE_PROC: &str = "_dht.find_records_by_type";
447
448fn to_rpc_value(r: &Record) -> Value {
453 let mut entries = vec![
454 (Value::text("type"), Value::Int(r.record_type as i128)),
455 (Value::text("key"), Value::Bytes(r.key.to_vec())),
456 (Value::text("version"), Value::Bytes(r.version.to_vec())),
457 (Value::text("created_at"), Value::Int(r.created_at)),
458 (Value::text("expires_at"), Value::Int(r.expires_at)),
459 (Value::text("payload"), r.payload.clone()),
460 ];
461 if r.signature.len() == 64 {
462 entries.push((Value::text("signature"), Value::Bytes(r.signature.clone())));
463 }
464 Value::Map(entries)
465}
466
467#[derive(Debug, PartialEq, Eq)]
468pub enum RecordFromRpcError {
469 MissingField(&'static str),
470 WrongFieldType(&'static str),
471}
472
473impl std::fmt::Display for RecordFromRpcError {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 match self {
476 RecordFromRpcError::MissingField(name) => write!(f, "dht: missing field {name:?}"),
477 RecordFromRpcError::WrongFieldType(name) => {
478 write!(f, "dht: field {name:?} has the wrong type")
479 }
480 }
481 }
482}
483
484impl std::error::Error for RecordFromRpcError {}
485
486fn record_from_rpc_value(v: &Value) -> Result<Record, RecordFromRpcError> {
487 let record_type = match v.get("type") {
488 Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8,
489 Some(_) => return Err(RecordFromRpcError::WrongFieldType("type")),
490 None => return Err(RecordFromRpcError::MissingField("type")),
491 };
492 let key = match v.get("key") {
493 Some(Value::Bytes(b)) => b
494 .as_slice()
495 .try_into()
496 .map_err(|_| RecordFromRpcError::WrongFieldType("key"))?,
497 Some(_) => return Err(RecordFromRpcError::WrongFieldType("key")),
498 None => return Err(RecordFromRpcError::MissingField("key")),
499 };
500 let version = match v.get("version") {
501 Some(Value::Bytes(b)) => b
502 .as_slice()
503 .try_into()
504 .map_err(|_| RecordFromRpcError::WrongFieldType("version"))?,
505 Some(_) => return Err(RecordFromRpcError::WrongFieldType("version")),
506 None => return Err(RecordFromRpcError::MissingField("version")),
507 };
508 let created_at = match v.get("created_at") {
509 Some(Value::Int(n)) => *n,
510 Some(_) => return Err(RecordFromRpcError::WrongFieldType("created_at")),
511 None => return Err(RecordFromRpcError::MissingField("created_at")),
512 };
513 let expires_at = match v.get("expires_at") {
514 Some(Value::Int(n)) => *n,
515 Some(_) => return Err(RecordFromRpcError::WrongFieldType("expires_at")),
516 None => return Err(RecordFromRpcError::MissingField("expires_at")),
517 };
518 let payload = v
519 .get("payload")
520 .cloned()
521 .ok_or(RecordFromRpcError::MissingField("payload"))?;
522 let signature = match v.get("signature") {
523 Some(Value::Bytes(b)) => b.clone(),
524 _ => Vec::new(),
525 };
526 Ok(Record {
527 record_type,
528 key,
529 version,
530 created_at,
531 expires_at,
532 payload,
533 signature,
534 })
535}
536
537#[derive(Debug)]
538pub enum DhtError {
539 Call(CallError),
540 Remote(String),
542 NotFound,
543 Malformed(RecordFromRpcError),
544 ExpectedList,
547}
548
549impl std::fmt::Display for DhtError {
550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551 match self {
552 DhtError::Call(e) => write!(f, "dht: {e}"),
553 DhtError::Remote(name) => write!(f, "dht: station reported {name}"),
554 DhtError::NotFound => write!(f, "dht: record not found"),
555 DhtError::Malformed(e) => write!(f, "dht: {e}"),
556 DhtError::ExpectedList => write!(f, "dht: expected a list reply"),
557 }
558 }
559}
560
561impl std::error::Error for DhtError {}
562
563fn deadline_ms(timeout: Duration) -> i128 {
564 now_ms() + timeout.as_millis() as i128
565}
566
567pub async fn put_record(session: &mut Session, id: &KeyPair, rec: &Record) -> Result<(), DhtError> {
570 let resp = session
571 .call(
572 PUT_RECORD_PROC,
573 DHT_REALM,
574 to_rpc_value(rec),
575 deadline_ms(DHT_TIMEOUT),
576 id,
577 DHT_TIMEOUT,
578 )
579 .await
580 .map_err(DhtError::Call)?;
581 match resp {
582 CallResponse::Result { .. } => Ok(()),
583 CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
584 }
585}
586
587pub async fn find_record(
593 session: &mut Session,
594 id: &KeyPair,
595 key: [u8; 32],
596) -> Result<Record, DhtError> {
597 let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]);
598 let resp = session
599 .call(
600 FIND_RECORD_PROC,
601 DHT_REALM,
602 args,
603 deadline_ms(DHT_TIMEOUT),
604 id,
605 DHT_TIMEOUT,
606 )
607 .await
608 .map_err(DhtError::Call)?;
609 match resp {
610 CallResponse::Result { payload, .. } => {
611 if matches!(&payload, Value::Text(t) if t == "not_found") {
612 return Err(DhtError::NotFound);
613 }
614 record_from_rpc_value(&payload).map_err(DhtError::Malformed)
615 }
616 CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
617 }
618}
619
620pub async fn find_records(
625 session: &mut Session,
626 id: &KeyPair,
627 key: [u8; 32],
628) -> Result<Vec<Record>, DhtError> {
629 let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]);
630 let resp = session
631 .call(
632 FIND_RECORDS_PROC,
633 DHT_REALM,
634 args,
635 deadline_ms(DHT_TIMEOUT),
636 id,
637 DHT_TIMEOUT,
638 )
639 .await
640 .map_err(DhtError::Call)?;
641 records_list_from_response(resp)
642}
643
644pub async fn find_records_by_type(
648 session: &mut Session,
649 id: &KeyPair,
650 typ: u8,
651) -> Result<Vec<Record>, DhtError> {
652 let args = Value::Map(vec![(Value::text("type"), Value::Int(typ as i128))]);
653 let resp = session
654 .call(
655 FIND_RECORDS_BY_TYPE_PROC,
656 DHT_REALM,
657 args,
658 deadline_ms(DHT_TIMEOUT),
659 id,
660 DHT_TIMEOUT,
661 )
662 .await
663 .map_err(DhtError::Call)?;
664 records_list_from_response(resp)
665}
666
667fn records_list_from_response(resp: CallResponse) -> Result<Vec<Record>, DhtError> {
668 match resp {
669 CallResponse::Result { payload, .. } => match payload {
670 Value::List(items) => Ok(items
671 .iter()
672 .filter_map(|item| record_from_rpc_value(item).ok())
673 .collect()),
674 _ => Err(DhtError::ExpectedList),
675 },
676 CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 fn sample_advertisement(id: &KeyPair) -> Record {
685 let station: [u8; 32] = [7u8; 32];
686 let uri = discovery_uri([0u8; 32], "test.procedure");
687 let rec = new_procedure_advertisement(id.node_id(), uri, station, DEFAULT_TTL);
688 sign(rec, id)
689 }
690
691 #[test]
692 fn sign_then_verify_round_trips() {
693 let id = KeyPair::generate();
694 let rec = sample_advertisement(&id);
695 assert_eq!(rec.signature.len(), 64);
696 assert!(verify(&rec).is_ok());
697 }
698
699 #[test]
700 fn verify_rejects_a_tampered_payload() {
701 let id = KeyPair::generate();
702 let mut rec = sample_advertisement(&id);
703 rec.record_type = TYPE_STATION_ENDPOINT;
706 assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature));
707 }
708
709 #[test]
710 fn verify_rejects_a_signature_from_the_wrong_signer() {
711 let signer = KeyPair::generate();
712 let mut rec = sample_advertisement(&signer);
713 rec.key = KeyPair::generate().public_bytes();
717 assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature));
718 }
719
720 #[test]
721 fn verify_rejects_an_expired_record() {
722 let id = KeyPair::generate();
723 let station: [u8; 32] = [7u8; 32];
724 let uri = discovery_uri([0u8; 32], "test.procedure");
725 let rec = new_procedure_advertisement(id.node_id(), uri, station, Duration::from_millis(1));
727 std::thread::sleep(Duration::from_millis(20));
728 let rec = sign(rec, &id);
729 assert_eq!(verify(&rec), Err(VerifyError::Expired));
730 }
731
732 #[test]
733 fn canonical_unsigned_is_deterministic() {
734 let id = KeyPair::generate();
735 let rec = sample_advertisement(&id);
736 assert_eq!(canonical_unsigned(&rec), canonical_unsigned(&rec));
740 }
741
742 #[test]
743 fn procedure_key_differs_by_realm() {
744 let a = procedure_key(&discovery_uri([0u8; 32], "same.name"));
745 let b = procedure_key(&discovery_uri([1u8; 32], "same.name"));
746 assert_ne!(
747 a, b,
748 "the same bare procedure name under different realms must not collide"
749 );
750 }
751
752 #[test]
753 fn discovery_uri_matches_expected_hex_format() {
754 let uri = discovery_uri([0u8; 32], "hecate_mail.initiate_mailbox");
755 assert_eq!(
756 uri,
757 format!("{}/hecate_mail.initiate_mailbox", "00".repeat(32))
758 );
759 }
760
761 #[test]
762 fn read_procedure_advertisement_round_trips_the_payload() {
763 let id = KeyPair::generate();
764 let station: [u8; 32] = [9u8; 32];
765 let uri = "0".repeat(64) + "/some.procedure";
766 let rec = new_procedure_advertisement(id.node_id(), uri.clone(), station, DEFAULT_TTL);
767 let read = read_procedure_advertisement(&rec).expect("should read back cleanly");
768 assert_eq!(read.procedure_uri, uri);
769 assert_eq!(read.advertiser_node, id.node_id());
770 assert_eq!(read.serving_station, station);
771 }
772
773 #[test]
774 fn read_procedure_advertisement_rejects_the_wrong_record_type() {
775 let id = KeyPair::generate();
776 let station: [u8; 32] = [9u8; 32];
777 let mut rec = new_procedure_advertisement(id.node_id(), "x/y", station, DEFAULT_TTL);
778 rec.record_type = TYPE_STATION_ENDPOINT;
779 assert!(matches!(
780 read_procedure_advertisement(&rec),
781 Err(ReadRecordError::WrongRecordType)
782 ));
783 }
784
785 #[test]
786 fn station_endpoint_host_advertised_reads_byte_string_entries() {
787 let rec = Record {
794 record_type: TYPE_STATION_ENDPOINT,
795 key: [1u8; 32],
796 version: [0u8; 16],
797 created_at: 0,
798 expires_at: 0,
799 payload: Value::Map(vec![
800 (Value::text("quic_port"), Value::Int(4433)),
801 (
802 Value::text("host_advertised"),
803 Value::List(vec![Value::Bytes(b"203.0.113.5".to_vec())]),
804 ),
805 ]),
806 signature: Vec::new(),
807 };
808 let ep = read_station_endpoint(&rec).expect("should read the byte-string host");
809 assert_eq!(ep.quic_port, 4433);
810 assert_eq!(ep.host_advertised, vec!["203.0.113.5".to_string()]);
811 }
812
813 #[test]
814 fn to_rpc_value_and_record_from_rpc_value_round_trip() {
815 let id = KeyPair::generate();
816 let rec = sample_advertisement(&id);
817 let rpc_value = to_rpc_value(&rec);
818 let back = record_from_rpc_value(&rpc_value).expect("should decode cleanly");
819 assert_eq!(back.record_type, rec.record_type);
820 assert_eq!(back.key, rec.key);
821 assert_eq!(back.version, rec.version);
822 assert_eq!(back.created_at, rec.created_at);
823 assert_eq!(back.expires_at, rec.expires_at);
824 assert_eq!(back.signature, rec.signature);
825 assert!(verify(&back).is_ok());
828 }
829}