Skip to main content

macula_rust/
record.rs

1//! macula 12's DHT records, as macula_record and macula-go sign and verify
2//! them. A record is the signed object `{key, tbs, signature}` under
3//! MACULA-PQ-RECORD-V1; its tbs holds type, alg, version, created_at,
4//! expires_at and payload, and subject only on a domain type (tags 0x20 to
5//! 0xFF). [`sign`] refuses a key whose purpose does not fit the type; [`verify`]
6//! reads a record's wire form in the design's order and keeps its tbs bytes,
7//! so [`encode`] sends them unchanged.
8//!
9//! A record is named by the key id of its key: the node_id for node records,
10//! procedure advertisements, content announcements and station endpoints, and
11//! the key id for every other type. A tombstone is named as the type it
12//! withdraws.
13
14mod authorization;
15mod content_announcement;
16mod node_record;
17mod payload;
18mod procedure_advertisement;
19mod station_endpoint;
20mod storage_key;
21mod tombstone;
22
23pub use authorization::{
24    in_own_namespace, namespace_node, own_namespace, own_procedure, procedure_org,
25    read_org_directory, read_procedure_delegation, verify_authorization, OrgDirectory,
26    ProcedureDelegation, Trust, OWN_NAMESPACE_PREFIX,
27};
28pub use content_announcement::{
29    new_content_announcement, read_content_announcement, ContentAnnouncement,
30    ContentAnnouncementOptions,
31};
32pub use node_record::{new_node_record, read_node_record, NodeRecord, NodeRecordOptions};
33pub use procedure_advertisement::{
34    new_procedure_advertisement, read_procedure_advertisement, Authorization,
35    ProcedureAdvertisement, ProcedureAdvertisementOptions,
36};
37pub use station_endpoint::{
38    new_station_endpoint, read_station_endpoint, StationEndpoint, StationEndpointOptions,
39};
40pub use storage_key::{
41    content_key, org_directory_key, procedure_delegation_key, procedure_key, station_endpoint_key,
42    storage_key,
43};
44pub use tombstone::{new_tombstone, read_tombstone, Reason, Tombstone, TombstoneOptions};
45
46use std::fmt;
47
48use crate::cbor::{self, Value};
49use crate::node_key::{key_id_of, node_id_of, signature_size, KeyError, NodeKey, Purpose};
50use crate::profile::Profile;
51use crate::signed_object::{sign_object, verify_object, Object, ObjectError};
52
53const LABEL: &str = "MACULA-PQ-RECORD-V1";
54
55/// The longest wire form a record may have, 256 KiB.
56pub const MAX_RECORD_BYTES: usize = 256 * 1024;
57
58/// How far a verifier's clock may be from a record's created_at and
59/// expires_at, 5 minutes.
60pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS;
61
62/// The longest a realm member endorsement admits its member, 30 days.
63pub const MAX_ENDORSEMENT_WINDOW_MS: u64 = 30 * DAY_MS;
64
65const MAX_PROTOCOL_INT: u64 = 1 << 53;
66const MAX_PAYLOAD_NESTING: usize = 63;
67const MINUTE_MS: u64 = 60 * 1000;
68const HOUR_MS: u64 = 60 * MINUTE_MS;
69const DAY_MS: u64 = 24 * HOUR_MS;
70
71/// The longest a record of a type lives (D28).
72const NODE_RECORD_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS;
73const CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS;
74const PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS: u64 = 5 * MINUTE_MS;
75const STATION_ENDPOINT_TTL_MS: u64 = 5 * MINUTE_MS;
76const REALM_AND_ORG_MAX_LIFETIME_MS: u64 = 6 * HOUR_MS;
77const DOMAIN_RECORD_MAX_LIFETIME_MS: u64 = 7 * DAY_MS;
78const DEFAULT_MAX_LIFETIME_MS: u64 = 30 * DAY_MS;
79const DEFAULT_TTL_MS: u64 = 48 * HOUR_MS;
80
81/// A record's type tag.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
83pub struct RecordType(pub u8);
84
85impl RecordType {
86    pub const NODE_RECORD: RecordType = RecordType(0x01);
87    pub const REALM_DIRECTORY: RecordType = RecordType(0x03);
88    pub const REALM_STATIONS: RecordType = RecordType(0x04);
89    pub const REALM_MEMBER_ENDORSEMENT: RecordType = RecordType(0x05);
90    pub const PROCEDURE_ADVERTISEMENT: RecordType = RecordType(0x06);
91    pub const TOMBSTONE: RecordType = RecordType(0x0C);
92    pub const FOUNDATION_SEED_LIST: RecordType = RecordType(0x0D);
93    pub const FOUNDATION_PARAMETER: RecordType = RecordType(0x0E);
94    pub const FOUNDATION_REALM_TRUST_LIST: RecordType = RecordType(0x0F);
95    pub const FOUNDATION_T3_ATTESTATION: RecordType = RecordType(0x10);
96    pub const CONTENT_ANNOUNCEMENT: RecordType = RecordType(0x11);
97    pub const STATION_ENDPOINT: RecordType = RecordType(0x12);
98    pub const ORG_DIRECTORY: RecordType = RecordType(0x15);
99    pub const PROCEDURE_DELEGATION: RecordType = RecordType(0x16);
100    /// Tags from here to 0xFF are domain types, whose owners set their
101    /// payload rules.
102    pub const DOMAIN_MIN: RecordType = RecordType(0x20);
103
104    fn is_domain(self) -> bool {
105        self >= RecordType::DOMAIN_MIN
106    }
107}
108
109/// The refusals of a record, named as macula_record names them.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum RecordError {
112    /// A wire form over 256 KiB.
113    TooLarge,
114    /// A record without exactly the shape, fields and payload of its type,
115    /// and what.
116    Malformed(String),
117    /// Created more than 5 minutes ahead of the verifier's clock.
118    NotYetValid,
119    /// Expired more than 5 minutes before the verifier's clock.
120    Expired,
121    /// A payload that names a signer other than the key id of its key.
122    KeyIdMismatch,
123    /// A lifetime over its type's maximum.
124    LifetimeTooLong,
125    /// Expires no later than it is created.
126    LifetimeReversed,
127    /// A key whose purpose does not fit the type it would sign.
128    KeyPurposeMismatch,
129    /// A record with no key, tbs or signature to encode.
130    Unsigned,
131    /// A domain envelope for a built-in type.
132    NotADomainType,
133    /// A domain record's subject that is empty.
134    InvalidSubject,
135    /// A signature that does not verify.
136    SignatureInvalid,
137    /// A signed object whose alg names another profile's algorithm.
138    AlgMismatch,
139    /// A node record coordinate that is not a number within its range.
140    InvalidCoordinate(String),
141    /// A station endpoint's QUIC port of 0.
142    InvalidPort,
143    /// Not a tag 2 content id of 50 bytes.
144    NotAContentId,
145    /// A tombstone built to withdraw a tombstone.
146    TombstoneOfATombstone,
147    /// An org procedure's advertisement that carries no authorization.
148    NoAuthorization,
149    /// An authorization on a procedure whose namespace takes none.
150    AuthorizationNotAllowed,
151    /// An authorization in a form macula 12 does not have, a certificate
152    /// chain among them.
153    AuthorizationFormUnsupported,
154    /// An advertisement that is not in its advertiser's own namespace.
155    NotOwnNamespace,
156    /// An authorization checked without a trusted realm key.
157    NoRealmKey,
158    /// An org directory that does not verify as one, and why.
159    OrgDirectoryInvalid(String),
160    /// An org directory the trusted realm key did not sign, or for another
161    /// realm.
162    OrgDirectoryWrongRealm,
163    /// An org directory for another org.
164    OrgDirectoryWrongOrg,
165    /// A procedure delegation that does not verify as one, and why.
166    DelegationInvalid(String),
167    /// A delegation the org key did not sign, or for another advertiser.
168    DelegationMismatch,
169    /// An advertisement that expires after its org directory or delegation.
170    AuthorizationOutlived,
171    /// The key could not sign.
172    Key(KeyError),
173}
174
175impl fmt::Display for RecordError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            RecordError::Malformed(why) => write!(f, "malformed record: {why}"),
179            RecordError::InvalidCoordinate(what) => {
180                write!(f, "a coordinate outside its range: {what}")
181            }
182            RecordError::OrgDirectoryInvalid(why) => {
183                write!(f, "the org directory does not verify: {why}")
184            }
185            RecordError::DelegationInvalid(why) => {
186                write!(f, "the procedure delegation does not verify: {why}")
187            }
188            RecordError::Key(e) => write!(f, "{e}"),
189            other => write!(f, "{other:?}"),
190        }
191    }
192}
193
194impl std::error::Error for RecordError {}
195
196fn malformed(why: impl Into<String>) -> RecordError {
197    RecordError::Malformed(why.into())
198}
199
200/// What signing or verifying gave a record: the key as carried, its key id,
201/// the alg, the tbs bytes, and the signature.
202#[derive(Debug, Clone, PartialEq)]
203pub struct Signed {
204    pub key: Vec<u8>,
205    pub key_id: [u8; 32],
206    pub alg: String,
207    pub tbs: Vec<u8>,
208    pub signature: Vec<u8>,
209}
210
211/// A record: unsigned as a builder returns it, or signed or verified, when
212/// `signed` holds what signing or verifying gave. The payload is a map.
213/// `subject` names a domain record's subject, `None` for none and on every
214/// other type.
215#[derive(Debug, Clone, PartialEq)]
216pub struct Record {
217    pub record_type: RecordType,
218    pub version: [u8; 16],
219    pub created_at: u64,
220    pub expires_at: u64,
221    pub payload: Value,
222    pub subject: Option<Vec<u8>>,
223    pub signed: Option<Signed>,
224}
225
226/// A record of `record_type`, created now with a UUID v7 version, living
227/// `ttl_ms`, or its type's default when `ttl_ms` is 0.
228fn unsigned(record_type: RecordType, payload: Value, ttl_ms: u64) -> Record {
229    let ttl_ms = if ttl_ms == 0 {
230        default_ttl(record_type)
231    } else {
232        ttl_ms
233    };
234    let now = crate::uuid_v7::now_ms();
235    Record {
236        record_type,
237        version: crate::uuid_v7::new(),
238        created_at: now,
239        expires_at: now + ttl_ms,
240        payload,
241        subject: None,
242        signed: None,
243    }
244}
245
246/// An unsigned record of a domain type, 0x20 to 0xFF, with `subject`, living
247/// `ttl_ms`, or 48 hours when 0. An empty subject would name a slot apart
248/// from no subject, so it is refused.
249pub fn envelope(
250    record_type: u8,
251    payload: Value,
252    subject: Option<Vec<u8>>,
253    ttl_ms: u64,
254) -> Result<Record, RecordError> {
255    let t = RecordType(record_type);
256    if !t.is_domain() {
257        return Err(RecordError::NotADomainType);
258    }
259    if subject.as_ref().is_some_and(|s| s.is_empty()) {
260        return Err(RecordError::InvalidSubject);
261    }
262    let mut r = unsigned(t, payload, ttl_ms);
263    r.subject = subject;
264    Ok(r)
265}
266
267/// Signs `r` with `key`, as macula_record's sign/2 does. Refused, in
268/// macula's order: a key whose purpose does not fit the type; a lifetime that
269/// runs backwards or past its type's maximum; a payload that names a signer
270/// other than the key; a tbs or payload a verifier would refuse; and a record
271/// over 256 KiB.
272pub fn sign(r: &Record, key: &NodeKey) -> Result<Record, RecordError> {
273    let profile = key.profile();
274    if !signer_may_sign(r.record_type.0, &r.payload, key.purpose()) {
275        return Err(RecordError::KeyPurposeMismatch);
276    }
277    lifetime(r)?;
278    let carried = key.public_key();
279    let key_id = key_id_by_kind(r.record_type.0, &r.payload, &carried, profile);
280    if !named_signer(r.record_type, &r.payload, &key_id) {
281        return Err(RecordError::KeyIdMismatch);
282    }
283    let fields = tbs_fields(r);
284    let mut with_alg = fields.clone();
285    with_alg.push((Value::text("alg"), Value::text(profile.sig_alg())));
286    let encoded = cbor::encode(&Value::Map(with_alg)).map_err(|e| malformed(e.to_string()))?;
287    let tbs = cbor::decode(&encoded)
288        .map_err(|e| malformed(format!("a tbs the decoding rule refuses: {e}")))?;
289    match read_tbs(&tbs) {
290        Some(read) if payload::payload_ok(read.record_type, &read.payload) => {}
291        _ => return Err(malformed("a record a verifier would refuse")),
292    }
293    let unsigned_size = cbor::encode(&Value::Map(fields.clone()))
294        .map_err(|e| malformed(e.to_string()))?
295        .len()
296        + carried.len()
297        + signature_size(profile);
298    if unsigned_size > MAX_RECORD_BYTES {
299        return Err(RecordError::TooLarge);
300    }
301    let object = sign_object(LABEL, &fields, key).map_err(|e| match e {
302        ObjectError::Key(k) => RecordError::Key(k),
303        other => malformed(other.to_string()),
304    })?;
305    let wire = cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))?;
306    if wire.len() > MAX_RECORD_BYTES {
307        return Err(RecordError::TooLarge);
308    }
309    let mut out = r.clone();
310    out.signed = Some(Signed {
311        key: object.key,
312        key_id,
313        alg: profile.sig_alg().to_string(),
314        tbs: object.tbs,
315        signature: object.signature,
316    });
317    Ok(out)
318}
319
320/// `r` with a new version, created now, with the same lifetime, signed again
321/// with `key`.
322pub fn refresh(r: &Record, key: &NodeKey) -> Result<Record, RecordError> {
323    let now = crate::uuid_v7::now_ms();
324    let fresh = Record {
325        version: crate::uuid_v7::new(),
326        created_at: now,
327        expires_at: now + r.expires_at.saturating_sub(r.created_at),
328        signed: None,
329        ..r.clone()
330    };
331    sign(&fresh, key)
332}
333
334/// The wire form of a signed or verified record: its `{key, tbs, signature}`
335/// map, tbs unchanged.
336pub fn encode(r: &Record) -> Result<Vec<u8>, RecordError> {
337    let signed = r.signed.as_ref().ok_or(RecordError::Unsigned)?;
338    let object = Object {
339        key: signed.key.clone(),
340        tbs: signed.tbs.clone(),
341        signature: signed.signature.clone(),
342    };
343    cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))
344}
345
346/// A record [`verify`] returned. Only `verify` makes one.
347#[derive(Debug, Clone, PartialEq)]
348pub struct Verified(Record);
349
350impl Verified {
351    /// The verified record.
352    pub fn record(&self) -> &Record {
353        &self.0
354    }
355
356    /// The verified record, taken.
357    pub fn into_record(self) -> Record {
358        self.0
359    }
360}
361
362/// Reads a record's wire form under the verifier's `profile` and clock
363/// `now_ms`, as macula_record's verify/3 does, in this order: a wire form
364/// over 256 KiB, before anything is decoded; a signed object that carries its
365/// key; its signature and alg; a tbs of exactly its fields; created_at no more
366/// than 5 minutes ahead and expires_at no more than 5 minutes behind; a
367/// lifetime within its type's maximum; its type's payload rules; and a payload
368/// that names its signer by the key's id.
369pub fn verify(wire: &[u8], profile: Profile, now_ms: i64) -> Result<Verified, RecordError> {
370    if wire.len() > MAX_RECORD_BYTES {
371        return Err(RecordError::TooLarge);
372    }
373    let value = cbor::decode(wire).map_err(|e| malformed(e.to_string()))?;
374    let object = Object::from_value(&value).map_err(|e| malformed(e.to_string()))?;
375    let verified = verify_object(LABEL, &value, profile).map_err(|e| match e {
376        ObjectError::SignatureInvalid => RecordError::SignatureInvalid,
377        ObjectError::AlgMismatch => RecordError::AlgMismatch,
378        other => malformed(other.to_string()),
379    })?;
380    let mut r = read_tbs(&verified.fields).ok_or_else(|| malformed("a tbs of another shape"))?;
381    let created = r.created_at as i64;
382    let expires = r.expires_at as i64;
383    let tolerance = CLOCK_TOLERANCE_MS as i64;
384    if created > now_ms + tolerance {
385        return Err(RecordError::NotYetValid);
386    }
387    if expires + tolerance < now_ms {
388        return Err(RecordError::Expired);
389    }
390    lifetime(&r)?;
391    if !payload::payload_ok(r.record_type, &r.payload) {
392        return Err(malformed("a payload its type's rules refuse"));
393    }
394    let key_id = key_id_by_kind(r.record_type.0, &r.payload, &verified.key, profile);
395    if !named_signer(r.record_type, &r.payload, &key_id) {
396        return Err(RecordError::KeyIdMismatch);
397    }
398    r.signed = Some(Signed {
399        key: verified.key,
400        key_id,
401        alg: profile.sig_alg().to_string(),
402        tbs: verified.tbs,
403        signature: object.signature,
404    });
405    Ok(Verified(r))
406}
407
408/// Checks a payload before anything is signed: its encoding is at most 256
409/// KiB, and it nests at most 63 levels, which a record's tbs leaves it under
410/// the decoding rule's 64.
411pub fn payload_bounded(payload: &Value) -> Result<(), RecordError> {
412    let size = cbor::encode(payload)
413        .map_err(|e| malformed(e.to_string()))?
414        .len();
415    if size > MAX_RECORD_BYTES {
416        return Err(RecordError::TooLarge);
417    }
418    if nesting(payload, 0) > MAX_PAYLOAD_NESTING {
419        return Err(malformed("a payload nested past 63 levels"));
420    }
421    Ok(())
422}
423
424fn nesting(v: &Value, depth: usize) -> usize {
425    let children: Vec<&Value> = match v {
426        Value::List(items) => items.iter().collect(),
427        Value::Map(pairs) => pairs.iter().flat_map(|(k, v)| [k, v]).collect(),
428        _ => return depth,
429    };
430    let mut deepest = depth + 1;
431    for child in children {
432        if deepest > MAX_PAYLOAD_NESTING {
433            break;
434        }
435        deepest = deepest.max(nesting(child, depth + 1));
436    }
437    deepest
438}
439
440fn tbs_fields(r: &Record) -> Vec<(Value, Value)> {
441    let mut fields = vec![
442        (Value::text("type"), Value::Int(i128::from(r.record_type.0))),
443        (Value::text("version"), Value::Bytes(r.version.to_vec())),
444        (
445            Value::text("created_at"),
446            Value::Int(i128::from(r.created_at)),
447        ),
448        (
449            Value::text("expires_at"),
450            Value::Int(i128::from(r.expires_at)),
451        ),
452        (Value::text("payload"), r.payload.clone()),
453    ];
454    if let Some(subject) = &r.subject {
455        fields.push((Value::text("subject"), Value::Bytes(subject.clone())));
456    }
457    fields
458}
459
460/// A verified record's tbs, as macula_record's read_tbs does: exactly type
461/// (1 to 255), alg, version (16 bytes), created_at and expires_at (below
462/// 2^53), payload (a map), and subject (bytes, not empty) only on a domain
463/// type.
464fn read_tbs(tbs: &Value) -> Option<Record> {
465    let Value::Map(pairs) = tbs else {
466        return None;
467    };
468    let field = |name: &str| tbs.get(name);
469    let record_type = match field("type")? {
470        Value::Int(n) if (1..=255).contains(n) => RecordType(*n as u8),
471        _ => return None,
472    };
473    let Value::Text(_) = field("alg")? else {
474        return None;
475    };
476    let version: [u8; 16] = match field("version")? {
477        Value::Bytes(b) => b.as_slice().try_into().ok()?,
478        _ => return None,
479    };
480    let created_at = protocol_uint(field("created_at")?)?;
481    let expires_at = protocol_uint(field("expires_at")?)?;
482    let payload = field("payload")?;
483    if !matches!(payload, Value::Map(_)) {
484        return None;
485    }
486    let mut r = Record {
487        record_type,
488        version,
489        created_at,
490        expires_at,
491        payload: payload.clone(),
492        subject: None,
493        signed: None,
494    };
495    match (pairs.len(), field("subject")) {
496        (6, None) => Some(r),
497        (7, Some(Value::Bytes(subject))) if !subject.is_empty() && record_type.is_domain() => {
498            r.subject = Some(subject.clone());
499            Some(r)
500        }
501        _ => None,
502    }
503}
504
505fn protocol_uint(v: &Value) -> Option<u64> {
506    match v {
507        Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64),
508        _ => None,
509    }
510}
511
512/// Refuses a record whose lifetime does not run forward, or is longer than
513/// its type's maximum.
514fn lifetime(r: &Record) -> Result<(), RecordError> {
515    let lived = r.expires_at as i128 - r.created_at as i128;
516    if lived <= 0 {
517        return Err(RecordError::LifetimeReversed);
518    }
519    if lived > i128::from(max_lifetime(i128::from(r.record_type.0), &r.payload)) {
520        return Err(RecordError::LifetimeTooLong);
521    }
522    Ok(())
523}
524
525/// The withdrawn_type a tombstone's payload names, when it is an integer.
526fn withdrawn_type(payload: &Value) -> Option<Option<i128>> {
527    match payload.get("withdrawn_type") {
528        None => None,
529        Some(Value::Int(n)) => Some(Some(*n)),
530        Some(_) => Some(None),
531    }
532}
533
534/// The longest a record of type `t` lives, as macula_record's max_lifetime/2
535/// has it. A tombstone's follows the type it withdraws, plus twice the clock
536/// tolerance.
537fn max_lifetime(t: i128, payload: &Value) -> u64 {
538    match t {
539        0x01 => NODE_RECORD_MAX_LIFETIME_MS,
540        0x11 => CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS,
541        0x06 => PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS,
542        0x12 => STATION_ENDPOINT_TTL_MS,
543        0x04 | 0x15 | 0x16 => REALM_AND_ORG_MAX_LIFETIME_MS,
544        0x05 => MAX_ENDORSEMENT_WINDOW_MS,
545        0x0C => match withdrawn_type(payload) {
546            None | Some(Some(0x0C)) => DEFAULT_MAX_LIFETIME_MS,
547            Some(None) => DEFAULT_MAX_LIFETIME_MS + 2 * CLOCK_TOLERANCE_MS,
548            Some(Some(w)) => max_lifetime(w, &Value::Map(Vec::new())) + 2 * CLOCK_TOLERANCE_MS,
549        },
550        t if t >= 0x20 => DOMAIN_RECORD_MAX_LIFETIME_MS,
551        _ => DEFAULT_MAX_LIFETIME_MS,
552    }
553}
554
555/// The lifetime a builder given no ttl takes: 48 hours, or the type's
556/// maximum when shorter.
557fn default_ttl(t: RecordType) -> u64 {
558    DEFAULT_TTL_MS.min(max_lifetime(i128::from(t.0), &Value::Map(Vec::new())))
559}
560
561/// Whether a key of `purpose` may sign a record of type `t`, as
562/// macula_record's signer_purposes/2 has it: an identity key signs node
563/// records, procedure advertisements, content announcements, station
564/// endpoints and domain records; the realm, org and foundation types are
565/// their keys'; a tombstone is signed like the type it withdraws.
566fn signer_may_sign(t: u8, payload: &Value, purpose: Purpose) -> bool {
567    match t {
568        0x0C => match withdrawn_type(payload) {
569            Some(Some(w)) if (0..=255).contains(&w) && w != 0x0C => {
570                signer_may_sign(w as u8, &Value::Map(Vec::new()), purpose)
571            }
572            _ => false,
573        },
574        0x01 | 0x06 | 0x11 | 0x12 => purpose == Purpose::Identity,
575        t if t >= 0x20 => purpose == Purpose::Identity,
576        _ => false,
577    }
578}
579
580/// Whether a type is signed by some key at all, which a withdrawable type
581/// must be.
582fn signed_by_some_key(t: i128) -> bool {
583    matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x12 | 0x15 | 0x16) || t >= 0x20
584}
585
586/// Whether a record of type `t` names its signer by node_id rather than key
587/// id. A tombstone is named as the type it withdraws.
588fn named_by_node_id(t: u8, payload: &Value) -> bool {
589    let t = if t == 0x0C {
590        match withdrawn_type(payload) {
591            Some(Some(w)) => w,
592            _ => return false,
593        }
594    } else {
595        i128::from(t)
596    };
597    matches!(t, 0x01 | 0x06 | 0x11 | 0x12)
598}
599
600fn key_id_by_kind(t: u8, payload: &Value, carried: &[u8], profile: Profile) -> [u8; 32] {
601    if named_by_node_id(t, payload) {
602        node_id_of(carried, profile)
603    } else {
604        key_id_of(carried, profile)
605    }
606}
607
608/// Whether the payload field that names a type's signer, where the type has
609/// one, holds `key_id`.
610fn named_signer(t: RecordType, payload: &Value, key_id: &[u8; 32]) -> bool {
611    let name = match t {
612        RecordType::NODE_RECORD => "node_id",
613        RecordType::PROCEDURE_ADVERTISEMENT => "advertiser_node",
614        RecordType::CONTENT_ANNOUNCEMENT => "announcer_node",
615        RecordType::PROCEDURE_DELEGATION => "org_key",
616        _ => return true,
617    };
618    matches!(payload.get(name), Some(Value::Bytes(b)) if b.as_slice() == key_id)
619}
620
621/// A 32-byte id field of `payload`, or zeros.
622fn id_field(payload: &Value, name: &str) -> [u8; 32] {
623    match payload.get(name) {
624        Some(Value::Bytes(b)) if b.len() == 32 => b.as_slice().try_into().unwrap_or([0; 32]),
625        _ => [0; 32],
626    }
627}
628
629fn text_field(payload: &Value, name: &str) -> String {
630    match payload.get(name) {
631        Some(Value::Text(t)) => t.clone(),
632        _ => String::new(),
633    }
634}
635
636fn entry(name: &str, value: Value) -> (Value, Value) {
637    (Value::text(name), value)
638}