1mod 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
55pub const MAX_RECORD_BYTES: usize = 256 * 1024;
57
58pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS;
61
62pub 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
71const 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#[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 pub const DOMAIN_MIN: RecordType = RecordType(0x20);
103
104 fn is_domain(self) -> bool {
105 self >= RecordType::DOMAIN_MIN
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum RecordError {
112 TooLarge,
114 Malformed(String),
117 NotYetValid,
119 Expired,
121 KeyIdMismatch,
123 LifetimeTooLong,
125 LifetimeReversed,
127 KeyPurposeMismatch,
129 Unsigned,
131 NotADomainType,
133 InvalidSubject,
135 SignatureInvalid,
137 AlgMismatch,
139 InvalidCoordinate(String),
141 InvalidPort,
143 NotAContentId,
145 TombstoneOfATombstone,
147 NoAuthorization,
149 AuthorizationNotAllowed,
151 AuthorizationFormUnsupported,
154 NotOwnNamespace,
156 NoRealmKey,
158 OrgDirectoryInvalid(String),
160 OrgDirectoryWrongRealm,
163 OrgDirectoryWrongOrg,
165 DelegationInvalid(String),
167 DelegationMismatch,
169 AuthorizationOutlived,
171 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#[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#[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
226fn 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
246pub 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
267pub 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
320pub 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
334pub 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#[derive(Debug, Clone, PartialEq)]
348pub struct Verified(Record);
349
350impl Verified {
351 pub fn record(&self) -> &Record {
353 &self.0
354 }
355
356 pub fn into_record(self) -> Record {
358 self.0
359 }
360}
361
362pub 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
408pub 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
460fn 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
512fn 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
525fn 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
534fn 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
555fn default_ttl(t: RecordType) -> u64 {
558 DEFAULT_TTL_MS.min(max_lifetime(i128::from(t.0), &Value::Map(Vec::new())))
559}
560
561fn 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
580fn signed_by_some_key(t: i128) -> bool {
583 matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x12 | 0x15 | 0x16) || t >= 0x20
584}
585
586fn 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
608fn 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
621fn 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}