1mod authorization;
16mod content_announcement;
17mod node_record;
18mod payload;
19mod procedure_advertisement;
20mod station_authorization;
21mod station_endpoint;
22mod storage_key;
23mod tombstone;
24#[cfg(test)]
25mod wire_1_0_tests;
26
27pub use authorization::{
28 in_own_namespace, namespace_node, own_namespace, own_procedure, procedure_org,
29 read_org_directory, read_procedure_delegation, verify_authorization, OrgDirectory,
30 ProcedureDelegation, Trust, OWN_NAMESPACE_PREFIX,
31};
32pub use content_announcement::{
33 new_content_announcement, read_content_announcement, ContentAnnouncement,
34 ContentAnnouncementOptions,
35};
36pub use node_record::{new_node_record, read_node_record, NodeRecord, NodeRecordOptions};
37pub use procedure_advertisement::{
38 new_procedure_advertisement, read_procedure_advertisement, Authorization,
39 ProcedureAdvertisement, ProcedureAdvertisementOptions,
40};
41pub use station_authorization::{read_station_authorization, StationAuthorization};
42pub use station_endpoint::{
43 new_station_endpoint, read_station_endpoint, StationEndpoint, StationEndpointOptions,
44};
45pub use storage_key::{
46 app_record_key, content_key, org_directory_key, procedure_delegation_key, procedure_key,
47 station_authorization_key, station_endpoint_key, storage_key,
48};
49pub use tombstone::{new_tombstone, read_tombstone, Reason, Tombstone, TombstoneOptions};
50
51use std::fmt;
52
53use crate::cbor::{self, Value};
54use crate::node_key::{key_id_of, node_id_of, signature_size, KeyError, NodeKey, Purpose};
55use crate::profile::Profile;
56use crate::signed_object::{sign_object, verify_object_by_alg, Object, ObjectError};
57
58const LABEL: &str = "MACULA-PQ-RECORD-V1";
59
60pub const MAX_RECORD_BYTES: usize = 256 * 1024;
62
63pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS;
66
67pub const MAX_ENDORSEMENT_WINDOW_MS: u64 = 30 * DAY_MS;
69
70pub const SCHEMA: u16 = 1;
73
74pub const LIFETIME_CLASS_MAX_MS: [u64; 6] = [
78 5 * MINUTE_MS,
79 30 * MINUTE_MS,
80 6 * HOUR_MS,
81 48 * HOUR_MS,
82 7 * DAY_MS,
83 30 * DAY_MS,
84];
85
86const MAX_PROTOCOL_INT: u64 = 1 << 53;
87const MAX_PAYLOAD_NESTING: usize = 63;
88const MINUTE_MS: u64 = 60 * 1000;
89const HOUR_MS: u64 = 60 * MINUTE_MS;
90const DAY_MS: u64 = 24 * HOUR_MS;
91const DEFAULT_TTL_MS: u64 = 48 * HOUR_MS;
92
93const ROW_5_MINUTES: u8 = 0;
95const ROW_30_MINUTES: u8 = 1;
96const ROW_6_HOURS: u8 = 2;
97const ROW_48_HOURS: u8 = 3;
98const ROW_7_DAYS: u8 = 4;
99const ROW_30_DAYS: u8 = 5;
100
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
103pub struct RecordType(pub u8);
104
105impl RecordType {
106 pub const NODE_RECORD: RecordType = RecordType(0x01);
107 pub const REALM_DIRECTORY: RecordType = RecordType(0x03);
108 pub const REALM_STATIONS: RecordType = RecordType(0x04);
109 pub const REALM_MEMBER_ENDORSEMENT: RecordType = RecordType(0x05);
110 pub const PROCEDURE_ADVERTISEMENT: RecordType = RecordType(0x06);
111 pub const TOMBSTONE: RecordType = RecordType(0x0C);
112 pub const FOUNDATION_SEED_LIST: RecordType = RecordType(0x0D);
113 pub const FOUNDATION_PARAMETER: RecordType = RecordType(0x0E);
114 pub const FOUNDATION_REALM_TRUST_LIST: RecordType = RecordType(0x0F);
115 pub const FOUNDATION_T3_ATTESTATION: RecordType = RecordType(0x10);
116 pub const CONTENT_ANNOUNCEMENT: RecordType = RecordType(0x11);
117 pub const STATION_ENDPOINT: RecordType = RecordType(0x12);
118 pub const STATION_AUTHORIZATION: RecordType = RecordType(0x13);
119 pub const ORG_DIRECTORY: RecordType = RecordType(0x15);
120 pub const PROCEDURE_DELEGATION: RecordType = RecordType(0x16);
121 pub const APP_RECORD: RecordType = RecordType(0x17);
122 pub const DOMAIN_MIN: RecordType = RecordType(0x20);
125
126 fn is_domain(self) -> bool {
127 self >= RecordType::DOMAIN_MIN
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RecordError {
134 TooLarge,
136 Malformed(String),
139 NotYetValid,
141 Expired,
143 KeyIdMismatch,
145 LifetimeTooLong,
147 LifetimeReversed,
149 KeyPurposeMismatch,
151 Unsigned,
153 NotADomainType,
155 InvalidSubject,
157 SignatureInvalid,
159 AlgUnsupported,
162 LifetimeClassUnknown,
164 LifetimeClassMismatch,
166 SubjectMissing,
169 TypeUnknown,
171 SchemaUnsupported,
173 McidTagUnknown,
176 ExtensionUnsupported(String),
178 InvalidCoordinate(String),
180 InvalidPort,
182 NotAContentId,
184 TombstoneOfATombstone,
186 NoAuthorization,
188 AuthorizationNotAllowed,
190 AuthorizationFormUnsupported,
193 NotOwnNamespace,
195 NoRealmKey,
197 OrgDirectoryInvalid(String),
199 OrgDirectoryWrongRealm,
202 OrgDirectoryWrongOrg,
204 DelegationInvalid(String),
206 DelegationMismatch,
208 AuthorizationOutlived,
210 Key(KeyError),
212}
213
214impl fmt::Display for RecordError {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 match self {
217 RecordError::Malformed(why) => write!(f, "malformed record: {why}"),
218 RecordError::InvalidCoordinate(what) => {
219 write!(f, "a coordinate outside its range: {what}")
220 }
221 RecordError::OrgDirectoryInvalid(why) => {
222 write!(f, "the org directory does not verify: {why}")
223 }
224 RecordError::DelegationInvalid(why) => {
225 write!(f, "the procedure delegation does not verify: {why}")
226 }
227 RecordError::Key(e) => write!(f, "{e}"),
228 other => write!(f, "{other:?}"),
229 }
230 }
231}
232
233impl std::error::Error for RecordError {}
234
235fn malformed(why: impl Into<String>) -> RecordError {
236 RecordError::Malformed(why.into())
237}
238
239#[derive(Debug, Clone, PartialEq)]
242pub struct Signed {
243 pub key: Vec<u8>,
244 pub key_id: [u8; 32],
245 pub alg: String,
246 pub tbs: Vec<u8>,
247 pub signature: Vec<u8>,
248}
249
250#[derive(Debug, Clone, PartialEq)]
257pub struct Record {
258 pub record_type: RecordType,
259 pub version: [u8; 16],
260 pub created_at: u64,
261 pub expires_at: u64,
262 pub payload: Value,
263 pub subject: Option<Vec<u8>>,
264 pub schema: u16,
265 pub lifetime_class: u8,
266 pub ext: Option<Value>,
267 pub signed: Option<Signed>,
268}
269
270fn unsigned(record_type: RecordType, payload: Value, ttl_ms: u64) -> Record {
273 let ttl_ms = if ttl_ms == 0 {
274 default_ttl(record_type)
275 } else {
276 ttl_ms
277 };
278 let now = crate::uuid_v7::now_ms();
279 let (lifetime_class, _) = type_row(i128::from(record_type.0), &payload);
280 Record {
281 record_type,
282 version: crate::uuid_v7::new(),
283 created_at: now,
284 expires_at: now + ttl_ms,
285 payload,
286 subject: None,
287 schema: SCHEMA,
288 lifetime_class,
289 ext: None,
290 signed: None,
291 }
292}
293
294pub fn envelope(
298 record_type: u8,
299 payload: Value,
300 subject: Option<Vec<u8>>,
301 ttl_ms: u64,
302) -> Result<Record, RecordError> {
303 let t = RecordType(record_type);
304 if !t.is_domain() {
305 return Err(RecordError::NotADomainType);
306 }
307 if subject.as_ref().is_some_and(|s| s.is_empty()) {
308 return Err(RecordError::InvalidSubject);
309 }
310 let mut r = unsigned(t, payload, ttl_ms);
311 r.subject = subject;
312 Ok(r)
313}
314
315pub fn sign(r: &Record, key: &NodeKey) -> Result<Record, RecordError> {
321 let profile = key.profile();
322 if !signer_may_sign(r.record_type.0, &r.payload, key.purpose()) {
323 return Err(RecordError::KeyPurposeMismatch);
324 }
325 lifetime(r)?;
326 let carried = key.public_key();
327 let key_id = key_id_by_kind(r.record_type.0, &r.payload, &carried, profile);
328 if !named_signer(r.record_type, &r.payload, &key_id) {
329 return Err(RecordError::KeyIdMismatch);
330 }
331 let fields = tbs_fields(r);
332 let mut with_alg = fields.clone();
333 with_alg.push((Value::text("alg"), Value::text(profile.sig_alg())));
334 let encoded = cbor::encode(&Value::Map(with_alg)).map_err(|e| malformed(e.to_string()))?;
335 let tbs = cbor::decode(&encoded)
336 .map_err(|e| malformed(format!("a tbs the decoding rule refuses: {e}")))?;
337 let read = read_envelope_tbs(&tbs).ok_or_else(|| malformed("a tbs of another shape"))?;
338 if reader_knows(&read)
339 .and_then(|()| payload_checks(&read))
340 .is_err()
341 {
342 return Err(malformed("a record a verifier would refuse"));
343 }
344 let unsigned_size = cbor::encode(&Value::Map(fields.clone()))
345 .map_err(|e| malformed(e.to_string()))?
346 .len()
347 + carried.len()
348 + signature_size(profile);
349 if unsigned_size > MAX_RECORD_BYTES {
350 return Err(RecordError::TooLarge);
351 }
352 let object = sign_object(LABEL, &fields, key).map_err(|e| match e {
353 ObjectError::Key(k) => RecordError::Key(k),
354 other => malformed(other.to_string()),
355 })?;
356 let wire = cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))?;
357 if wire.len() > MAX_RECORD_BYTES {
358 return Err(RecordError::TooLarge);
359 }
360 let mut out = r.clone();
361 out.signed = Some(Signed {
362 key: object.key,
363 key_id,
364 alg: profile.sig_alg().to_string(),
365 tbs: object.tbs,
366 signature: object.signature,
367 });
368 Ok(out)
369}
370
371pub fn refresh(r: &Record, key: &NodeKey) -> Result<Record, RecordError> {
374 let now = crate::uuid_v7::now_ms();
375 let fresh = Record {
376 version: crate::uuid_v7::new(),
377 created_at: now,
378 expires_at: now + r.expires_at.saturating_sub(r.created_at),
379 signed: None,
380 ..r.clone()
381 };
382 sign(&fresh, key)
383}
384
385pub fn encode(r: &Record) -> Result<Vec<u8>, RecordError> {
388 let signed = r.signed.as_ref().ok_or(RecordError::Unsigned)?;
389 let object = Object {
390 key: signed.key.clone(),
391 tbs: signed.tbs.clone(),
392 signature: signed.signature.clone(),
393 };
394 cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))
395}
396
397#[derive(Debug, Clone, PartialEq)]
399pub struct Verified(Record);
400
401impl Verified {
402 pub fn record(&self) -> &Record {
404 &self.0
405 }
406
407 pub fn into_record(self) -> Record {
409 self.0
410 }
411}
412
413pub fn verify(wire: &[u8], profile: Profile, now_ms: i64) -> Result<Verified, RecordError> {
420 let r = custodian_checks(wire, profile, now_ms)?;
421 reader_knows(&r)?;
422 if let Some(ext) = &r.ext {
423 crate::frame::act_on_ext(ext).map_err(|e| match e {
424 crate::frame::FrameError::ExtensionUnsupported(name) => {
425 RecordError::ExtensionUnsupported(name)
426 }
427 other => malformed(other.to_string()),
428 })?;
429 }
430 payload_checks(&r)?;
431 let key_id = r.signed.as_ref().map(|s| s.key_id).unwrap_or_default();
432 if !named_signer(r.record_type, &r.payload, &key_id) {
433 return Err(RecordError::KeyIdMismatch);
434 }
435 Ok(Verified(r))
436}
437
438pub fn check_as_custodian(
450 wire: &[u8],
451 profile: Profile,
452 now_ms: i64,
453) -> Result<[u8; 32], RecordError> {
454 custodian_slot(&custodian_checks(wire, profile, now_ms)?)
455}
456
457fn custodian_checks(wire: &[u8], profile: Profile, now_ms: i64) -> Result<Record, RecordError> {
460 if wire.len() > MAX_RECORD_BYTES {
461 return Err(RecordError::TooLarge);
462 }
463 let value = cbor::decode(wire).map_err(|e| malformed(e.to_string()))?;
464 let object = Object::from_value(&value).map_err(|e| malformed(e.to_string()))?;
465 let verified = verify_object_by_alg(LABEL, &value, profile).map_err(|e| match e {
466 ObjectError::SignatureInvalid => RecordError::SignatureInvalid,
467 ObjectError::AlgUnsupported(_) => RecordError::AlgUnsupported,
468 other => malformed(other.to_string()),
469 })?;
470 let mut r =
471 read_envelope_tbs(&verified.fields).ok_or_else(|| malformed("a tbs of another shape"))?;
472 clock(&r, now_ms)?;
473 lifetime(&r)?;
474 let (row, known) = type_row(i128::from(r.record_type.0), &r.payload);
475 if known && r.lifetime_class != row {
476 return Err(RecordError::LifetimeClassMismatch);
477 }
478 let key_id = key_id_by_kind(r.record_type.0, &r.payload, &verified.key, profile);
479 r.signed = Some(Signed {
480 key: verified.key,
481 key_id,
482 alg: profile.sig_alg().to_string(),
483 tbs: verified.tbs,
484 signature: object.signature,
485 });
486 custodian_slot(&r)?;
487 Ok(r)
488}
489
490fn custodian_slot(r: &Record) -> Result<[u8; 32], RecordError> {
495 if type_row(i128::from(r.record_type.0), &r.payload).1 {
496 return storage_key(r).map_err(|e| match e {
497 RecordError::Malformed(why) => RecordError::Malformed(why),
498 other => malformed(other.to_string()),
499 });
500 }
501 let (t, subject) = match (r.record_type, withdrawn_type(&r.payload)) {
502 (RecordType::TOMBSTONE, Some(Some(w))) => {
503 let t = u8::try_from(w).map_err(|_| malformed("a withdrawn_type beyond 255"))?;
504 let subject = match r.payload.get("subject") {
505 None => None,
506 Some(Value::Bytes(s)) => Some(s.as_slice()),
507 Some(_) => return Err(malformed("a tombstone's subject that is not bytes")),
508 };
509 (t, subject)
510 }
511 _ => (r.record_type.0, r.subject.as_deref()),
512 };
513 let subject = subject.ok_or(RecordError::SubjectMissing)?;
514 let key_id = r.signed.as_ref().ok_or(RecordError::Unsigned)?.key_id;
515 Ok(storage_key::unknown_type_key(t, &key_id, subject))
516}
517
518fn reader_knows(r: &Record) -> Result<(), RecordError> {
520 if !type_row(i128::from(r.record_type.0), &r.payload).1 {
521 return Err(RecordError::TypeUnknown);
522 }
523 if r.schema != SCHEMA {
524 return Err(RecordError::SchemaUnsupported);
525 }
526 Ok(())
527}
528
529fn payload_checks(r: &Record) -> Result<(), RecordError> {
532 let lifetime_class_is_own =
533 type_row(i128::from(r.record_type.0), &r.payload).0 == r.lifetime_class;
534 if (r.subject.is_some() && !r.record_type.is_domain())
535 || !payload::payload_ok(r.record_type, &r.payload)
536 || !lifetime_class_is_own
537 {
538 return Err(malformed("a payload its type's rules refuse"));
539 }
540 payload::content_id_tag_known(r.record_type, &r.payload)
541}
542
543fn clock(r: &Record, now_ms: i64) -> Result<(), RecordError> {
546 let tolerance = CLOCK_TOLERANCE_MS as i64;
547 if r.created_at as i64 > now_ms + tolerance {
548 return Err(RecordError::NotYetValid);
549 }
550 if (r.expires_at as i64) + tolerance < now_ms {
551 return Err(RecordError::Expired);
552 }
553 Ok(())
554}
555
556pub fn payload_bounded(payload: &Value) -> Result<(), RecordError> {
560 let size = cbor::encode(payload)
561 .map_err(|e| malformed(e.to_string()))?
562 .len();
563 if size > MAX_RECORD_BYTES {
564 return Err(RecordError::TooLarge);
565 }
566 if nesting(payload, 0) > MAX_PAYLOAD_NESTING {
567 return Err(malformed("a payload nested past 63 levels"));
568 }
569 Ok(())
570}
571
572fn nesting(v: &Value, depth: usize) -> usize {
573 let children: Vec<&Value> = match v {
574 Value::List(items) => items.iter().collect(),
575 Value::Map(pairs) => pairs.iter().flat_map(|(k, v)| [k, v]).collect(),
576 _ => return depth,
577 };
578 let mut deepest = depth + 1;
579 for child in children {
580 if deepest > MAX_PAYLOAD_NESTING {
581 break;
582 }
583 deepest = deepest.max(nesting(child, depth + 1));
584 }
585 deepest
586}
587
588fn tbs_fields(r: &Record) -> Vec<(Value, Value)> {
589 let mut fields = vec![
590 (Value::text("type"), Value::Int(i128::from(r.record_type.0))),
591 (Value::text("version"), Value::Bytes(r.version.to_vec())),
592 (
593 Value::text("created_at"),
594 Value::Int(i128::from(r.created_at)),
595 ),
596 (
597 Value::text("expires_at"),
598 Value::Int(i128::from(r.expires_at)),
599 ),
600 (Value::text("payload"), r.payload.clone()),
601 (Value::text("schema"), Value::Int(i128::from(r.schema))),
602 (
603 Value::text("lifetime_class"),
604 Value::Int(i128::from(r.lifetime_class)),
605 ),
606 ];
607 if let Some(subject) = &r.subject {
608 fields.push((Value::text("subject"), Value::Bytes(subject.clone())));
609 }
610 if let Some(ext) = &r.ext {
611 fields.push((Value::text("ext"), ext.clone()));
612 }
613 fields
614}
615
616fn read_envelope_tbs(tbs: &Value) -> Option<Record> {
622 let Value::Map(pairs) = tbs else {
623 return None;
624 };
625 let field = |name: &str| tbs.get(name);
626 let record_type = match field("type")? {
627 Value::Int(n) if (1..=255).contains(n) => RecordType(*n as u8),
628 _ => return None,
629 };
630 let Value::Text(_) = field("alg")? else {
631 return None;
632 };
633 let version: [u8; 16] = match field("version")? {
634 Value::Bytes(b) => b.as_slice().try_into().ok()?,
635 _ => return None,
636 };
637 let schema = match field("schema")? {
638 Value::Int(n) if (1..=65535).contains(n) => *n as u16,
639 _ => return None,
640 };
641 let lifetime_class = protocol_uint(field("lifetime_class")?)?;
642 let payload = field("payload")?;
643 if !matches!(payload, Value::Map(_)) {
644 return None;
645 }
646 let subject = match field("subject") {
647 None => None,
648 Some(Value::Bytes(s)) if !s.is_empty() => Some(s.clone()),
649 Some(_) => return None,
650 };
651 let ext = match field("ext") {
652 None => None,
653 Some(ext) => {
654 crate::frame::check_ext(ext).ok()?;
655 Some(ext.clone())
656 }
657 };
658 let size = 8 + usize::from(subject.is_some()) + usize::from(ext.is_some());
659 if pairs.len() != size {
660 return None;
661 }
662 Some(Record {
663 record_type,
664 version,
665 created_at: protocol_uint(field("created_at")?)?,
666 expires_at: protocol_uint(field("expires_at")?)?,
667 payload: payload.clone(),
668 subject,
669 schema,
670 lifetime_class: u8::try_from(lifetime_class).unwrap_or(u8::MAX),
671 ext,
672 signed: None,
673 })
674}
675
676fn protocol_uint(v: &Value) -> Option<u64> {
677 match v {
678 Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64),
679 _ => None,
680 }
681}
682
683fn lifetime(r: &Record) -> Result<(), RecordError> {
689 let lived = r.expires_at as i128 - r.created_at as i128;
690 if lived <= 0 {
691 return Err(RecordError::LifetimeReversed);
692 }
693 let Some(max) = LIFETIME_CLASS_MAX_MS.get(usize::from(r.lifetime_class)) else {
694 return Err(RecordError::LifetimeClassUnknown);
695 };
696 let margin = match r.record_type {
697 RecordType::TOMBSTONE => 2 * CLOCK_TOLERANCE_MS,
698 _ => 0,
699 };
700 if lived > i128::from(max + margin) {
701 return Err(RecordError::LifetimeTooLong);
702 }
703 Ok(())
704}
705
706fn withdrawn_type(payload: &Value) -> Option<Option<i128>> {
708 match payload.get("withdrawn_type") {
709 None => None,
710 Some(Value::Int(n)) => Some(Some(*n)),
711 Some(_) => Some(None),
712 }
713}
714
715fn type_row(t: i128, payload: &Value) -> (u8, bool) {
723 match t {
724 0x01 | 0x11 => (ROW_48_HOURS, true),
725 0x06 | 0x12 => (ROW_5_MINUTES, true),
726 0x04 | 0x15 => (ROW_6_HOURS, true),
727 0x13 | 0x16 | 0x17 => (ROW_30_MINUTES, true),
728 0x03 | 0x05 | 0x0D..=0x10 => (ROW_30_DAYS, true),
729 0x0C => match withdrawn_type(payload) {
730 Some(Some(w)) if w != 0x0C => type_row(w, &Value::Map(Vec::new())),
731 _ => (ROW_30_DAYS, true),
732 },
733 0x20..=0xFF => (ROW_7_DAYS, true),
734 _ => (ROW_30_DAYS, false),
735 }
736}
737
738fn default_ttl(t: RecordType) -> u64 {
741 let (row, _) = type_row(i128::from(t.0), &Value::Map(Vec::new()));
742 DEFAULT_TTL_MS.min(LIFETIME_CLASS_MAX_MS[usize::from(row)])
743}
744
745fn signer_may_sign(t: u8, payload: &Value, purpose: Purpose) -> bool {
751 match t {
752 0x0C => match withdrawn_type(payload) {
753 Some(Some(w)) if (0..=255).contains(&w) && w != 0x0C => {
754 signer_may_sign(w as u8, &Value::Map(Vec::new()), purpose)
755 }
756 _ => false,
757 },
758 0x01 | 0x06 | 0x11 | 0x12 => purpose == Purpose::Identity,
759 t if t >= 0x20 => purpose == Purpose::Identity,
760 _ => false,
761 }
762}
763
764fn signed_by_some_key(t: i128) -> bool {
767 matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x13 | 0x15..=0x17) || t >= 0x20
768}
769
770fn named_by_node_id(t: u8, payload: &Value) -> bool {
773 let t = if t == 0x0C {
774 match withdrawn_type(payload) {
775 Some(Some(w)) => w,
776 _ => return false,
777 }
778 } else {
779 i128::from(t)
780 };
781 matches!(t, 0x01 | 0x06 | 0x11 | 0x12)
782}
783
784fn key_id_by_kind(t: u8, payload: &Value, carried: &[u8], profile: Profile) -> [u8; 32] {
785 if named_by_node_id(t, payload) {
786 node_id_of(carried, profile)
787 } else {
788 key_id_of(carried, profile)
789 }
790}
791
792fn named_signer(t: RecordType, payload: &Value, key_id: &[u8; 32]) -> bool {
795 let name = match t {
796 RecordType::NODE_RECORD => "node_id",
797 RecordType::PROCEDURE_ADVERTISEMENT => "advertiser_node",
798 RecordType::CONTENT_ANNOUNCEMENT => "announcer_node",
799 RecordType::PROCEDURE_DELEGATION => "org_key",
800 _ => return true,
801 };
802 matches!(payload.get(name), Some(Value::Bytes(b)) if b.as_slice() == key_id)
803}
804
805fn id_field(payload: &Value, name: &str) -> [u8; 32] {
807 match payload.get(name) {
808 Some(Value::Bytes(b)) if b.len() == 32 => b.as_slice().try_into().unwrap_or([0; 32]),
809 _ => [0; 32],
810 }
811}
812
813fn text_field(payload: &Value, name: &str) -> String {
814 match payload.get(name) {
815 Some(Value::Text(t)) => t.clone(),
816 _ => String::new(),
817 }
818}
819
820fn entry(name: &str, value: Value) -> (Value, Value) {
821 (Value::text(name), value)
822}