Skip to main content

macula_rust/
record.rs

1//! Wire 1.0's DHT records (WIRE_1_0.md section 5, macula#100). A record is
2//! the signed object `{key, tbs, signature}` under MACULA-PQ-RECORD-V1; its
3//! tbs holds type, alg, version, created_at, expires_at, schema,
4//! lifetime_class and payload, and subject and ext where present. [`sign`]
5//! refuses a key whose purpose does not fit the type. [`check_as_custodian`]
6//! runs the checks a station stores any record by, known type or not, and
7//! never reads a payload; [`verify`] runs them and then a reader's own, and
8//! keeps the tbs bytes, so [`encode`] sends them unchanged.
9//!
10//! A record is named by the key id of its key: the node_id for node records,
11//! procedure advertisements, content announcements and station endpoints, and
12//! the key id for every other type. A tombstone is named as the type it
13//! withdraws.
14
15mod 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
60/// The longest wire form a record may have, 256 KiB.
61pub const MAX_RECORD_BYTES: usize = 256 * 1024;
62
63/// How far a verifier's clock may be from a record's created_at and
64/// expires_at, 5 minutes.
65pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS;
66
67/// The longest a realm member endorsement admits its member, 30 days.
68pub const MAX_ENDORSEMENT_WINDOW_MS: u64 = 30 * DAY_MS;
69
70/// The schema version every built-in type has in wire 1.0; readers check it,
71/// custodians do not (WIRE_1_0.md section 5.1).
72pub const SCHEMA: u16 = 1;
73
74/// The frozen lifetime table (WIRE_1_0.md section 5.1): a record's
75/// lifetime_class is a row, and it lives at most that row's maximum. Rows are
76/// only added, and a row's maximum never grows.
77pub 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
93/// The rows the types this crate knows name.
94const 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/// A record's type tag.
102#[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    /// Tags from here to 0xFF are domain types, whose owners set their
123    /// payload rules.
124    pub const DOMAIN_MIN: RecordType = RecordType(0x20);
125
126    fn is_domain(self) -> bool {
127        self >= RecordType::DOMAIN_MIN
128    }
129}
130
131/// The refusals of a record, named as macula_record names them.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RecordError {
134    /// A wire form over 256 KiB.
135    TooLarge,
136    /// A record without exactly the shape, fields and payload of its type,
137    /// and what.
138    Malformed(String),
139    /// Created more than 5 minutes ahead of the verifier's clock.
140    NotYetValid,
141    /// Expired more than 5 minutes before the verifier's clock.
142    Expired,
143    /// A payload that names a signer other than the key id of its key.
144    KeyIdMismatch,
145    /// A lifetime over its type's maximum.
146    LifetimeTooLong,
147    /// Expires no later than it is created.
148    LifetimeReversed,
149    /// A key whose purpose does not fit the type it would sign.
150    KeyPurposeMismatch,
151    /// A record with no key, tbs or signature to encode.
152    Unsigned,
153    /// A domain envelope for a built-in type.
154    NotADomainType,
155    /// A domain record's subject that is empty.
156    InvalidSubject,
157    /// A signature that does not verify.
158    SignatureInvalid,
159    /// An alg the verifier's allowlist does not hold, refused before any
160    /// signature work (`alg_unsupported`).
161    AlgUnsupported,
162    /// A lifetime_class that names no row of the frozen table.
163    LifetimeClassUnknown,
164    /// A known type's lifetime_class that is not its own row.
165    LifetimeClassMismatch,
166    /// A type with no slot rule of its own that carries no subject to place
167    /// it by.
168    SubjectMissing,
169    /// A type the reader does not know; a custodian stores it.
170    TypeUnknown,
171    /// A schema the reader does not read; a custodian stores it.
172    SchemaUnsupported,
173    /// A content id whose tag the reader does not know; a custodian stores
174    /// it.
175    McidTagUnknown,
176    /// A critical extension the reader does not know, by name.
177    ExtensionUnsupported(String),
178    /// A node record coordinate that is not a number within its range.
179    InvalidCoordinate(String),
180    /// A station endpoint's QUIC port of 0.
181    InvalidPort,
182    /// Not a tag 2 content id of 50 bytes.
183    NotAContentId,
184    /// A tombstone built to withdraw a tombstone.
185    TombstoneOfATombstone,
186    /// An org procedure's advertisement that carries no authorization.
187    NoAuthorization,
188    /// An authorization on a procedure whose namespace takes none.
189    AuthorizationNotAllowed,
190    /// An authorization in a form macula 12 does not have, a certificate
191    /// chain among them.
192    AuthorizationFormUnsupported,
193    /// An advertisement that is not in its advertiser's own namespace.
194    NotOwnNamespace,
195    /// An authorization checked without a trusted realm key.
196    NoRealmKey,
197    /// An org directory that does not verify as one, and why.
198    OrgDirectoryInvalid(String),
199    /// An org directory the trusted realm key did not sign, or for another
200    /// realm.
201    OrgDirectoryWrongRealm,
202    /// An org directory for another org.
203    OrgDirectoryWrongOrg,
204    /// A procedure delegation that does not verify as one, and why.
205    DelegationInvalid(String),
206    /// A delegation the org key did not sign, or for another advertiser.
207    DelegationMismatch,
208    /// An advertisement that expires after its org directory or delegation.
209    AuthorizationOutlived,
210    /// The key could not sign.
211    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/// What signing or verifying gave a record: the key as carried, its key id,
240/// the alg, the tbs bytes, and the signature.
241#[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/// A record: unsigned as a builder returns it, or signed or verified, when
251/// `signed` holds what signing or verifying gave. The payload is a map.
252/// `subject` names a domain record's subject, `None` for none and on every
253/// other type. `schema` and `lifetime_class` are the tbs's schema version and
254/// lifetime row, which a builder sets to [`SCHEMA`] and the type's own row;
255/// `ext` is the tbs's ext map, `None` for none.
256#[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
270/// A record of `record_type`, created now with a UUID v7 version, living
271/// `ttl_ms`, or its type's default when `ttl_ms` is 0.
272fn 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
294/// An unsigned record of a domain type, 0x20 to 0xFF, with `subject`, living
295/// `ttl_ms`, or 48 hours when 0. An empty subject would name a slot apart
296/// from no subject, so it is refused.
297pub 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
315/// Signs `r` with `key`, as macula_record's sign/2 does. Refused, in
316/// macula's order: a key whose purpose does not fit the type; a lifetime that
317/// runs backwards or past its type's maximum; a payload that names a signer
318/// other than the key; a tbs or payload a verifier would refuse; and a record
319/// over 256 KiB.
320pub 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
371/// `r` with a new version, created now, with the same lifetime, signed again
372/// with `key`.
373pub 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
385/// The wire form of a signed or verified record: its `{key, tbs, signature}`
386/// map, tbs unchanged.
387pub 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/// A record [`verify`] returned. Only `verify` makes one.
398#[derive(Debug, Clone, PartialEq)]
399pub struct Verified(Record);
400
401impl Verified {
402    /// The verified record.
403    pub fn record(&self) -> &Record {
404        &self.0
405    }
406
407    /// The verified record, taken.
408    pub fn into_record(self) -> Record {
409        self.0
410    }
411}
412
413/// Reads a record's wire form as a reader does (WIRE_1_0.md section 5.2),
414/// under the verifier's `profile` and clock `now_ms`: a custodian's checks
415/// first ([`check_as_custodian`]'s), then a reader's own, in this order: a
416/// type it knows, a schema it reads; an unknown critical extension, an
417/// unknown non-critical one counted; the type's payload rules, a content id
418/// tag it knows, and a payload that names its signer by the key's id.
419pub 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
438/// Runs the checks a station stores a record by (WIRE_1_0.md section 5.2,
439/// steps 1 to 9), under `profile`'s alg allowlist and the clock `now_ms`, in
440/// this order: a wire form over 256 KiB; a signed object of key, tbs and
441/// signature; a tbs under the decoding rule holding alg as text; alg on the
442/// allowlist, and key in its carried form; the signature; a tbs of exactly
443/// the envelope keys, each of its type; the clock, with 5 minutes of
444/// tolerance; the lifetime against the row it names, then a known type's own
445/// row; the slot. It never reads a payload beyond its type's slot rule, never
446/// checks schema, and stores any type, known or not. It returns the record's
447/// storage key: its type's own slot, or for a type with none,
448/// H(type | signer key id | subject).
449pub 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
457/// Section 5.2's steps 1 to 9: the record read, signed with what verifying
458/// gave.
459fn 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
490/// A record's slot as a custodian places it: a known type by its own rule,
491/// any other by H(type | signer key id | subject), refusing one without a
492/// subject. A tombstone of a type this crate does not know takes that type's
493/// slot, with its payload's subject (WIRE_1_0.md section 5.1).
494fn 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
518/// A reader's step 10: a type this crate knows and a schema it reads.
519fn 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
529/// A reader's step 12 before the signer: the type's payload rules, with no
530/// subject on a built-in type, and a content id tag this crate knows.
531fn 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
543/// Refuses a record created more than the clock tolerance ahead of `now_ms`,
544/// or expired more than the tolerance before it.
545fn 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
556/// Checks a payload before anything is signed: its encoding is at most 256
557/// KiB, and it nests at most 63 levels, which a record's tbs leaves it under
558/// the decoding rule's 64.
559pub 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
616/// A verified record's tbs as section 5.2's step 6 reads it: exactly type
617/// (1 to 255), alg (text), version (16 bytes), created_at and expires_at
618/// (below 2^53), schema (1 to 65535), lifetime_class (below 2^53), payload (a
619/// map, not otherwise read), and, where present, subject (bytes, not empty)
620/// and ext (a well-formed ext map, not acted on).
621fn 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
683/// Refuses a record whose lifetime does not run forward, whose
684/// lifetime_class names no row of the frozen table, or that lives longer
685/// than its row's maximum (section 5.1). A tombstone may live twice the clock
686/// tolerance past its row: it outlives a record that may have been created
687/// up to the tolerance ahead, by the tolerance.
688fn 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
706/// The withdrawn_type a tombstone's payload names, when it is an integer.
707fn 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
715/// The row a record of type `t` names (section 5.1), and whether this crate
716/// knows the type: node record and content announcement 48 hours; procedure
717/// advertisement and station endpoint 5 minutes; realm stations and org
718/// directory 6 hours; procedure delegation, app record and station
719/// authorization 30 minutes; a domain type 7 days; the other built-in types it
720/// knows 30 days; and a tombstone the row of the type it withdraws, known only
721/// when that type is.
722fn 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
738/// The lifetime a builder given no ttl takes: 48 hours, or the type's row's
739/// maximum when that is shorter.
740fn 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
745/// Whether a key of `purpose` may sign a record of type `t`, as
746/// macula_record's signer_purposes/2 has it: an identity key signs node
747/// records, procedure advertisements, content announcements, station
748/// endpoints and domain records; the realm, org and foundation types are
749/// their keys'; a tombstone is signed like the type it withdraws.
750fn 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
764/// Whether a type is signed by some key at all, which a withdrawable type
765/// must be.
766fn signed_by_some_key(t: i128) -> bool {
767    matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x13 | 0x15..=0x17) || t >= 0x20
768}
769
770/// Whether a record of type `t` names its signer by node_id rather than key
771/// id. A tombstone is named as the type it withdraws.
772fn 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
792/// Whether the payload field that names a type's signer, where the type has
793/// one, holds `key_id`.
794fn 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
805/// A 32-byte id field of `payload`, or zeros.
806fn 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}